| from typing import Optional, List |
| from fastapi import APIRouter, Depends, HTTPException, status |
| from pydantic import BaseModel, Field |
|
|
| from backend.services.token_manager import TokenManager |
| from backend.api.deps.auth_deps import get_current_user, get_token_manager |
|
|
| router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) |
|
|
|
|
| |
| |
| |
| class LoginRequest(BaseModel): |
| username: str = Field(..., example="demo_user") |
| password: str = Field(..., example="hyperflow2026") |
| role: Optional[str] = Field("recruiter_evaluator", example="recruiter_evaluator") |
| scopes: Optional[List[str]] = Field( |
| default=["orders:read", "orders:write", "inventory:read", "ml:view"], |
| example=["orders:read", "orders:write", "inventory:read", "ml:view"] |
| ) |
|
|
|
|
| class TokenRefreshRequest(BaseModel): |
| refresh_token: str = Field(..., description="Active Refresh Token") |
|
|
|
|
| class LogoutRequest(BaseModel): |
| refresh_token: Optional[str] = Field(None, description="Optional Refresh Token to revoke alongside Access Token") |
|
|
|
|
| class TokenResponse(BaseModel): |
| status: str = "success" |
| access_token: str |
| refresh_token: str |
| token_type: str = "bearer" |
| expires_in: int |
| access_jti: str |
| refresh_jti: str |
| message: str = "Dual token pair issued successfully." |
|
|
|
|
| |
| |
| |
| @router.post("/login", response_model=TokenResponse) |
| async def login( |
| req: LoginRequest, |
| token_mgr: TokenManager = Depends(get_token_manager) |
| ): |
| """ |
| Authenticates user credentials and issues an Enterprise Dual-Token Pair. |
| Access Token (15 min) + Refresh Token (7 days) with unique JTIs. |
| """ |
| |
| if not req.username or not req.password: |
| raise HTTPException( |
| status_code=status.HTTP_400_BAD_REQUEST, |
| detail="Username and password are required." |
| ) |
|
|
| pair = token_mgr.issue_token_pair( |
| sub=req.username, |
| role=req.role or "user", |
| scopes=req.scopes |
| ) |
|
|
| return TokenResponse( |
| status="success", |
| access_token=pair["access_token"], |
| refresh_token=pair["refresh_token"], |
| token_type="bearer", |
| expires_in=pair["expires_in"], |
| access_jti=pair["access_jti"], |
| refresh_jti=pair["refresh_jti"], |
| message=f"Enterprise dual token pair issued for {req.username}." |
| ) |
|
|
|
|
| @router.post("/refresh", response_model=TokenResponse) |
| async def refresh_tokens( |
| req: TokenRefreshRequest, |
| token_mgr: TokenManager = Depends(get_token_manager) |
| ): |
| """ |
| Refresh Token Rotation endpoint. |
| Validates refresh token JTI, revokes old refresh token in Redis, |
| and issues a brand new Access + Refresh token pair. |
| """ |
| try: |
| payload = token_mgr.verify_refresh_token(req.refresh_token) |
| except ValueError as e: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail=f"Refresh failed: {str(e)}" |
| ) |
|
|
| sub = payload["sub"] |
| role = payload.get("role", "user") |
| old_refresh_jti = payload["jti"] |
| old_refresh_exp = payload["exp"] |
|
|
| |
| token_mgr.revoke_jti(old_refresh_jti, old_refresh_exp) |
|
|
| |
| pair = token_mgr.issue_token_pair( |
| sub=sub, |
| role=role, |
| scopes=["orders:read", "orders:write", "inventory:read", "ml:view"] |
| ) |
|
|
| return TokenResponse( |
| status="success", |
| access_token=pair["access_token"], |
| refresh_token=pair["refresh_token"], |
| token_type="bearer", |
| expires_in=pair["expires_in"], |
| access_jti=pair["access_jti"], |
| refresh_jti=pair["refresh_jti"], |
| message="Token pair successfully rotated." |
| ) |
|
|
|
|
| @router.post("/logout") |
| async def logout( |
| req: LogoutRequest, |
| current_user: dict = Depends(get_current_user), |
| token_mgr: TokenManager = Depends(get_token_manager) |
| ): |
| """ |
| Logs out user by adding current Access Token JTI (and optional Refresh Token JTI) |
| to the Redis JTI Revocation Blacklist with TTL. |
| """ |
| access_jti = current_user.get("jti") |
| access_exp = current_user.get("exp") |
|
|
| if access_jti and access_exp: |
| token_mgr.revoke_jti(access_jti, access_exp) |
|
|
| revoked_refresh = False |
| if req.refresh_token: |
| try: |
| refresh_payload = token_mgr.decode_token(req.refresh_token) |
| ref_jti = refresh_payload.get("jti") |
| ref_exp = refresh_payload.get("exp") |
| if ref_jti and ref_exp: |
| token_mgr.revoke_jti(ref_jti, ref_exp) |
| revoked_refresh = True |
| except ValueError: |
| pass |
|
|
| return { |
| "status": "success", |
| "message": f"Successfully revoked access token (JTI: {access_jti}).", |
| "access_jti_revoked": access_jti, |
| "refresh_jti_revoked": revoked_refresh |
| } |
|
|
|
|
| @router.post("/logout-all") |
| async def logout_all( |
| current_user: dict = Depends(get_current_user), |
| token_mgr: TokenManager = Depends(get_token_manager) |
| ): |
| """ |
| Mass Revocation (Logout All Sessions). |
| Blacklists all active JTIs registered for the authenticated user ID in Redis. |
| """ |
| sub = current_user["sub"] |
| count = token_mgr.revoke_all_user_jtis(sub) |
|
|
| return { |
| "status": "success", |
| "message": f"Mass revocation completed for user '{sub}'.", |
| "revoked_sessions_count": count |
| } |
|
|
|
|
| @router.get("/me") |
| async def get_my_profile(current_user: dict = Depends(get_current_user)): |
| """Authenticated user profile endpoint verified against Redis JTI blacklist.""" |
| return { |
| "status": "success", |
| "user": { |
| "sub": current_user["sub"], |
| "role": current_user.get("role"), |
| "scopes": current_user.get("scopes", []), |
| "token_jti": current_user.get("jti"), |
| "token_exp": current_user.get("exp"), |
| "issued_at": current_user.get("iat") |
| } |
| } |
|
|
|
|
| @router.get("/jti-status/{jti}") |
| async def get_jti_status( |
| jti: str, |
| token_mgr: TokenManager = Depends(get_token_manager) |
| ): |
| """Inspection endpoint for monitoring JTI revocation state and remaining TTL in Redis.""" |
| status_info = token_mgr.get_jti_status(jti) |
| return { |
| "status": "success", |
| "revocation_details": status_info |
| } |
|
|
|
|
| |
| @router.post("/demo") |
| async def demo_login(token_mgr: TokenManager = Depends(get_token_manager)): |
| pair = token_mgr.issue_token_pair( |
| sub="demo_user", |
| role="recruiter_evaluator", |
| scopes=["orders:read", "orders:write", "inventory:read", "ml:view"] |
| ) |
| return { |
| "status": "success", |
| "token": pair["access_token"], |
| "access_token": pair["access_token"], |
| "refresh_token": pair["refresh_token"], |
| "access_jti": pair["access_jti"], |
| "refresh_jti": pair["refresh_jti"], |
| "message": "Enterprise dual token demo access granted." |
| } |
|
|