"""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 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 class UserResponse(BaseModel): id: int username: str email: str 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) # --- 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.""" # 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), ) 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