Flask Session -> Double Token

This commit is contained in:
喆OvO
2026-08-11 19:57:21 +08:00
parent 0fef309972
commit cbbb2e6d34
18 changed files with 1375 additions and 607 deletions
+142 -78
View File
@@ -1,30 +1,38 @@
"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password.
"""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:
- 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.
- 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 session, redirect, url_for, request, jsonify, current_app, g
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,
get_user_session_version, user_exists, is_admin_user, get_db,
user_exists, is_admin_user, get_db,
create_auth_token, find_access_token,
delete_auth_token, REFRESH_TOKEN_TTL, ACCESS_TOKEN_TTL,
)
# ─── 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
# ─── 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:
@@ -33,44 +41,60 @@ def _db_path(preferred: str = None) -> str:
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:
# ─── 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
# Cache the validation result for the duration of this request.
if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid:
row = find_access_token(token)
if not row:
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
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:
"""Returns True if the user has completed MFA authentication."""
_validate_version()
return session.get(SKEY_AUTH) is True
"""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 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
"""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 (session user, or scheduler thread user)."""
"""Return the id of the current user (token user, or scheduler thread user)."""
try:
from flask import has_request_context
if has_request_context():
_validate_version()
uid = session.get(SKEY_USER)
uid = getattr(_g(), "user_id", None)
if uid is not None:
return int(uid)
except Exception:
@@ -103,64 +127,104 @@ def is_admin() -> bool:
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
# ─── 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_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 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_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)
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. TOTP-first with a Passkey fallback
# (the fallback ceremony is handled by the routes layer).
# 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():
"""Check if recent re-authentication is still valid.
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
last = session.get(SKEY_REAUTH)
if last and (time.time() - last) < _REAUTH_TTL:
return True
return False
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."""
session[SKEY_REAUTH] = time.time()
"""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 ──────────────────────────────
@@ -168,7 +232,7 @@ def mark_reauth():
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")
view_param = getattr(_g(), "auth_view", None) or "control"
return redirect(url_for("api.login_page", next=request.path, view=view_param))