"""Authentication API: signup, login, and current-user retrieval.""" from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from jose import jwt from passlib.context import CryptContext from pydantic import BaseModel, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession from backend.api.deps import get_current_user, get_db from backend.config.settings import get_settings from backend.db.models import User from backend.db.repository import create_user, get_user_by_email, get_user_by_username router = APIRouter(prefix="/api/auth", tags=["auth"]) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # ── Schemas ────────────────────────────────────────────────────────── class SignupRequest(BaseModel): username: str = Field(..., min_length=3, max_length=80) email: EmailStr password: str = Field(..., min_length=6) class LoginRequest(BaseModel): username: str password: str class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" class UserResponse(BaseModel): id: str username: str email: str created_at: datetime model_config = {"from_attributes": True} # ── Helpers ────────────────────────────────────────────────────────── def _hash_password(password: str) -> str: return pwd_context.hash(password) def _verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) def _create_token(user_id: str) -> str: settings = get_settings() expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) payload = {"sub": user_id, "exp": expire} return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) # ── Routes ─────────────────────────────────────────────────────────── @router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def signup(body: SignupRequest, session: AsyncSession = Depends(get_db)): """Register a new user account.""" # Check for duplicate username if await get_user_by_username(session, body.username): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Username already taken", ) # Check for duplicate email if await get_user_by_email(session, body.email): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Email already registered", ) user = await create_user( session, username=body.username, email=body.email, hashed_password=_hash_password(body.password), ) return TokenResponse(access_token=_create_token(user.id)) @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, session: AsyncSession = Depends(get_db)): """Authenticate and receive a JWT.""" user = await get_user_by_username(session, body.username) if not user or not _verify_password(body.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password", ) return TokenResponse(access_token=_create_token(user.id)) @router.get("/me", response_model=UserResponse) async def me(current_user: User = Depends(get_current_user)): """Return the currently authenticated user.""" return current_user