2026-07-21 10:29:31 +02:00

163 lines
3.5 KiB
Python

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
SESSION_COOKIE = "mv_governance_session"
SESSION_SALT = "mv-governance-ui-v0314"
@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:
if "ADMIN" in user.roles:
return True
if review_owner == "PRODUCTION":
return "PRODUCTION" in user.roles
if review_owner == "TECHNICAL":
return "TECHNICAL" in user.roles
if review_owner == "JOINT":
return bool(
user.roles
& {
"PRODUCTION",
"TECHNICAL",
"JOINT",
}
)
return False
def can_review_owner(
user: CurrentUser,
review_owner: str,
) -> bool:
return can_view_owner(
user,
review_owner,
)