511 lines
18 KiB
Python
511 lines
18 KiB
Python
"""Dual-token authentication: short-lived access token (Authorization header,
|
|
validated per-request) + long-lived refresh token (httpOnly cookie, rotated
|
|
on each refresh). TOTP / Passkey / recovery codes / temp password still drive
|
|
the login ceremony; the result is a token pair instead of a Flask session.
|
|
|
|
Multi-user support:
|
|
- Every request resolves its user from the access token (`g.user_id`).
|
|
- Access tokens snapshot the user's `session_version`; bumping it (delete user,
|
|
kick devices) invalidates every outstanding access token immediately.
|
|
- Refresh tokens are stored hashed in `auth_tokens`; deleting them (logout of
|
|
one device / kick) ends the corresponding device session.
|
|
- Temp-account / temp-password logins get `temp=1` tokens; the user is forced
|
|
through MFA setup until they complete it, then a fresh non-temp pair is
|
|
issued.
|
|
"""
|
|
import functools
|
|
import os
|
|
import secrets
|
|
import string
|
|
import time
|
|
from flask import g, redirect, url_for, request, jsonify, current_app
|
|
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,
|
|
user_exists, is_admin_user, get_db,
|
|
create_auth_token, find_access_token,
|
|
delete_auth_token, REFRESH_TOKEN_TTL, ACCESS_TOKEN_TTL,
|
|
)
|
|
|
|
# ─── Token / cookie constants ────────────────
|
|
REFRESH_COOKIE = "refresh_token" # httpOnly, SameSite=Lax, Secure under HTTPS
|
|
|
|
|
|
def _https_enabled() -> bool:
|
|
return os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _db_path(preferred: str = None) -> str:
|
|
if preferred:
|
|
return preferred
|
|
return current_app.config.get("DB_PATH", "data.db")
|
|
|
|
|
|
# ─── Auth context (per-request, populated by before_request) ───
|
|
|
|
def load_auth_context():
|
|
"""Parse `Authorization: Bearer <access>` and fill `g` with the auth context.
|
|
Runs for every request (cheap: no token → no-op)."""
|
|
token = _extract_bearer()
|
|
if not token:
|
|
return
|
|
row = find_access_token(token)
|
|
if not row:
|
|
return
|
|
g.user_id = row["user_id"]
|
|
g.auth_view = row["view"]
|
|
g.temp_pending = bool(row["temp"])
|
|
g.auth_authenticated = True
|
|
g.access_token_id = row["id"]
|
|
g.reauth_at = row.get("reauth_at") or 0
|
|
|
|
|
|
def _extract_bearer() -> str | None:
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
return auth[7:].strip()
|
|
return None
|
|
|
|
|
|
def _g():
|
|
return g
|
|
|
|
|
|
def is_authenticated() -> bool:
|
|
"""True when the request carries a valid, non-temp access token."""
|
|
try:
|
|
return (getattr(_g(), "auth_authenticated", False)
|
|
and not getattr(_g(), "temp_pending", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def is_temp_pending() -> bool:
|
|
"""True when logged in with a temp password / temp account (MFA setup required)."""
|
|
try:
|
|
return (getattr(_g(), "auth_authenticated", False)
|
|
and bool(getattr(_g(), "temp_pending", False)))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def current_user_id() -> int | None:
|
|
"""Return the id of the current user (token user, or scheduler thread user)."""
|
|
try:
|
|
from flask import has_request_context
|
|
if has_request_context():
|
|
uid = getattr(_g(), "user_id", None)
|
|
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)
|
|
|
|
|
|
# ─── Token issuance / refresh / revocation ───
|
|
|
|
def issue_tokens(user_id: int, view: str = "control", temp: bool = False) -> dict:
|
|
"""Create a fresh access+refresh token pair. The refresh token must be
|
|
delivered to the client via an httpOnly cookie by the caller."""
|
|
access = create_auth_token(user_id, "access", view=view, temp=temp)
|
|
refresh = create_auth_token(user_id, "refresh", view=view, temp=temp)
|
|
return {"access_token": access, "refresh_token": refresh}
|
|
|
|
|
|
def set_refresh_cookie(resp, refresh: str):
|
|
"""Attach the refresh token as an httpOnly cookie on the response."""
|
|
resp.set_cookie(
|
|
REFRESH_COOKIE, refresh,
|
|
max_age=REFRESH_TOKEN_TTL,
|
|
httponly=True,
|
|
samesite="Lax",
|
|
secure=_https_enabled(),
|
|
path="/",
|
|
)
|
|
|
|
|
|
def clear_refresh_cookie(resp):
|
|
resp.delete_cookie(REFRESH_COOKIE, path="/")
|
|
|
|
|
|
def rotate_refresh(refresh_raw: str) -> dict | None:
|
|
"""Validate a refresh token, revoke it and issue a fresh pair (rotation).
|
|
Atomic (single transaction): a replayed old token can never mint twice.
|
|
Returns the new {access_token, refresh_token} or None when invalid."""
|
|
from backend.models import rotate_refresh_token
|
|
return rotate_refresh_token(refresh_raw)
|
|
|
|
|
|
def logout_current_device():
|
|
"""Revoke this device's refresh token (from the httpOnly cookie) and also
|
|
the presented access token, if any (so a stolen access dies immediately)."""
|
|
raw = request.cookies.get(REFRESH_COOKIE)
|
|
if raw:
|
|
delete_auth_token(raw, kind="refresh")
|
|
acc = _extract_bearer()
|
|
if acc:
|
|
delete_auth_token(acc, kind="access")
|
|
|
|
|
|
def load_page_context() -> bool:
|
|
"""Authenticate a top-level page navigation via the httpOnly refresh cookie
|
|
(browsers cannot attach Authorization headers to navigations).
|
|
Fills g.user_id / g.auth_view / g.temp_pending on success.
|
|
Returns False when unauthenticated."""
|
|
raw = request.cookies.get(REFRESH_COOKIE)
|
|
if not raw:
|
|
return False
|
|
from backend.models import find_refresh_token
|
|
row = find_refresh_token(raw)
|
|
if not row:
|
|
return False
|
|
g.user_id = row["user_id"]
|
|
g.auth_view = row["view"]
|
|
g.temp_pending = bool(row["temp"])
|
|
g.page_authenticated = True
|
|
return True
|
|
|
|
|
|
# ─── Re-authentication guard ─────────────────
|
|
# Sensitive operations (user management, MFA changes) require re-confirming
|
|
# identity within the last N seconds. The marker lives on the ACCESS TOKEN row,
|
|
# so it is per-device (each device's token must re-auth independently) and
|
|
# lost on token rotation (a fresh access token requires a fresh re-auth).
|
|
|
|
_REAUTH_TTL = 300 # 5 minutes
|
|
|
|
|
|
def require_reauth() -> bool:
|
|
"""Check if recent re-authentication is still valid for THIS access token.
|
|
Skips the check for temp_pending users (first-time MFA setup flow).
|
|
"""
|
|
if is_temp_pending():
|
|
mark_reauth()
|
|
return True
|
|
try:
|
|
last = getattr(g, "reauth_at", 0) or 0
|
|
except Exception:
|
|
return False
|
|
return bool(last) and (time.time() - last) < _REAUTH_TTL
|
|
|
|
|
|
def mark_reauth():
|
|
"""Mark re-authentication as done now for the current access token."""
|
|
try:
|
|
from flask import g as _g
|
|
from backend.models import mark_token_reauth
|
|
mark_token_reauth(getattr(_g, "access_token_id", None))
|
|
except Exception as e:
|
|
try:
|
|
current_app.logger.warning(f"mark_reauth failed: {e}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ─── Decorators ──────────────────────────────
|
|
|
|
def _unauthorized(request):
|
|
if request.is_json or request.path.startswith("/api/"):
|
|
return jsonify({"error": "Unauthorized", "login_required": True}), 401
|
|
view_param = getattr(_g(), "auth_view", None) or "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()
|