Spaces:
Running
Running
| """Auth — JWT, password hashing, GitHub OAuth, MFA stubs.""" | |
| import logging | |
| import os | |
| import secrets | |
| from datetime import datetime, timedelta | |
| from typing import Optional | |
| import bcrypt | |
| from fastapi import Depends, HTTPException, Request, status | |
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer | |
| from jose import JWTError, jwt | |
| logger = logging.getLogger("rga_auditor.auth") | |
| JWT_ALG = "HS256" | |
| _1_HOUR = 60 | |
| def get_jwt_secret() -> str: | |
| secret = os.getenv("JWT_SECRET_KEY") | |
| if not secret: | |
| env = os.getenv("ENVIRONMENT", "development").lower() | |
| if env == "production": | |
| raise RuntimeError("JWT_SECRET_KEY is required in production") | |
| secret = "dev-only-fallback-secret-change-me" | |
| return secret | |
| def hash_password(password: str) -> str: | |
| return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() | |
| def verify_password(password: str, hashed: str) -> bool: | |
| try: | |
| return bcrypt.checkpw(password.encode(), hashed.encode()) | |
| except Exception: | |
| return False | |
| def create_access_token( | |
| user_id: str, | |
| roles: Optional[list[str]] = None, | |
| extra: Optional[dict] = None, | |
| expires_minutes: Optional[int] = None, | |
| ) -> tuple[str, int, str]: | |
| minutes = expires_minutes or int(os.getenv("JWT_EXPIRE_MINUTES", str(_1_HOUR))) | |
| expire = datetime.utcnow() + timedelta(minutes=minutes) | |
| jti = secrets.token_urlsafe(16) | |
| payload = { | |
| "sub": user_id, | |
| "exp": expire, | |
| "iat": datetime.utcnow(), | |
| "jti": jti, | |
| "roles": roles or ["user"], | |
| } | |
| if extra: | |
| payload.update(extra) | |
| token = jwt.encode(payload, get_jwt_secret(), algorithm=JWT_ALG) | |
| return token, minutes * 60, jti | |
| def decode_token(token: str) -> dict: | |
| try: | |
| return jwt.decode(token, get_jwt_secret(), algorithms=[JWT_ALG]) | |
| except JWTError as e: | |
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"invalid token: {e}") | |
| bearer_scheme = HTTPBearer(auto_error=False) | |
| def _roles_from_payload(payload: dict) -> list[str]: | |
| roles = payload.get("roles") | |
| if isinstance(roles, list) and roles: | |
| return [str(r) for r in roles] | |
| legacy = payload.get("role") | |
| if legacy: | |
| return [str(legacy)] | |
| return ["user"] | |
| async def current_user( | |
| request: Request, | |
| creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), | |
| ) -> dict: | |
| if creds is None: | |
| raise HTTPException(status_code=401, detail="missing bearer token") | |
| payload = decode_token(creds.credentials) | |
| user_id = payload.get("sub") | |
| if not user_id: | |
| raise HTTPException(status_code=401, detail="token missing subject") | |
| from ..database.repository import get_user_by_id, is_token_blacklisted | |
| from ..database.session import get_session_factory | |
| jti = payload.get("jti", "") | |
| sf = get_session_factory() | |
| if sf is not None: | |
| async with sf() as s: | |
| if await is_token_blacklisted(s, jti): | |
| raise HTTPException(status_code=401, detail="token revoked") | |
| db_user = await get_user_by_id(s, user_id) | |
| if db_user is None: | |
| raise HTTPException(status_code=401, detail="user not found") | |
| roles = db_user.get("roles") or _roles_from_payload(payload) | |
| else: | |
| # In-memory fallback: also check the in-memory blacklist store | |
| if await is_token_blacklisted(None, jti): | |
| raise HTTPException(status_code=401, detail="token revoked") | |
| roles = _roles_from_payload(payload) | |
| request.state.user_id = user_id | |
| request.state.jti = payload.get("jti", "") | |
| request.state.roles = roles | |
| return {"id": user_id, "role": roles[0] if roles else "user", "roles": roles} | |
| def require_role(*roles: str): | |
| allowed = set(roles) | |
| async def dep(user: dict = Depends(current_user)) -> dict: | |
| user_roles = set(user.get("roles") or [user.get("role", "user")]) | |
| if not (user_roles & allowed): | |
| raise HTTPException(status_code=403, detail=f"requires role: {', '.join(sorted(allowed))}") | |
| return user | |
| return dep | |