from __future__ import annotations import secrets from dataclasses import dataclass from typing import FrozenSet import bcrypt from fastapi import HTTPException, Request, status from itsdangerous import BadSignature, SignatureExpired from itsdangerous.url_safe import URLSafeTimedSerializer from .settings import UserConfig, get_settings from .policy import ( can_approve_context_roles, can_manage_clarification_roles, can_view_owner_roles, ) SESSION_COOKIE = "mv_governance_session" SESSION_SALT = "mv-governance-ui-v0315" @dataclass(frozen=True) class CurrentUser: username: str display_name: str roles: FrozenSet[str] csrf_token: str def serializer() -> URLSafeTimedSerializer: settings = get_settings() return URLSafeTimedSerializer( secret_key=settings.session_secret, salt=SESSION_SALT, ) def authenticate( username: str, password: str, ) -> UserConfig | None: settings = get_settings() user = settings.users.get(username) if user is None: return None try: valid = bcrypt.checkpw( password.encode("utf-8"), user.password_hash.encode("utf-8"), ) except ValueError: return None return user if valid else None def build_session_token(user: UserConfig) -> str: payload = { "username": user.username, "display_name": user.display_name, "roles": sorted(user.roles), "csrf_token": secrets.token_urlsafe(32), } return serializer().dumps(payload) def read_current_user( request: Request, ) -> CurrentUser | None: token = request.cookies.get(SESSION_COOKIE) if not token: return None settings = get_settings() try: payload = serializer().loads( token, max_age=settings.session_max_age_s, ) except ( BadSignature, SignatureExpired, ): return None username = str(payload.get("username", "")) configured = settings.users.get(username) if configured is None: return None csrf_token = str( payload.get("csrf_token", "") ) if not csrf_token: return None return CurrentUser( username=configured.username, display_name=configured.display_name, roles=configured.roles, csrf_token=csrf_token, ) def require_user(request: Request) -> CurrentUser: user = read_current_user(request) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Sesión no válida.", ) return user def verify_csrf( user: CurrentUser, submitted_token: str, ) -> None: if ( not submitted_token or not secrets.compare_digest( user.csrf_token, submitted_token, ) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Token CSRF no válido.", ) def can_view_owner( user: CurrentUser, review_owner: str, ) -> bool: return can_view_owner_roles( user.roles, review_owner, ) def can_review_owner( user: CurrentUser, review_owner: str, ) -> bool: return can_view_owner( user, review_owner, ) def can_approve_context( user: CurrentUser, ) -> bool: return can_approve_context_roles( user.roles ) def can_manage_clarification( user: CurrentUser, ) -> bool: return can_manage_clarification_roles( user.roles )