"""Authentication router: login, logout and current-user endpoints. The JWT access token is delivered as an **HttpOnly** cookie (``aegis_token``) so it is inaccessible to client-side JavaScript, mitigating XSS token-theft attacks. The JSON response also includes the token in the body for backwards compatibility and for clients that cannot use cookies (e.g. Swagger UI). """ import os from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status from fastapi.security import OAuth2PasswordRequestForm from slowapi import Limiter from slowapi.util import get_remote_address from sqlalchemy.orm import Session from jose import jwt, JWTError from app.auth import verify_password, create_access_token, blacklist_token from app.config import settings from app.database import get_db from app.dependencies.auth import get_current_user from app.models.user import User from app.schemas.auth import TokenResponse, UserOut # Rate limiter instance (shares backend state via app.state.limiter) limiter = Limiter(key_func=get_remote_address) router = APIRouter(prefix="/auth", tags=["auth"]) # Detect whether we're behind HTTPS (production) so the cookie can be Secure _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production" # Cookie name used to transport the JWT _COOKIE_NAME = "aegis_token" # --------------------------------------------------------------------------- # POST /auth/login # --------------------------------------------------------------------------- @router.post("/login", response_model=TokenResponse) @limiter.limit("5/minute") def login( request: Request, response: Response, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db), ): """Authenticate a user and return a JWT access token. Rate-limited to **5 attempts per minute per IP** to prevent brute-force attacks. The token is set as an HttpOnly cookie **and** returned in the JSON body for API/Swagger compatibility. """ user = db.query(User).filter(User.username == form_data.username).first() if user is None or not verify_password(form_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect username or password", ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled. Contact an administrator.", ) access_token = create_access_token(data={"sub": user.username}) # Set HttpOnly cookie — inaccessible from JS response.set_cookie( key=_COOKIE_NAME, value=access_token, httponly=True, secure=_IS_HTTPS, samesite="strict", max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/", ) return TokenResponse(access_token=access_token) # --------------------------------------------------------------------------- # POST /auth/logout # --------------------------------------------------------------------------- @router.post("/logout") def logout( request: Request, response: Response, aegis_token: str | None = Cookie(None), ): """Clear the authentication cookie and revoke the current token. The token's ``jti`` is added to the Redis blacklist so it cannot be reused even if the cookie has already been copied elsewhere. The blacklist entry auto-expires when the token's ``exp`` is reached. """ # Attempt to blacklist the token's jti token = aegis_token or request.headers.get("Authorization", "").removeprefix("Bearer ").strip() if token: try: payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], ) jti = payload.get("jti") exp = payload.get("exp", 0) if jti: blacklist_token(jti, float(exp)) except JWTError: pass # token already invalid — nothing to revoke response.delete_cookie( key=_COOKIE_NAME, httponly=True, secure=_IS_HTTPS, samesite="strict", path="/", ) return {"detail": "Logged out"} # --------------------------------------------------------------------------- # GET /auth/me # --------------------------------------------------------------------------- @router.get("/me", response_model=UserOut) def read_current_user(current_user: User = Depends(get_current_user)): """Return the profile of the currently authenticated user.""" return current_user