Spaces:
Running
Running
AuthorBot
Deliver enterprise features with JWT blacklist, SuperAdmin TOTP, and CI hardening.
007cdd2 | """Author RAG — Auth + misc schema routes mounted at /api. | |
| Routes: | |
| POST /api/auth/register | |
| POST /api/auth/login | |
| POST /api/auth/refresh | |
| POST /api/auth/logout | |
| GET /api/auth/me | |
| """ | |
| import time | |
| from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.config import get_settings | |
| from app.core.access.jwt_blacklist import blacklist_token, is_blacklisted | |
| from app.core.access.token_crypto import decode_jwt | |
| from app.dependencies import get_db, get_current_user, get_redis | |
| from pydantic import BaseModel, Field | |
| from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse, RefreshRequest | |
| from app.services.auth_service import AuthService, _create_token_pair | |
| router = APIRouter() | |
| class SsoExchangeRequest(BaseModel): | |
| """One-time code from SAML redirect.""" | |
| code: str = Field(..., min_length=20, max_length=200) | |
| def _cookie_secure() -> bool: | |
| return get_settings().APP_ENV == "production" | |
| def _set_refresh_cookie(response: Response, refresh_token: str) -> None: | |
| response.set_cookie( | |
| "refresh_token", | |
| refresh_token, | |
| httponly=True, | |
| secure=_cookie_secure(), | |
| samesite="lax", | |
| max_age=7 * 24 * 60 * 60, | |
| path="/", | |
| ) | |
| async def sso_exchange( | |
| body: SsoExchangeRequest, | |
| response: Response, | |
| db: AsyncSession = Depends(get_db), | |
| redis=Depends(get_redis), | |
| ): | |
| """Exchange one-time SSO code (from SAML redirect) for JWT pair.""" | |
| from app.services.sso_exchange_service import consume_sso_code | |
| from app.models.user import User | |
| user_id = await consume_sso_code(redis, body.code) | |
| if not user_id: | |
| raise HTTPException(401, "Invalid or expired SSO login code. Please sign in again.") | |
| user = await db.get(User, user_id) | |
| if not user or not user.is_active: | |
| raise HTTPException(401, "Account not found or inactive.") | |
| tokens = _create_token_pair(user.id) | |
| _set_refresh_cookie(response, tokens["refresh_token"]) | |
| return { | |
| **tokens, | |
| "is_superadmin": user.role == "superadmin", | |
| "role": user.role, | |
| "author_slug": user.id, | |
| } | |
| async def register(payload: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)): | |
| """Register a new author account.""" | |
| try: | |
| result = await AuthService(db).register( | |
| email=payload.email, | |
| password=payload.password, | |
| full_name=payload.full_name, | |
| referral_code=payload.referral_code, | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| _set_refresh_cookie(response, result["refresh_token"]) | |
| return result | |
| async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): | |
| """Authenticate author and return JWT token pair.""" | |
| result = await AuthService(db).login(email=payload.email, password=payload.password) | |
| _set_refresh_cookie(response, result["refresh_token"]) | |
| return result | |
| async def refresh( | |
| request: Request, | |
| response: Response, | |
| authorization: str = Header(default=""), | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Issue new JWT token pair and blacklist the old access token.""" | |
| from fastapi import HTTPException | |
| refresh_token = request.cookies.get("refresh_token") | |
| if not refresh_token: | |
| try: | |
| body = await request.json() | |
| refresh_token = body.get("refresh_token") | |
| except Exception: | |
| pass | |
| if not refresh_token: | |
| raise HTTPException(401, "Refresh token required") | |
| redis = await get_redis() | |
| # R-143: Reject blacklisted refresh tokens; blacklist submitted refresh jti | |
| try: | |
| refresh_payload = decode_jwt(refresh_token) | |
| if refresh_payload.get("type") != "refresh": | |
| raise HTTPException(401, "Not a refresh token") | |
| refresh_jti = refresh_payload.get("jti") | |
| if refresh_jti: | |
| try: | |
| if await is_blacklisted(redis, refresh_jti): | |
| raise HTTPException(401, "Refresh token has been revoked") | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| pass # Redis down: fail-open (same as access-token blacklist check) | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| raise HTTPException(401, "Invalid refresh token") | |
| refresh_ttl = max(int(refresh_payload.get("exp", 0) - time.time()), 1) | |
| # R-004: Blacklist old access token before issuing new pair | |
| if authorization and authorization.startswith("Bearer "): | |
| old_token = authorization.removeprefix("Bearer ").strip() | |
| try: | |
| old_payload = decode_jwt(old_token) | |
| old_jti = old_payload.get("jti") | |
| if old_jti: | |
| ttl = max(int(old_payload.get("exp", 0) - time.time()), 1) | |
| await blacklist_token(redis, old_jti, ttl) | |
| except Exception: | |
| pass # Best-effort blacklisting | |
| result = await AuthService(db).refresh_tokens(refresh_token) | |
| _set_refresh_cookie(response, result["refresh_token"]) | |
| # R-143: Blacklist the rotated refresh token after successful issue | |
| if refresh_jti: | |
| await blacklist_token(redis, refresh_jti, refresh_ttl) | |
| return result | |
| async def logout( | |
| request: Request, | |
| response: Response, | |
| authorization: str = Header(default=""), | |
| _=Depends(get_current_user), | |
| ): | |
| """Invalidate refresh token cookie and blacklist current JWT.""" | |
| redis = await get_redis() | |
| # R-144: Blacklist refresh token from cookie (read before clearing response cookie) | |
| refresh_token = request.cookies.get("refresh_token") | |
| # R-005: Blacklist current access token in Redis | |
| if authorization and authorization.startswith("Bearer "): | |
| token = authorization.removeprefix("Bearer ").strip() | |
| try: | |
| payload = decode_jwt(token) | |
| jti = payload.get("jti") | |
| if jti: | |
| ttl = max(int(payload.get("exp", 0) - time.time()), 1) | |
| await blacklist_token(redis, jti, ttl) | |
| except Exception: | |
| pass # Best-effort blacklisting | |
| if refresh_token: | |
| try: | |
| payload = decode_jwt(refresh_token) | |
| jti = payload.get("jti") | |
| if jti: | |
| ttl = max(int(payload.get("exp", 0) - time.time()), 1) | |
| await blacklist_token(redis, jti, ttl) | |
| except Exception: | |
| pass | |
| response.delete_cookie("refresh_token", path="/") | |
| async def get_me(current_user=Depends(get_current_user)): | |
| """Return current author profile.""" | |
| return current_user | |