447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""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()
|