feat(auth): implement auth stack
This commit is contained in:
101
app/api/deps.py
Normal file
101
app/api/deps.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Shared FastAPI dependencies (auth, RBAC, ownership)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core import exceptions as exc, security
|
||||
from app.db import db
|
||||
from app.repositories import OrgRepository, UserRepository
|
||||
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
ROLE_RANKS: dict[str, int] = {"viewer": 0, "member": 1, "admin": 2}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CurrentUser:
|
||||
"""Authenticated user context derived from the access token."""
|
||||
|
||||
user_id: UUID
|
||||
email: str
|
||||
org_id: UUID
|
||||
org_role: str
|
||||
token: str
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> CurrentUser:
|
||||
"""Extract and validate the current user from the Authorization header."""
|
||||
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise exc.UnauthorizedError("Missing bearer token")
|
||||
|
||||
try:
|
||||
payload = security.TokenPayload(security.decode_access_token(credentials.credentials))
|
||||
except security.JWTError as err: # pragma: no cover - jose error types
|
||||
raise exc.UnauthorizedError("Invalid access token") from err
|
||||
|
||||
async with db.connection() as conn:
|
||||
user_repo = UserRepository(conn)
|
||||
user = await user_repo.get_by_id(payload.user_id)
|
||||
if user is None:
|
||||
raise exc.UnauthorizedError("User not found")
|
||||
|
||||
org_repo = OrgRepository(conn)
|
||||
membership = await org_repo.get_member(payload.user_id, payload.org_id)
|
||||
if membership is None:
|
||||
raise exc.ForbiddenError("Organization access denied")
|
||||
|
||||
return CurrentUser(
|
||||
user_id=payload.user_id,
|
||||
email=user["email"],
|
||||
org_id=payload.org_id,
|
||||
org_role=membership["role"],
|
||||
token=credentials.credentials,
|
||||
)
|
||||
|
||||
|
||||
class RoleChecker:
|
||||
"""Dependency that enforces a minimum organization role."""
|
||||
|
||||
def __init__(self, minimum_role: str) -> None:
|
||||
if minimum_role not in ROLE_RANKS:
|
||||
raise ValueError(f"Unknown role '{minimum_role}'")
|
||||
self.minimum_role = minimum_role
|
||||
|
||||
def __call__(self, current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if ROLE_RANKS[current_user.org_role] < ROLE_RANKS[self.minimum_role]:
|
||||
raise exc.ForbiddenError("Insufficient role for this operation")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(min_role: str) -> Callable[[CurrentUser], CurrentUser]:
|
||||
"""Factory that returns a dependency enforcing the specified role."""
|
||||
|
||||
return RoleChecker(min_role)
|
||||
|
||||
|
||||
def ensure_org_access(resource_org_id: UUID, current_user: CurrentUser) -> None:
|
||||
"""Verify that the resource belongs to the active org in the token."""
|
||||
|
||||
if resource_org_id != current_user.org_id:
|
||||
raise exc.ForbiddenError("Resource does not belong to the active organization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CurrentUser",
|
||||
"ROLE_RANKS",
|
||||
"RoleChecker",
|
||||
"bearer_scheme",
|
||||
"ensure_org_access",
|
||||
"get_current_user",
|
||||
"require_role",
|
||||
]
|
||||
59
app/api/v1/auth.py
Normal file
59
app/api/v1/auth.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Authentication API endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_user
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
LogoutRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
SwitchOrgRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.services import AuthService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
auth_service = AuthService()
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(payload: RegisterRequest) -> TokenResponse:
|
||||
"""Register a new user and default org, returning auth tokens."""
|
||||
|
||||
return await auth_service.register_user(payload)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login_user(payload: LoginRequest) -> TokenResponse:
|
||||
"""Authenticate an existing user and issue tokens."""
|
||||
|
||||
return await auth_service.login_user(payload)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_tokens(payload: RefreshRequest) -> TokenResponse:
|
||||
"""Rotate refresh token and mint a new access token."""
|
||||
|
||||
return await auth_service.refresh_tokens(payload)
|
||||
|
||||
|
||||
@router.post("/switch-org", response_model=TokenResponse)
|
||||
async def switch_org(
|
||||
payload: SwitchOrgRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> TokenResponse:
|
||||
"""Switch the active organization for the authenticated user."""
|
||||
|
||||
return await auth_service.switch_org(current_user, payload)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
payload: LogoutRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Revoke the provided refresh token for the current session."""
|
||||
|
||||
await auth_service.logout(current_user, payload)
|
||||
Reference in New Issue
Block a user