Arag / app /api /schemas_router.py
AuthorBot
Deliver enterprise features with JWT blacklist, SuperAdmin TOTP, and CI hardening.
007cdd2
Raw
History Blame Contribute Delete
7.17 kB
"""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="/",
)
@router.post("/auth/sso/exchange", response_model=TokenResponse, tags=["Auth"])
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,
}
@router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"])
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
@router.post("/auth/login", response_model=TokenResponse, tags=["Auth"])
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
@router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"])
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
@router.post("/auth/logout", status_code=204, tags=["Auth"])
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="/")
@router.get("/auth/me", response_model=UserResponse, tags=["Auth"])
async def get_me(current_user=Depends(get_current_user)):
"""Return current author profile."""
return current_user