"""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 JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel, EmailStr 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 RefreshRequest(BaseModel): refresh_token: str class RegisterRequest(BaseModel): username: str email: EmailStr password: str over_18: bool # Simple boolean confirmation instead of date_of_birth 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. Includes a unique ``jti`` claim so that concurrent/same-second calls (e.g. back-to-back /refresh requests) still produce distinct tokens — without it, two tokens minted for the same user within the same second are byte-identical since ``sub``/``exp`` are the only claims. """ to_encode = data.copy() to_encode["jti"] = str(uuid.uuid4()) expire = datetime.now(timezone.utc) + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) to_encode.update({"exp": expire}) return jwt.encode( to_encode, settings.private_pem, algorithm=settings.ALGORITHM, ) def _create_refresh_token(data: dict) -> str: """Create a signed refresh token using RS256. Includes a unique ``jti`` claim so that concurrent calls produce distinct tokens even when issued within the same second. """ to_encode = data.copy() to_encode["jti"] = str(uuid.uuid4()) expire = datetime.now(timezone.utc) + timedelta( days=settings.REFRESH_TOKEN_EXPIRE_DAYS ) to_encode.update({"exp": expire}) return jwt.encode( to_encode, settings.private_pem, 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 _issue_refresh_token(user_id: int, db: AsyncSession) -> str: """Create and persist a refresh token for the given user.""" 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) return refresh_token_value # --- 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 over_18 boolean confirmation; rejects users who are not 18+. """ if not req.over_18: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Must be 18 or older to register", ) # 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=None, # No longer collecting DOB ) db.add(user) await db.flush() # flush to get user.id before creating refresh token await db.refresh(user) # Create access token access_token = _create_access_token({"sub": str(user.id)}) # Create and persist refresh token refresh_token_value = _issue_refresh_token(user.id, db) 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 and persist refresh token refresh_token_value = _issue_refresh_token(user.id, db) 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("/refresh", response_model=TokenResponse) async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)): """Exchange a valid refresh token for a new access token. Validates the refresh token against the database: - Must exist in the refresh_tokens table - Must not be expired (expires_at > now) - Must be active (is_active == true) On success, returns a new access token. The refresh token itself is NOT rotated (the client keeps using the same refresh token). """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", ) # Step 1: Decode and validate the JWT signature/claims try: payload = jwt.decode( req.refresh_token, settings.public_pem, algorithms=[settings.ALGORITHM], ) except JWTError: raise credentials_exception user_id_str: str | None = payload.get("sub") if user_id_str is None: raise credentials_exception try: user_id = int(user_id_str) except (ValueError, TypeError): raise credentials_exception # Step 2: Look up the refresh token in the database result = await db.execute( select(RefreshToken).where( RefreshToken.token == req.refresh_token, RefreshToken.user_id == user_id, ) ) stored_token = result.scalar_one_or_none() if stored_token is None: raise credentials_exception # Step 3: Check if the token is expired if datetime.now(timezone.utc) >= stored_token.expires_at: raise credentials_exception # Step 4: Check if the token is active if not stored_token.is_active: raise credentials_exception # Step 5: Issue a new access token access_token = _create_access_token({"sub": str(user_id)}) return TokenResponse( access_token=access_token, refresh_token=req.refresh_token, # Return the same refresh token 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