"""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 JWTError, jwt from passlib.context import CryptContext from app.config import settings from app.database import get_db 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 LoginRequest(BaseModel): username: str password: str # --- 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: return pwd_context.verify(plain_password, hashed_password) def _get_password_hash(password: str) -> str: 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=_get_password_hash(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(req: LoginRequest, db: AsyncSession = Depends(get_db)): """Authenticate user and return an access token.""" result = await db.execute(select(User).where(User.username == req.username)) user = result.scalar_one_or_none() if not user or not _verify_password(req.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") async def get_current_user( # In production, extract from Authorization header via OAuth2PasswordBearer # For now, accept a user_id query param for simplicity user_id: int, db: AsyncSession = Depends(get_db), ): """Get current user profile.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") return { "id": user.id, "username": user.username, "email": user.email, "is_active": user.is_active, "created_at": user.created_at, }