First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+446
View File
@@ -0,0 +1,446 @@
"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password.
Multi-user support:
- Sessions carry the authenticated `user_id` (Root = 0).
- The unified login (6-digit code) searches every active user's TOTP secret,
then temp accounts' temp-TOTP secrets, then Root's temp password.
- Session version is per-user (stored in the users table), so deleting a user
or kicking all sessions immediately invalidates their cookies.
"""
import functools
import secrets
import string
import time
from flask import session, redirect, url_for, request, jsonify, current_app, g
from backend.models import (
get_auth_config, update_auth_config, get_temp_password, set_temp_password,
clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured,
get_user_session_version, user_exists, is_admin_user, get_db,
)
# ─── Session keys ────────────────────────────
SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated
SKEY_TEMP = "mfa_temp_pending" # bool: logged in with temp pw / temp account, config required
SKEY_VIEW = "mfa_view" # str: "control" or "display"
SKEY_REAUTH = "mfa_reauth_at" # float: timestamp of last re-authentication
SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation)
SKEY_USER = "user_id" # int: id of the authenticated user
def _db_path(preferred: str = None) -> str:
if preferred:
return preferred
return current_app.config.get("DB_PATH", "data.db")
def _validate_version():
"""If the session version is stale (kick_all / user deleted), clear the session."""
uid = session.get(SKEY_USER)
if uid is None:
return
# Cache the validation result for the duration of this request.
if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid:
return
ver = session.get(SKEY_VERSION)
if ver is None:
return
stored = get_user_session_version(uid)
if stored == -1 or ver != stored:
clear_session()
return
g._sv_ok = True
g._sv_uid = uid
def is_authenticated() -> bool:
"""Returns True if the user has completed MFA authentication."""
_validate_version()
return session.get(SKEY_AUTH) is True
def is_temp_pending() -> bool:
"""True if logged in with a temp password or temp account and must set up a login method."""
_validate_version()
return session.get(SKEY_TEMP) is True
def current_user_id() -> int | None:
"""Return the id of the current user (session user, or scheduler thread user)."""
try:
from flask import has_request_context
if has_request_context():
_validate_version()
uid = session.get(SKEY_USER)
if uid is not None:
return int(uid)
except Exception:
pass
try:
from backend import time_util
return time_util.get_thread_user()
except Exception:
return None
def current_remark() -> str:
"""Return the current user's remark for display (never the raw id unless it IS the remark)."""
uid = current_user_id()
if uid is None:
return ""
try:
from backend.models import get_user
u = get_user(uid)
return (u or {}).get("remark", "") or ""
except Exception:
return ""
def is_admin() -> bool:
"""Only Root (id 0) — and any future admin role — can manage users."""
uid = current_user_id()
if uid is None:
return False
return is_admin_user(uid)
def set_authenticated(view: str = "control", user_id: int = None):
if user_id is None:
user_id = session.get(SKEY_USER) or 0
session[SKEY_AUTH] = True
session[SKEY_TEMP] = False
session[SKEY_VIEW] = view
session[SKEY_USER] = user_id
session[SKEY_VERSION] = get_user_session_version(user_id)
# A fresh authentication must not inherit the temp-setup re-auth marker.
session.pop(SKEY_REAUTH, None)
session.permanent = True
def set_temp_pending(view: str = "control", user_id: int = None):
"""Mark the session as logged-in-but-must-configure (temp password / temp account)."""
if user_id is None:
user_id = session.get(SKEY_USER) or 0
session[SKEY_AUTH] = False
session[SKEY_TEMP] = True
session[SKEY_VIEW] = view
session[SKEY_USER] = user_id
session[SKEY_VERSION] = get_user_session_version(user_id)
session.permanent = True
def clear_session():
session.pop(SKEY_AUTH, None)
session.pop(SKEY_TEMP, None)
session.pop(SKEY_VIEW, None)
session.pop(SKEY_REAUTH, None)
session.pop(SKEY_USER, None)
session.pop(SKEY_VERSION, None)
# ─── Re-authentication guard ─────────────────
# Sensitive operations (user management, MFA changes) require re-confirming
# identity within the last N seconds. TOTP-first with a Passkey fallback
# (the fallback ceremony is handled by the routes layer).
_REAUTH_TTL = 300 # 5 minutes
def require_reauth():
"""Check if recent re-authentication is still valid.
Skips the check for temp_pending users (first-time MFA setup flow).
"""
if is_temp_pending():
mark_reauth()
return True
last = session.get(SKEY_REAUTH)
if last and (time.time() - last) < _REAUTH_TTL:
return True
return False
def mark_reauth():
"""Mark re-authentication as done now."""
session[SKEY_REAUTH] = time.time()
# ─── Decorators ──────────────────────────────
def _unauthorized(request):
if request.is_json or request.path.startswith("/api/"):
return jsonify({"error": "Unauthorized", "login_required": True}), 401
view_param = session.get(SKEY_VIEW, "control")
return redirect(url_for("api.login_page", next=request.path, view=view_param))
def login_required(view):
"""Require full MFA authentication. Redirects to login if not authenticated."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if is_authenticated():
return view(*args, **kwargs)
# If logged in with temp password / temp account, redirect to MFA setup
if is_temp_pending():
if request.is_json or request.path.startswith("/api/"):
return jsonify({"error": "MFA configuration required", "mfa_required": True}), 403
return redirect(url_for("api.mfa_setup_page"))
return _unauthorized(request)
return wrapped
def login_or_temp_required(view):
"""Allow access if fully authenticated OR in temp-pending state (MFA setup)."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if is_authenticated() or is_temp_pending():
return view(*args, **kwargs)
return _unauthorized(request)
return wrapped
def display_login_required(view):
"""Lighter auth check for display-end API calls."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if is_authenticated():
return view(*args, **kwargs)
return _unauthorized(request)
return wrapped
def admin_required(view):
"""Require full authentication AND administrator (Root) role."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if not is_authenticated():
return _unauthorized(request)
if not is_admin():
return jsonify({"error": "Forbidden: 仅管理员可执行此操作"}), 403
return view(*args, **kwargs)
return wrapped
# ─── Unified Login ───────────────────────────
def verify_login(raw: str, db_path: str = None) -> dict | None:
"""Unified login: tries every user's TOTP, then temp accounts' temp-TOTP,
then Root's temp password.
Returns:
{'method': 'totp'|'temp_password', 'user_id': int, 'temp_account': bool}
or None on failure.
"""
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
return None
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
conn = get_db(db_path)
try:
import pyotp
# Load all users + their auth rows in ONE query (avoids per-user connections).
rows = conn.execute(
"SELECT u.id AS user_id, u.is_temp, u.temp_totp_secret, "
"a.totp_secret, a.totp_enabled, a.temp_password "
"FROM users u LEFT JOIN auth_config a ON a.user_id = u.id "
"WHERE u.status='active'"
).fetchall()
# 1. Formal users' TOTP secrets
for r in rows:
if r["totp_enabled"] and r["totp_secret"]:
if pyotp.TOTP(r["totp_secret"]).verify(raw, valid_window=1):
return {"method": "totp", "user_id": r["user_id"], "temp_account": False}
# 2. Temp accounts' temp-TOTP (server-only secret; admin sees only the live code)
for r in rows:
if r["is_temp"] and r["temp_totp_secret"]:
if pyotp.TOTP(r["temp_totp_secret"]).verify(raw, valid_window=1):
return {"method": "totp", "user_id": r["user_id"], "temp_account": True}
# 3. Root temp password (initial bootstrap)
root_row = next((r for r in rows if r["user_id"] == 0), None)
stored_pw = (root_row["temp_password"] or "") if root_row else ""
if stored_pw and secrets.compare_digest(raw, stored_pw):
return {"method": "temp_password", "user_id": 0, "temp_account": False}
return None
finally:
conn.close()
# ─── Temp Password ───────────────────────────
def generate_temp_password(length: int = 6) -> str:
"""Generate a random numeric temp password."""
return "".join(secrets.choice(string.digits) for _ in range(length))
def verify_temp_password(raw: str, db_path: str = None, user_id: int = 0) -> bool:
"""Verify a temporary password for a given user (only Root uses one)."""
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
return False
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
stored = get_temp_password(db_path, user_id)
if not stored:
return False
return secrets.compare_digest(raw, stored)
# ─── Rate Limiter ─────────────────────────────
# Progressive per-IP ban: 5err→5min, 10err→15min, 15+err→24hr
_RATE_LIMIT_STORE: dict[str, dict] = {} # ip -> {"failures": int, "banned_until": float}
def _get_client_ip() -> str:
"""Extract client IP for rate limiting.
Relies on ProxyFix + Nginx config to set remote_addr correctly.
"""
if not current_app:
return "unknown"
return request.remote_addr or "unknown"
def _ban_duration_sec(failures: int) -> int:
"""Progressive ban duration based on consecutive failure count."""
if failures < 5:
return 0
if failures < 10:
return 5 * 60 # 5 minutes
if failures < 15:
return 15 * 60 # 15 minutes
return 24 * 60 * 60 # 24 hours
def check_rate_limit(ip: str = None) -> dict:
"""Check if this IP is currently rate-limited."""
if ip is None:
ip = _get_client_ip()
entry = _RATE_LIMIT_STORE.get(ip)
if not entry:
return {"ok": True}
now = time.time()
banned_until = entry.get("banned_until", 0)
if now < banned_until:
remaining = int(banned_until - now)
if remaining < 60:
msg = f"登录尝试过多,请等待 {remaining} 秒"
elif remaining < 3600:
msg = f"登录尝试过多,请等待 {remaining // 60} 分钟"
else:
msg = f"登录尝试过多,请等待 {remaining // 3600} 小时"
return {"ok": False, "message": msg, "retry_after": remaining}
# Ban expired — reset to current tier (keep failure count so next ban is higher tier)
entry["banned_until"] = 0
return {"ok": True}
def record_failure(ip: str = None):
"""Record a failed login attempt. Applies progressive ban if threshold reached."""
if ip is None:
ip = _get_client_ip()
now = time.time()
entry = _RATE_LIMIT_STORE.get(ip, {"failures": 0, "banned_until": 0})
entry["failures"] += 1
duration = _ban_duration_sec(entry["failures"])
if duration > 0:
entry["banned_until"] = now + duration
if current_app:
current_app.logger.warning(
f"Rate limit: IP {ip} banned for {duration // 60}min "
f"after {entry['failures']} failures"
)
_RATE_LIMIT_STORE[ip] = entry
def record_success(ip: str = None):
"""Reset failure counter on successful login."""
if ip is None:
ip = _get_client_ip()
_RATE_LIMIT_STORE.pop(ip, None)
# ─── TOTP ────────────────────────────────────
def verify_totp(raw: str, db_path: str = None, user_id: int = 0) -> bool:
"""Verify a TOTP 6-digit code against a specific user's stored secret."""
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
return False
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
cfg = get_auth_config(db_path, user_id)
secret = cfg.get("totp_secret", "")
if not secret:
return False
import pyotp
totp = pyotp.TOTP(secret)
return totp.verify(raw, valid_window=1)
def current_totp_code(user_id: int, db_path: str = None) -> str:
"""Compute the current live TOTP code for a user's secret (admin helper)."""
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
cfg = get_auth_config(db_path, user_id)
secret = cfg.get("totp_secret", "")
if not secret:
return ""
import pyotp
return pyotp.TOTP(secret).now()
def current_temp_totp_code(user_id: int, db_path: str = None) -> str:
"""Compute the current live temp-TOTP code for a temp account (admin helper)."""
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
from backend.models import get_temp_totp_secret
secret = get_temp_totp_secret(user_id, db_path)
if not secret:
return ""
import pyotp
return pyotp.TOTP(secret).now()
# ─── Recovery Codes ──────────────────────────
def generate_recovery_codes(count: int = 8) -> list:
"""Generate N random alphanumeric recovery codes, each 10 chars."""
alphabet = string.ascii_uppercase + string.digits
codes = []
for _ in range(count):
code = "".join(secrets.choice(alphabet) for _ in range(10))
formatted = f"{code[:4]}-{code[4:]}"
codes.append(formatted)
return codes
def verify_recovery_code(raw: str, db_path: str = None) -> int | None:
"""Verify and consume a recovery code. Returns the user id on success, else None."""
if not raw or not isinstance(raw, str):
return None
normalized = raw.replace("-", "").upper()
if len(normalized) != 10:
return None
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
conn = get_db(db_path)
try:
rows = conn.execute(
"SELECT u.id AS user_id, a.recovery_codes FROM users u "
"LEFT JOIN auth_config a ON a.user_id = u.id "
"WHERE u.status='active'"
).fetchall()
for r in rows:
raw_codes = r["recovery_codes"] or ""
if not raw_codes:
continue
codes = raw_codes.split(",")
for i, stored in enumerate(codes):
stored_norm = stored.replace("-", "").upper()
if secrets.compare_digest(normalized, stored_norm):
codes.pop(i)
conn.execute(
"UPDATE auth_config SET recovery_codes=? WHERE user_id=?",
(",".join(codes), r["user_id"])
)
conn.commit()
return r["user_id"]
return None
finally:
conn.close()