from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Annotated, Any, AsyncGenerator, Optional import jwt from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.config import get_settings from app.core.auth.models import Base, Permission, RefreshSession, Role, User logger = logging.getLogger("auth") _settings = get_settings() _is_temp_db = ( "sqlite" in _settings.database_url and "localhost" not in _settings.database_url and "postgres" not in _settings.database_url.lower() and "mysql" not in _settings.database_url.lower() ) _raw_url: str = getattr(_settings, "database_url", None) or "sqlite+aiosqlite:///data/auth.db" if "sqlite" in _raw_url and "sqlite+aiosqlite" not in _raw_url: _raw_url = _raw_url.replace("sqlite:///", "sqlite+aiosqlite:///") DATABASE_URL: str = _raw_url engine = create_async_engine(DATABASE_URL, echo=False) AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) ph = PasswordHasher() def _now() -> datetime: return datetime.now(timezone.utc) class TempDatabaseWarning: code: str = "TEMP_DATABASE" message: str = ( "No external database configuration was provided. " "The service is currently using its built-in SQLite database intended for development and temporary use. " "Data stored in this database should not be considered permanent." ) def get_temp_db_warning() -> Optional[dict]: if _is_temp_db: return {"code": TempDatabaseWarning.code, "message": TempDatabaseWarning.message} return None async def init_auth_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with AsyncSessionLocal() as session: result = await session.execute(select(Role).where(Role.name == "SuperAdmin")) if not result.scalars().first(): sa_role = Role(name="SuperAdmin", description="Full system access") admin_role = Role(name="Admin", description="Administrative access") user_role = Role(name="User", description="Standard user access") session.add_all([sa_role, admin_role, user_role]) perms = [] for code, desc in [ ("users:read", "Read users"), ("users:write", "Modify users"), ("users:delete", "Delete users"), ("admin:access", "Access admin panel"), ]: p = Permission(code=code, description=desc) perms.append(p) session.add(p) sa_role.permissions.extend(perms) admin_role.permissions.extend(perms[:-1]) user_role.permissions.append(perms[0]) await session.commit() admin_email = "admin@example.com" result = await session.execute(select(User).where(User.email == admin_email)) if not result.scalars().first(): admin_password = _settings.admin_password or "Admin123!" admin_user = User( email=admin_email, full_name="System Administrator", password_hash=ph.hash(admin_password), is_verified=True, ) role_result = await session.execute(select(Role).where(Role.name == "SuperAdmin")) admin_role = role_result.scalars().first() if admin_role: admin_user.roles.append(admin_role) session.add(admin_user) await session.commit() from app.services.auth_service import AuthService await AuthService.cleanup_expired_sessions(session) async def get_db() -> AsyncGenerator[AsyncSession, None]: async with AsyncSessionLocal() as session: try: yield session finally: await session.close() async def get_current_user( request: Request, db: Annotated[AsyncSession, Depends(get_db)], ) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): raise credentials_exception token = auth_header.split(" ", 1)[1] try: payload = jwt.decode(token, _settings.jwt_secret_key, algorithms=[_settings.jwt_algorithm]) user_id: str = payload.get("sub") token_type: str = payload.get("type") if user_id is None or token_type != "access": raise credentials_exception except jwt.PyJWTError: raise credentials_exception result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None))) user = result.scalars().first() if user is None or not user.is_active: raise credentials_exception return user def require_application_id(request: Request): app_id = request.headers.get("X-Application-Id") expected = _settings.application_id if not expected: return True if not app_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing X-Application-Id header", ) if app_id != expected: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid application_id", ) return True def require_permissions(*required_perms: str): async def permission_checker( user: Annotated[User, Depends(get_current_user)], ) -> User: user_perms = set() for role in user.roles: for perm in role.permissions: user_perms.add(perm.code) missing = [p for p in required_perms if p not in user_perms] if missing: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing required permissions: {', '.join(missing)}", ) return user return permission_checker