499 lines
14 KiB
Python
499 lines
14 KiB
Python
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
|