"""Authentication router - login, register, token refresh.""" from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr, field_validator from jose import jwt from passlib.context import CryptContext from app.config import settings from app.database import get_db from app.dependencies import CurrentUser from app.models.user import User from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") router = APIRouter() # --- Pydantic schemas --- class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" 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: 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.SECRET_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: datetime) -> int: """Calculate age in years from a birth date.""" 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 an access token. 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) access_token = _create_access_token({"sub": str(user.id)}) return TokenResponse(access_token=access_token) @router.post("/login", response_model=TokenResponse) async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): """Authenticate user and return an access token. 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", ) access_token = _create_access_token({"sub": str(user.id)}) return TokenResponse(access_token=access_token) @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