172 lines
3.5 KiB
Python
172 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
|
|
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
|
|
)
|