import os import secrets import uuid from datetime import datetime, timedelta, timezone from db import get_db from fastapi import Depends, HTTPException, Request from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from models import APIKey, RefreshTokenJti, User from passlib.context import CryptContext from sqlalchemy import select, update from sqlalchemy.orm import Session JWT_SECRET = os.environ.get("JWT_SECRET", "") JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_DAYS = 30 ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "") AUTH_DISABLED = os.environ.get("AUTH_DISABLED", "").lower() in {"1", "true", "yes", "on"} pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) def dummy_verify_password() -> None: """Burn the same bcrypt cycles as a real verify so login timing doesn't leak whether an email exists.""" pwd_context.dummy_verify() def generate_api_key() -> tuple[str, str, str]: """Returns (full_key, prefix, hash).""" raw = secrets.token_urlsafe(32) full_key = f"m0sk_{raw}" prefix = full_key[:12] key_hash = pwd_context.hash(full_key) return full_key, prefix, key_hash def verify_api_key_hash(plain_key: str, hashed: str) -> bool: return pwd_context.verify(plain_key, hashed) def _get_secret() -> str: if not JWT_SECRET: raise HTTPException(status_code=500, detail="JWT_SECRET is not configured.") return JWT_SECRET def create_access_token(user_id: str, role: str) -> str: expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) payload = {"sub": user_id, "role": role, "exp": expire, "type": "access"} return jwt.encode(payload, _get_secret(), algorithm=JWT_ALGORITHM) def create_refresh_token(user_id: str, db: Session) -> str: expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) jti = uuid.uuid4() db.add(RefreshTokenJti(jti=jti, user_id=uuid.UUID(user_id), expires_at=expire)) db.commit() payload = {"sub": user_id, "exp": expire, "jti": str(jti), "type": "refresh"} return jwt.encode(payload, _get_secret(), algorithm=JWT_ALGORITHM) def consume_refresh_jti(jti: str, db: Session) -> None: """Atomically mark a refresh token's jti as used. Raises 401 if missing, already used, or expired. The conditional UPDATE closes the read-check-write race: concurrent replays of the same token race on a single row, so at most one update affects a row and the rest see rowcount 0. """ try: jti_uuid = uuid.UUID(jti) except (TypeError, ValueError): raise HTTPException(status_code=401, detail="Refresh token is no longer valid.") now = datetime.now(timezone.utc) result = db.execute( update(RefreshTokenJti) .where( RefreshTokenJti.jti == jti_uuid, RefreshTokenJti.used_at.is_(None), RefreshTokenJti.expires_at > now, ) .values(used_at=now) ) if result.rowcount == 0: raise HTTPException(status_code=401, detail="Refresh token is no longer valid.") db.commit() def decode_token(token: str) -> dict: try: return jwt.decode(token, _get_secret(), algorithms=[JWT_ALGORITHM]) except JWTError: raise HTTPException(status_code=401, detail="Invalid or expired token.") bearer_scheme = HTTPBearer(auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) def _mark_auth_type(request: Request, auth_type: str) -> None: request.state.auth_type = auth_type def _get_default_user(db: Session) -> User | None: return db.scalar(select(User).order_by(User.created_at.asc())) def _resolve_user_from_jwt(token: str, db: Session) -> User: payload = decode_token(token) if payload.get("type") != "access": raise HTTPException(status_code=401, detail="Invalid token type.") user = db.get(User, payload.get("sub")) if user is None: raise HTTPException(status_code=401, detail="User not found.") return user def _resolve_user_from_api_key(key: str, db: Session) -> User: prefix = key[:12] if len(key) >= 12 else key candidates = ( db.execute(select(APIKey).where(APIKey.key_prefix == prefix, APIKey.revoked_at.is_(None))).scalars().all() ) for candidate in candidates: if verify_api_key_hash(key, candidate.key_hash): candidate.last_used_at = datetime.now(timezone.utc) db.commit() user = db.get(User, candidate.created_by) if user is None: raise HTTPException(status_code=401, detail="API key owner not found.") return user raise HTTPException(status_code=401, detail="Invalid API key.") async def verify_auth( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), x_api_key: str | None = Depends(api_key_header), db: Session = Depends(get_db), ) -> User | None: """Authenticate via JWT, X-API-Key, or legacy ADMIN_API_KEY. Returns User or None.""" if credentials is not None: _mark_auth_type(request, "bearer") return _resolve_user_from_jwt(credentials.credentials, db) if x_api_key is not None: if ADMIN_API_KEY and secrets.compare_digest(x_api_key, ADMIN_API_KEY): _mark_auth_type(request, "admin_api_key") return None _mark_auth_type(request, "api_key") return _resolve_user_from_api_key(x_api_key, db) if AUTH_DISABLED: _mark_auth_type(request, "disabled") return None raise HTTPException( status_code=401, detail="Authentication required. Provide a Bearer token or X-API-Key header.", headers={"WWW-Authenticate": "Bearer"}, ) async def require_auth( request: Request, user: User | None = Depends(verify_auth), db: Session = Depends(get_db), ) -> User: """Like verify_auth but guarantees a non-None User. Use for endpoints that require auth.""" if user is None: if getattr(request.state, "auth_type", "none") in {"admin_api_key", "disabled"}: default_user = _get_default_user(db) if default_user is not None: return default_user raise HTTPException(status_code=401, detail="Authentication required.") return user _BOOTSTRAP_ADMIN = User( id=uuid.UUID(int=0), name="admin_api_key", email="", password_hash="", role="admin", created_at=datetime.min.replace(tzinfo=timezone.utc), ) async def require_admin( request: Request, user: User | None = Depends(verify_auth), db: Session = Depends(get_db), ) -> User: """Like require_auth but also enforces admin role. ADMIN_API_KEY and AUTH_DISABLED callers are treated as admin even when the users table is empty (fresh-deploy bootstrap). """ auth_type = getattr(request.state, "auth_type", "none") if user is None: if auth_type in {"admin_api_key", "disabled"}: default_user = _get_default_user(db) if default_user is not None: if default_user.role != "admin": raise HTTPException(status_code=403, detail="Admin role required.") return default_user return _BOOTSTRAP_ADMIN raise HTTPException(status_code=401, detail="Authentication required.") if user.role != "admin": raise HTTPException(status_code=403, detail="Admin role required.") return user