Files
ad/molodetz/utils/auth.py
T

223 lines
6.5 KiB
Python

# retoor <retoor@molodetz.nl>
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="Niet gevonden"):
raise HTTPException(404, detail)