from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.jwt import ( create_access_token, create_refresh_token, hash_refresh_token, get_token_expiry, ) from app.auth.password import verify_password from app.database import get_db from app.middleware.rate_limit import limiter from app.models.user import User, RefreshToken from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest router = APIRouter(prefix="/auth", tags=["Auth"]) @router.post("/login", response_model=TokenResponse) @limiter.limit("5/minute") async def login( request: Request, response: Response, data: LoginRequest, db: AsyncSession = Depends(get_db), ): """ Authenticate with email and password. Returns access token (15 min) and refresh token (7 days). """ result = await db.execute(select(User).where(User.email == data.email)) user = result.scalars().first() if user is None or not verify_password(data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", headers={"WWW-Authenticate": "Bearer"}, ) if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive", ) access_token = create_access_token(data={"sub": str(user.id)}) plaintext_refresh, refresh_hash = create_refresh_token() refresh_token_row = RefreshToken( user_id=user.id, token_hash=refresh_hash, expires_at=get_token_expiry(), revoked=False, created_at=datetime.now(timezone.utc), ) db.add(refresh_token_row) await db.flush() return TokenResponse( access_token=access_token, refresh_token=plaintext_refresh, token_type="bearer", ) @router.post("/refresh", response_model=TokenResponse) @limiter.limit("5/minute") async def refresh_tokens( request: Request, response: Response, data: RefreshRequest, db: AsyncSession = Depends(get_db), ): """ Exchange a valid refresh token for a new token pair. The old refresh token is revoked atomically. """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", headers={"WWW-Authenticate": "Bearer"}, ) now = datetime.now(timezone.utc) token_hash = hash_refresh_token(data.refresh_token) result = await db.execute( select(RefreshToken).where( RefreshToken.token_hash == token_hash, RefreshToken.revoked == False, RefreshToken.expires_at > now, ) ) matched_row = result.scalars().first() if matched_row is None: raise credentials_exception # Revoke old token matched_row.revoked = True # Load user result = await db.execute(select(User).where(User.id == matched_row.user_id)) user = result.scalars().first() if user is None or not user.is_active: raise credentials_exception # Issue new tokens access_token = create_access_token(data={"sub": str(user.id)}) plaintext_refresh, refresh_hash = create_refresh_token() new_refresh_row = RefreshToken( user_id=user.id, token_hash=refresh_hash, expires_at=get_token_expiry(), revoked=False, created_at=now, ) db.add(new_refresh_row) await db.flush() return TokenResponse( access_token=access_token, refresh_token=plaintext_refresh, token_type="bearer", )