glo/auth.py

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