import base64 import hashlib import hmac import json import re import secrets import time from typing import Optional from flask import jsonify, request, session from werkzeug.exceptions import BadRequest from werkzeug.security import check_password_hash, generate_password_hash from config import ( CLIENT_HASH_KEY, PLACEHOLDER_SECRETS, PRIVATE_ACCESS_KEY, RATE_LIMITS, ROLES, STATUSES, MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_USERNAME_LENGTH, now_iso, parse_iso, utc_now, ) from db import db_write, get_db USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$") def normalize_username(value: object) -> str: return value.strip().lower() if isinstance(value, str) else "" def validate_username(username: str) -> Optional[str]: if not username: return "Username is required." if len(username) < MIN_USERNAME_LENGTH: return f"Username must be at least {MIN_USERNAME_LENGTH} characters." if len(username) > MAX_USERNAME_LENGTH: return f"Username must be {MAX_USERNAME_LENGTH} characters or less." if not USERNAME_RE.fullmatch(username): return "Username contains invalid characters." return None def validate_password(password: object, username: str = "") -> Optional[str]: if not isinstance(password, str) or not password: return "Password is required." if len(password) < MIN_PASSWORD_LENGTH: return f"Password must be at least {MIN_PASSWORD_LENGTH} characters." if len(password) > MAX_PASSWORD_LENGTH: return f"Password must be {MAX_PASSWORD_LENGTH} characters or less." if not re.search(r"[a-z]", password): return "Password must include a lowercase letter." if not re.search(r"[A-Z]", password): return "Password must include an uppercase letter." if not re.search(r"\d", password): return "Password must include a number." if not re.search(r"[^A-Za-z0-9]", password): return "Password must include a symbol." if username and username in password.lower(): return "Password must not contain your username." return None def safe_json() -> dict: if not request.is_json: raise ValueError("Request body must be JSON.") try: data = request.get_json(silent=False) except BadRequest as error: raise ValueError("Invalid JSON request body.") from error if not isinstance(data, dict): raise ValueError("JSON body must be an object.") return data def csrf_token() -> str: token = session.get("csrf_token") if not token: token = secrets.token_urlsafe(32) session["csrf_token"] = token return token def csrf_valid() -> bool: supplied = request.headers.get("X-CSRF-Token", "") expected = session.get("csrf_token", "") return bool(supplied and expected and hmac.compare_digest(supplied, expected)) def client_ip() -> str: return (request.remote_addr or "unknown")[:128] def ctx_hash(): return hashlib.sha256 def hash_identifier(value: str) -> str: return hmac.new( CLIENT_HASH_KEY.encode("utf-8"), value.encode("utf-8"), ctx_hash(), ).hexdigest() def b64url_encode_bytes(data: bytes) -> str: return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") def sign_call_helper_token(payload: dict, shared_secret: str) -> str: payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) payload_bytes = payload_json.encode("utf-8") signature = hmac.new( shared_secret.encode("utf-8"), payload_bytes, hashlib.sha256, ).digest() return f"{b64url_encode_bytes(payload_bytes)}.{b64url_encode_bytes(signature)}" def current_client_hash() -> str: return hash_identifier(client_ip()) def audit( action: str, actor_user_id: Optional[int] = None, target_type: Optional[str] = None, target_id: Optional[str] = None, details: Optional[str] = None, ) -> None: db_write( """ INSERT INTO audit_log ( actor_user_id, action, target_type, target_id, details, client_hash, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( actor_user_id, action, target_type, target_id, details, current_client_hash(), now_iso(), ), ) def active_blacklist_match(kind: str, value_hash: str) -> bool: with get_db() as db: row = db.execute( """ SELECT id FROM blacklist_entries WHERE kind = ? AND value_hash = ? AND ( expires_at IS NULL OR expires_at > ? ) """, (kind, value_hash, now_iso()), ).fetchone() return bool(row) def request_blacklisted() -> bool: if request.endpoint in {"static"}: return False if active_blacklist_match("ip", current_client_hash()): return True user_id = session.get("user_id") if user_id and active_blacklist_match("user", hash_identifier(str(user_id))): return True return False def check_rate_limit(scope: str) -> Optional[int]: limit, window_seconds = RATE_LIMITS[scope] now_value = time.time() cutoff = now_value - window_seconds subject_hash = current_client_hash() with get_db() as db: db.execute("DELETE FROM rate_limit_events WHERE created_at < ?", (cutoff - 60,)) rows = db.execute( """ SELECT created_at FROM rate_limit_events WHERE scope = ? AND subject_hash = ? AND created_at >= ? ORDER BY created_at ASC """, (scope, subject_hash, cutoff), ).fetchall() if len(rows) >= limit: oldest = rows[0]["created_at"] return max(1, int(window_seconds - (now_value - oldest)) + 1) db.execute( """ INSERT INTO rate_limit_events ( scope, subject_hash, created_at ) VALUES (?, ?, ?) """, (scope, subject_hash, now_value), ) db.commit() return None def rate_limited(scope: str): retry_after = check_rate_limit(scope) if retry_after is None: return None return ( jsonify( { "error": "Too many requests. Please wait and try again.", "code": "rate_limited", "retry_after": retry_after, } ), 429, ) def user_payload(row, settings_row=None) -> dict: status = row["status"] restricted_until = parse_iso(row["restricted_until"]) if status == "restricted" and restricted_until and restricted_until <= utc_now(): status = "active" return { "id": row["id"], "username": row["username"], "role": row["role"], "status": status, "restricted_until": row["restricted_until"], } def current_user() -> Optional[dict]: user_id = session.get("user_id") if not user_id: return None with get_db() as db: row = db.execute( """ SELECT id, username, role, status, restricted_until FROM users WHERE id = ? """, (user_id,), ).fetchone() if not row: session.clear() return None user = user_payload(row) if user["status"] == "restricted": expires = parse_iso(user["restricted_until"]) if expires and expires <= utc_now(): db_write( """ UPDATE users SET status = 'active', restricted_until = NULL, restriction_reason = NULL, is_active = 1 WHERE id = ? """, (user["id"],), ) user["status"] = "active" user["restricted_until"] = None if user["status"] in {"disabled", "blacklisted"}: session.clear() return None return user def login_user(row) -> dict: session.clear() session.permanent = True session["user_id"] = row["id"] session["csrf_token"] = secrets.token_urlsafe(32) return { "id": row["id"], "username": row["username"], "role": row["role"], "status": row["status"], "restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"], } def require_user(): user = current_user() if not user: return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401) return user, None def require_writable_user(): user, error = require_user() if error: return None, error if user["status"] == "restricted": return None, ( jsonify( { "error": "Your account is temporarily restricted.", "code": "restricted", "restricted_until": user["restricted_until"], } ), 403, ) return user, None def require_moderator(): user, error = require_writable_user() if error: return None, error if user["role"] not in {"moderator", "admin"}: return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403) return user, None def require_admin(): user, error = require_writable_user() if error: return None, error if user["role"] != "admin": return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403) return user, None def private_access_key_valid(candidate: object) -> bool: configured = (PRIVATE_ACCESS_KEY or "").strip() if not configured: return True if not isinstance(candidate, str): return False return candidate.strip() == configured def register_account(data: dict): access_key = data.get("access_key", "") if not private_access_key_valid(access_key): return None, (jsonify({"error": "Invalid private access key."}), 403) username = normalize_username(data.get("username")) password = data.get("password", "") username_error = validate_username(username) password_error = validate_password(password, username) if username_error: return None, (jsonify({"error": username_error}), 400) if password_error: return None, (jsonify({"error": password_error}), 400) try: cursor = db_write( """ INSERT INTO users ( username, password_hash, role, status, is_admin, is_active, created_at ) VALUES (?, ?, 'user', 'active', 0, 1, ?) """, (username, generate_password_hash(password), now_iso()), ) except Exception as error: if "UNIQUE" in str(error).upper(): return None, (jsonify({"error": "Unable to create account with those details."}), 409) raise user = { "id": cursor.lastrowid, "username": username, "role": "user", "status": "active", "restricted_until": None, } db_write( """ INSERT OR IGNORE INTO user_profiles ( user_id, display_name, badge_text, bio, bio_mode, website, location, status_message, banner_attachment_id, updated_at ) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?) """, (user["id"], username, now_iso()), ) db_write( """ INSERT OR IGNORE INTO user_settings ( user_id, accent_color, name_color, custom_css, theme_mode, profile_background_color, profile_banner_color, profile_visibility, last_active_visibility, presence_mode, dm_layout_mode, show_presence_badge, updated_at ) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?) """, (user["id"], now_iso()), ) authenticated = login_user(user) audit("account_registered", user["id"], "user", str(user["id"])) return authenticated, None def authenticate_account(data: dict): access_key = data.get("access_key", "") if not private_access_key_valid(access_key): return None, (jsonify({"error": "Invalid private access key."}), 403) username = normalize_username(data.get("username")) password = data.get("password", "") with get_db() as db: row = db.execute( """ SELECT id, username, password_hash, role, status, restricted_until FROM users WHERE username = ? """, (username,), ).fetchone() if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password): return None, (jsonify({"error": "Invalid username or password."}), 401) if row["status"] in {"disabled", "blacklisted"}: return None, (jsonify({"error": "Invalid username or password."}), 401) db_write("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"])) authenticated = login_user(row) audit("account_logged_in", row["id"], "user", str(row["id"])) return authenticated, None