223 lines
6.5 KiB
Python
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)
|