"""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 import os # Import APIRouter, Cookie, Depends, Request, Response from fastapi from fastapi import APIRouter, Cookie, Depends, Request, Response # Import OAuth2PasswordRequestForm from fastapi.security from fastapi.security import OAuth2PasswordRequestForm # Import jwt (PyJWT) import jwt # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import blacklist_token, create_access_token, verify_pa... from app.auth from app.auth import blacklist_token, create_access_token, verify_password # Import settings from app.config from app.config import settings # Import get_db from app.database from app.database import get_db # Import get_current_user from app.dependencies.auth from app.dependencies.auth import get_current_user # Import BusinessRuleViolation, PermissionViolation from app.domain.errors from app.domain.errors import BusinessRuleViolation, PermissionViolation # Import UnitOfWork from app.domain.unit_of_work from app.domain.unit_of_work import UnitOfWork # Import limiter from app.limiter from app.limiter import limiter # Import resolve_client_ip from app.middleware.request_context from app.middleware.request_context import resolve_client_ip # Import User from app.models.user from app.models.user import User # Import TokenResponse, UserOut from app.schemas.auth from app.schemas.auth import TokenResponse, UserOut # Import PasswordChange from app.schemas.user from app.schemas.user import PasswordChange # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import from app.services.auth_service from app.services.auth_service import ( _DUMMY_HASH, ) # Import from app.services.auth_service from app.services.auth_service import ( change_password as auth_change_password, ) # Assign router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"]) # Assign _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production" _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production" # Assign _COOKIE_NAME = "aegis_token" _COOKIE_NAME = "aegis_token" # Apply the @router.post decorator @router.post("/login", response_model=TokenResponse) # Apply the @limiter.limit decorator @limiter.limit("5/minute") # Define function login def login( # Entry: request request: Request, # Entry: response response: Response, # Entry: form_data form_data: OAuth2PasswordRequestForm = Depends(), # Entry: db db: Session = Depends(get_db), ) -> TokenResponse: """Authenticate a user and return a JWT access token. Rate-limited to **5 attempts per minute per IP**. Failed and successful logins are recorded in the audit log (SEC-009). """ # Assign user = db.query(User).filter(User.username == form_data.username).first() user = db.query(User).filter(User.username == form_data.username).first() # Assign target_hash = user.hashed_password if user else _DUMMY_HASH target_hash = user.hashed_password if user else _DUMMY_HASH # Assign password_valid = verify_password(form_data.password, target_hash) password_valid = verify_password(form_data.password, target_hash) # Assign ip = resolve_client_ip(request) ip = resolve_client_ip(request) # Check: user is None or not password_valid if user is None or not password_valid: # Open context manager with UnitOfWork(db) as uow: # Call log_action() log_action( db, user.id if user else None, # Literal argument value "LOGIN_FAILED", # Literal argument value "auth", # Literal argument value None, # Keyword argument: details details={ # Literal argument value "username": form_data.username, # Literal argument value "ip": ip, # Literal argument value "reason": "invalid_credentials", }, # Keyword argument: ip_address ip_address=ip, ) # Call uow.commit() uow.commit() # Raise BusinessRuleViolation raise BusinessRuleViolation("Incorrect username or password") # Check: not user.is_active if not user.is_active: # Raise PermissionViolation raise PermissionViolation("Account is disabled. Contact an administrator.") # Assign access_token = create_access_token(data={"sub": user.username}) access_token = create_access_token(data={"sub": user.username}) # Open context manager with UnitOfWork(db) as uow: # Call log_action() log_action( db, user.id, # Literal argument value "LOGIN_SUCCESS", # Literal argument value "auth", str(user.id), # Keyword argument: details details={"username": user.username, "ip": ip}, # Keyword argument: ip_address ip_address=ip, ) # Call uow.commit() uow.commit() # Call response.set_cookie() response.set_cookie( # Keyword argument: key key=_COOKIE_NAME, # Keyword argument: value value=access_token, # Keyword argument: httponly httponly=True, # Keyword argument: secure secure=_IS_HTTPS, # Keyword argument: samesite samesite="strict", # Keyword argument: max_age max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Keyword argument: path path="/", ) # Return TokenResponse(access_token=access_token) return TokenResponse(access_token=access_token) # Apply the @router.post decorator @router.post("/logout") # Define function logout def logout( # Entry: request request: Request, # Entry: response response: Response, # Entry: aegis_token aegis_token: str | None = Cookie(None), ) -> dict: """Clear the authentication cookie and revoke the current token.""" # Assign bearer = ( bearer = ( request.headers.get("Authorization") or request.headers.get("authorization") or "" ) # Assign bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip() bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip() # Assign seen = set() seen: set[str] = set() # Iterate over (aegis_token, bearer) for raw in (aegis_token, bearer): # Check: not raw or raw in seen if not raw or raw in seen: # Skip to the next loop iteration continue # Call seen.add() seen.add(raw) # Attempt the following; catch errors below try: # Assign payload = jwt.decode( payload = jwt.decode( raw, settings.SECRET_KEY, # Keyword argument: algorithms algorithms=[settings.ALGORITHM], ) # Assign jti = payload.get("jti") jti = payload.get("jti") # Assign exp = payload.get("exp", 0) exp = payload.get("exp", 0) # Check: jti if jti: # Call blacklist_token() blacklist_token(jti, float(exp)) # Handle any JWT validation error during logout (token may be expired or malformed) except jwt.exceptions.InvalidTokenError: # Intentional no-op placeholder pass # Call response.delete_cookie() response.delete_cookie( # Keyword argument: key key=_COOKIE_NAME, # Keyword argument: httponly httponly=True, # Keyword argument: secure secure=_IS_HTTPS, # Keyword argument: samesite samesite="strict", # Keyword argument: path path="/", ) # Return {"detail": "Logged out"} return {"detail": "Logged out"} # Apply the @router.get decorator @router.get("/me", response_model=UserOut) # Define function read_current_user def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut: """Return the profile of the currently authenticated user.""" # Return current_user return current_user # Apply the @router.post decorator @router.post("/change-password") # Define function change_password def change_password( # Entry: body body: PasswordChange, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> dict: """Change the current user's password.""" # Call auth_change_password() auth_change_password( db, current_user, # Keyword argument: current_password current_password=body.current_password, # Keyword argument: new_password new_password=body.new_password, ) # Open context manager with UnitOfWork(db) as uow: # Call uow.commit() uow.commit() # Return {"detail": "Password changed successfully"} return {"detail": "Password changed successfully"}