import base64 import hashlib import hmac import os from collections.abc import Callable from datetime import UTC, datetime, timedelta from typing import Annotated import jwt from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jwt import InvalidTokenError from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.database import get_db from app.models import User PBKDF2_ITERATIONS = 600_000 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") ROLE_LEVELS = { "viewer": 10, "member": 20, "manager": 30, "admin": 40, "owner": 50, } def hash_password(password: str) -> str: salt = os.urandom(16) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS, ) return "pbkdf2_sha256${}${}${}".format( PBKDF2_ITERATIONS, base64.urlsafe_b64encode(salt).decode("ascii"), base64.urlsafe_b64encode(digest).decode("ascii"), ) def verify_password(password: str, encoded: str) -> bool: try: algorithm, iterations, salt_value, digest_value = encoded.split("$", 3) if algorithm != "pbkdf2_sha256": return False salt = base64.urlsafe_b64decode(salt_value.encode("ascii")) expected = base64.urlsafe_b64decode(digest_value.encode("ascii")) actual = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, int(iterations), ) return hmac.compare_digest(actual, expected) except (ValueError, TypeError): return False def create_access_token(user: User) -> str: settings = get_settings() now = datetime.now(UTC) expires_at = now + timedelta(minutes=settings.access_token_minutes) payload = { "sub": user.id, "tenant_id": user.tenant_id, "role": user.role, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, "iat": now, "exp": expires_at, } return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") def hash_service_key(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[Session, Depends(get_db)], ) -> User: settings = get_settings() credentials_error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication is required.", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode( token, settings.jwt_secret, algorithms=["HS256"], audience=settings.jwt_audience, issuer=settings.jwt_issuer, ) user_id = payload.get("sub") tenant_id = payload.get("tenant_id") except InvalidTokenError as exc: raise credentials_error from exc if not user_id or not tenant_id: raise credentials_error user = db.scalar( select(User).where( User.id == user_id, User.tenant_id == tenant_id, User.is_active.is_(True), ), ) if user is None: raise credentials_error return user CurrentUser = Annotated[User, Depends(get_current_user)] Database = Annotated[Session, Depends(get_db)] def require_role(minimum_role: str) -> Callable[[CurrentUser], User]: minimum_level = ROLE_LEVELS[minimum_role] def dependency(user: CurrentUser) -> User: if ROLE_LEVELS.get(user.role, 0) < minimum_level: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to perform this action.", ) return user return dependency Writer = Annotated[User, Depends(require_role("member"))] Manager = Annotated[User, Depends(require_role("manager"))] Admin = Annotated[User, Depends(require_role("admin"))]