# retoor import asyncio import base64 import hashlib from datetime import datetime, timezone from fastapi import HTTPException from passlib.hash import pbkdf2_sha256 from molodetz.cache import TTLCache from molodetz.constants import ROLE_ADMIN from molodetz.database import ( admin_uids_ordered, bump_cache_version, get_session, get_user, get_user_by_api_key, get_user_by_email, get_user_by_username, hard_delete_session, parse_iso, sync_local_cache, ) from molodetz.responses import wants_json from molodetz.utils.audit import record _UNSET = object() _user_cache = TTLCache(ttl=300, max_size=2000) USERNAME_PATTERN = r"^[A-Za-z0-9_-]{3,32}$" class GuardRedirect(Exception): def __init__(self, url, status_code=303): self.url = url self.status_code = status_code def hash_password(password): return pbkdf2_sha256.hash(password) def verify_password(password, password_hash): if not password_hash: return False try: return pbkdf2_sha256.verify(password, password_hash) except (ValueError, TypeError): return False async def hash_password_async(password): return await asyncio.to_thread(hash_password, password) async def verify_password_async(password, password_hash): return await asyncio.to_thread(verify_password, password, password_hash) def is_account_active(row): if not row: return False value = row.get("is_active") return value is None or bool(value) def is_admin(user): return bool(user) and user.get("role") == ROLE_ADMIN def is_primary_admin(user): if not is_admin(user): return False ordered = admin_uids_ordered() return bool(ordered) and ordered[0] == user["uid"] def is_senior_admin(actor, target): if not is_admin(target) or not actor: return False ordered = admin_uids_ordered() if target["uid"] not in ordered or actor["uid"] not in ordered: return False return ordered.index(target["uid"]) < ordered.index(actor["uid"]) def is_owner(item, user): return bool(item) and bool(user) and item.get("user_uid") == user.get("uid") def avatar_seed(user): if not user: return "guest" return user.get("avatar_seed") or user.get("username") or "guest" def invalidate_user_cache(propagate=True, key=None): if propagate: bump_cache_version("auth", _user_cache) elif key is not None: _user_cache.pop(key) else: _user_cache.clear() def _resolve_session(token): row = get_session(token) if not row: return None expires = parse_iso(row.get("expires_at")) if expires is None or expires < datetime.now(timezone.utc): hard_delete_session(row["id"]) return None user = get_user(row["user_uid"]) if not is_account_active(user): hard_delete_session(row["id"]) return None return user def _resolve_key(credential): user = get_user_by_api_key(credential) if is_account_active(user): return user, f"k:{credential}" return None, None def _resolve_basic(header_value): try: decoded = base64.b64decode(header_value).decode("utf-8") except (ValueError, UnicodeDecodeError): return None identifier, _, password = decoded.partition(":") user = get_user_by_username(identifier) or get_user_by_email(identifier.lower()) if not is_account_active(user) or not verify_password(password, user.get("password_hash")): return None return user def _lookup(key, loader): cached = _user_cache.get(key, _UNSET) if cached is not _UNSET: return cached user = loader() _user_cache.set(key, user) return user def get_current_user(request): memo = getattr(request.state, "user_cached", _UNSET) if memo is not _UNSET: return memo sync_local_cache("auth", _user_cache) user = None token = request.cookies.get("session") if token and len(token) == 64: user = _lookup(token, lambda: _resolve_session(token)) if user is None: api_key = (request.headers.get("x-api-key") or "").strip() if api_key: user = _lookup(f"k:{api_key}", lambda: _resolve_key(api_key)[0]) authorization = request.headers.get("authorization") or "" if user is None and authorization.lower().startswith("bearer "): credential = authorization[7:].strip() user = _lookup(f"k:{credential}", lambda: _resolve_key(credential)[0]) if user is None and authorization.lower().startswith("basic "): raw = authorization[6:].strip() key = "b:" + hashlib.sha256(raw.encode()).hexdigest() user = _lookup(key, lambda: _resolve_basic(raw)) request.state.user_cached = user return user def has_credentials(request): return bool(request.headers.get("x-api-key") or request.headers.get("authorization")) def refuse_suspended(request, user): if request.method not in ("POST", "PUT", "PATCH", "DELETE"): return path = request.url.path if path.startswith("/auth"): return until = parse_iso(user.get("suspended_until")) if until and until > datetime.now(timezone.utc): reason = user.get("suspend_reason") or "" record(request, "security.suspension.refused", result="denied", user=user) raise HTTPException(403, f"Account suspended. {reason}".strip()) def require_user(request): user = get_current_user(request) if user is None: record(request, "security.authorization.denied", result="denied", message="guest") if has_credentials(request) or wants_json(request): raise HTTPException(401, "Invalid credentials") raise GuardRedirect(f"/auth/login?next={request.url.path}", 303) refuse_suspended(request, user) return user def require_admin(request): user = require_user(request) if not is_admin(user): record(request, "security.authorization.denied", result="denied", message="admin required", user=user) if wants_json(request) or has_credentials(request): raise HTTPException(403, "Administrator access required") raise GuardRedirect("/", 303) return user def require_user_api(request): user = get_current_user(request) if user is None: record(request, "security.authorization.denied", result="denied", message="api guest") raise HTTPException(401, "Authentication required") refuse_suspended(request, user) return user def not_found(detail="Not found"): raise HTTPException(404, detail)