Spaces:
Paused
Paused
File size: 8,051 Bytes
5ec8557 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from db import SessionLocal
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),
) -> User | None:
"""Authenticate via JWT, X-API-Key, or legacy ADMIN_API_KEY. Returns User or None.
A short-lived session is opened only on the branches that query the DB, so no
pooled connection is held for the lifetime of the (possibly long-running) request.
"""
if credentials is not None:
_mark_auth_type(request, "bearer")
with SessionLocal() as db:
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")
with SessionLocal() as db:
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),
) -> 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"}:
with SessionLocal() as db:
default_user = _get_default_user(db)
if default_user is not None:
return default_user
raise HTTPException(status_code=401, detail="Authentication required.")
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),
) -> 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"}:
with SessionLocal() as db:
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 |