Files
iot-dboard/backend/auth.py
T
2026-08-11 22:07:55 +08:00

510 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, get_temp_password,
is_admin_user, get_db,
create_auth_token, find_access_token,
delete_auth_token, REFRESH_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()