This commit is contained in:
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/auth/alembic/script.py.mako
Normal file
22
services/auth/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
38
services/auth/alembic/versions/df0effc5d87a_init.py
Normal file
38
services/auth/alembic/versions/df0effc5d87a_init.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""init
|
||||
|
||||
Revision ID: df0effc5d87a
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:03.816755+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'df0effc5d87a'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('role', sa.String(length=32), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
@@ -5,6 +5,9 @@ psycopg2-binary
|
||||
alembic
|
||||
pydantic>=2
|
||||
pydantic-settings
|
||||
pydantic[email]
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
passlib[bcrypt]>=1.7
|
||||
|
||||
55
services/auth/src/app/api/routes/auth.py
Normal file
55
services/auth/src/app/api/routes/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
|
||||
from app.services.user_service import UserService
|
||||
from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["auth"])
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=201)
|
||||
def register(payload: UserCreate, db: Session = Depends(get_db)):
|
||||
svc = UserService(db)
|
||||
try:
|
||||
user = svc.create_user(email=payload.email, password=payload.password,
|
||||
full_name=payload.full_name, role=payload.role)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return user
|
||||
|
||||
@router.post("/token", response_model=TokenPair)
|
||||
def token(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
svc = UserService(db)
|
||||
user = svc.authenticate(email=payload.email, password=payload.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
|
||||
refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
|
||||
return TokenPair(access_token=access, refresh_token=refresh)
|
||||
|
||||
class RefreshRequest(LoginRequest.__class__):
|
||||
refresh_token: str # type: ignore
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair)
|
||||
def refresh_token(req: dict):
|
||||
# expects: {"refresh_token": "<token>"}
|
||||
from app.core.security import decode_token
|
||||
token = req.get("refresh_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="Missing refresh_token")
|
||||
claims = decode_token(token)
|
||||
if claims.type != "refresh":
|
||||
raise HTTPException(status_code=400, detail="Not a refresh token")
|
||||
access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
|
||||
refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
|
||||
return TokenPair(access_token=access, refresh_token=refresh)
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
svc = UserService(db)
|
||||
u = svc.get_user(claims.sub)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return u
|
||||
51
services/auth/src/app/api/routes/users.py
Normal file
51
services/auth/src/app/api/routes/users.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import require_roles
|
||||
from app.schemas.user import UserRead, UserUpdate, UserCreate
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/v1/users", tags=["users"])
|
||||
|
||||
@router.get("", response_model=list[UserRead])
|
||||
def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_roles("ADMIN"))):
|
||||
return UserService(db).list_users(offset=offset, limit=limit)
|
||||
|
||||
@router.post("", response_model=UserRead, status_code=201)
|
||||
def create_user(payload: UserCreate, db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_roles("ADMIN"))):
|
||||
try:
|
||||
return UserService(db).create_user(email=payload.email, password=payload.password,
|
||||
full_name=payload.full_name, role=payload.role)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
def get_user(user_id: str, db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_roles("ADMIN"))):
|
||||
u = UserService(db).get_user(user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return u
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_roles("ADMIN"))):
|
||||
svc = UserService(db)
|
||||
u = svc.get_user(user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return svc.update_user(u, full_name=payload.full_name, role=payload.role,
|
||||
is_active=payload.is_active, password=payload.password)
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
def delete_user(user_id: str, db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_roles("ADMIN"))):
|
||||
svc = UserService(db)
|
||||
u = svc.get_user(user_id)
|
||||
if not u:
|
||||
return
|
||||
svc.delete_user(u)
|
||||
9
services/auth/src/app/core/passwords.py
Normal file
9
services/auth/src/app/core/passwords.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def hash_password(p: str) -> str:
|
||||
return _pwd.hash(p)
|
||||
|
||||
def verify_password(p: str, hashed: str) -> bool:
|
||||
return _pwd.verify(p, hashed)
|
||||
65
services/auth/src/app/core/security.py
Normal file
65
services/auth/src/app/core/security.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
|
||||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
|
||||
REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
|
||||
|
||||
class TokenType(str, Enum):
|
||||
access = "access"
|
||||
refresh = "refresh"
|
||||
|
||||
class UserClaims(BaseModel):
|
||||
sub: str
|
||||
email: str
|
||||
role: str
|
||||
type: str
|
||||
exp: int
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
|
||||
|
||||
def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = now + timedelta(minutes=expires_minutes)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"type": token_type.value,
|
||||
"exp": int(exp.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
def create_access_token(*, sub: str, email: str, role: str) -> str:
|
||||
return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
|
||||
|
||||
def create_refresh_token(*, sub: str, email: str, role: str) -> str:
|
||||
return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
|
||||
|
||||
def decode_token(token: str) -> UserClaims:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return UserClaims(**payload)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
|
||||
return decode_token(token)
|
||||
|
||||
def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
|
||||
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
|
||||
if roles and user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||||
return user
|
||||
return dep
|
||||
@@ -1,5 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from .api.routes.ping import router as ping_router
|
||||
from .api.routes.auth import router as auth_router
|
||||
from .api.routes.users import router as users_router
|
||||
|
||||
app = FastAPI(title="AUTH Service")
|
||||
|
||||
@@ -7,5 +9,6 @@ app = FastAPI(title="AUTH Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "auth"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .user import User, Role # noqa: F401
|
||||
|
||||
28
services/auth/src/app/models/user.py
Normal file
28
services/auth/src/app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class Role(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
MATCHMAKER = "MATCHMAKER"
|
||||
CLIENT = "CLIENT"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
41
services/auth/src/app/repositories/user_repository.py
Normal file
41
services/auth/src/app/repositories/user_repository.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, update, delete
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get(self, user_id) -> Optional[User]:
|
||||
return self.db.get(User, user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
stmt = select(User).where(User.email == email)
|
||||
return self.db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
|
||||
stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
|
||||
return self.db.execute(stmt).scalars().all()
|
||||
|
||||
def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
|
||||
user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def update(self, user: User, **fields) -> User:
|
||||
for k, v in fields.items():
|
||||
if v is not None:
|
||||
setattr(user, k, v)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def delete(self, user: User) -> None:
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
38
services/auth/src/app/schemas/user.py
Normal file
38
services/auth/src/app/schemas/user.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, ConfigDict
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: Optional[str] = None
|
||||
role: str = "CLIENT"
|
||||
is_active: bool = True
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
role: str = "CLIENT"
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
class UserRead(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
full_name: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
48
services/auth/src/app/services/user_service.py
Normal file
48
services/auth/src/app/services/user_service.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.core.passwords import hash_password, verify_password
|
||||
from app.models.user import User
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: Session):
|
||||
self.repo = UserRepository(db)
|
||||
|
||||
# CRUD
|
||||
def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
|
||||
if self.repo.get_by_email(email):
|
||||
raise ValueError("Email already in use")
|
||||
pwd_hash = hash_password(password)
|
||||
return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
|
||||
|
||||
def get_user(self, user_id) -> Optional[User]:
|
||||
return self.repo.get(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
return self.repo.get_by_email(email)
|
||||
|
||||
def list_users(self, *, offset: int = 0, limit: int = 50):
|
||||
return self.repo.list(offset=offset, limit=limit)
|
||||
|
||||
def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
|
||||
is_active: bool | None = None, password: str | None = None) -> User:
|
||||
fields = {}
|
||||
if full_name is not None: fields["full_name"] = full_name
|
||||
if role is not None: fields["role"] = role
|
||||
if is_active is not None: fields["is_active"] = is_active
|
||||
if password: fields["password_hash"] = hash_password(password)
|
||||
return self.repo.update(user, **fields)
|
||||
|
||||
def delete_user(self, user: User) -> None:
|
||||
self.repo.delete(user)
|
||||
|
||||
# Auth
|
||||
def authenticate(self, *, email: str, password: str) -> Optional[User]:
|
||||
user = self.repo.get_by_email(email)
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
Reference in New Issue
Block a user