"""Authentication router - login, register, token refresh.""" import uuid from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from jose import jwt from passlib.context import CryptContext from pydantic import BaseModel, EmailStr, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.dependencies import CurrentUser from app.models.session import RefreshToken from app.models.user import User pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") router = APIRouter() # --- Pydantic schemas --- class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" access_token_expires_minutes: int class RegisterRequest(BaseModel): username: str email: EmailStr password: str date_of_birth: str # ISO format date string (YYYY-MM-DD) @field_validator("date_of_birth") @classmethod def validate_date_of_birth(cls, v: str) -> str: """Validate that the date string is a valid date in YYYY-MM-DD format.""" try: datetime.strptime(v, "%Y-%m-%d") except ValueError: raise ValueError("date_of_birth must be in YYYY-MM-DD format") return v class UserResponse(BaseModel): id: int username: str email: str date_of_birth: str | None = None is_active: bool created_at: datetime | None = None model_config = {"from_attributes": True} # --- Helpers --- def _create_access_token(data: dict) -> str: """Create a signed access token using RS256.""" to_encode = data.copy() expire = datetime.now(timezone.utc) + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) to_encode.update({"exp": expire}) return jwt.encode( to_encode, settings.private_key, algorithm=settings.ALGORITHM, ) def _create_refresh_token(data: dict) -> str: """Create a signed refresh token using RS256.""" to_encode = data.copy() expire = datetime.now(timezone.utc) + timedelta( days=settings.REFRESH_TOKEN_EXPIRE_DAYS ) to_encode.update({"exp": expire}) return jwt.encode( to_encode, settings.private_key, algorithm=settings.ALGORITHM, ) def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a plain password against a hash.""" return pwd_context.verify(plain_password, hashed_password) def hash_password(password: str) -> str: """Hash a password.""" return pwd_context.hash(password) def calculate_age(birth_date, today=None) -> int: """Calculate age in years from a birth date. Args: birth_date: The person's date of birth. today: The reference date. Defaults to today's date (UTC). Returns: Age in whole years as of the reference date. """ if today is None: today = datetime.now(timezone.utc).date() age = today.year - birth_date.year # Adjust if birthday hasn't occurred yet this year if (today.month, today.day) < (birth_date.month, birth_date.day): age -= 1 return age # --- Endpoints --- @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): """Register a new user and return access + refresh tokens. Requires date_of_birth and rejects users under 18. """ # Parse and validate date_of_birth dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date() age = calculate_age(dob) if age < 18: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})", ) # Check if username already exists result = await db.execute(select(User).where(User.username == req.username)) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Username already registered", ) # Check if email already exists result = await db.execute(select(User).where(User.email == req.email)) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Email already registered", ) user = User( username=req.username, email=req.email, hashed_password=hash_password(req.password), date_of_birth=dob, ) db.add(user) await db.commit() await db.refresh(user) # Create access token access_token = _create_access_token({"sub": str(user.id)}) # Create refresh token and persist it refresh_token_value = _create_refresh_token({"sub": str(user.id)}) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), user_id=user.id, token=refresh_token_value, expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), ) db.add(refresh_token_record) await db.commit() return TokenResponse( access_token=access_token, refresh_token=refresh_token_value, access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, ) @router.post("/login", response_model=TokenResponse) async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): """Authenticate user and return access + refresh tokens. Accepts form-encoded credentials (username/password) as expected by OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly. """ result = await db.execute(select(User).where(User.username == form.username)) user = result.scalar_one_or_none() if not user or not verify_password(form.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive", ) # Create access token access_token = _create_access_token({"sub": str(user.id)}) # Create refresh token and persist it refresh_token_value = _create_refresh_token({"sub": str(user.id)}) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), user_id=user.id, token=refresh_token_value, expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), ) db.add(refresh_token_record) await db.commit() return TokenResponse( access_token=access_token, refresh_token=refresh_token_value, access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, ) @router.get("/me", response_model=UserResponse) async def get_current_user(current_user: CurrentUser): """Get current user profile from the JWT token.""" return current_user