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
+159 -36
View File
@@ -18,16 +18,17 @@ from datetime import datetime
from flask import (
Blueprint, request, jsonify, Response,
render_template, current_app, stream_with_context,
session, redirect, url_for,
g, redirect, url_for,
)
from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
from backend.auth import (
login_required, login_or_temp_required, display_login_required, admin_required,
is_authenticated, is_temp_pending, is_admin, current_user_id,
set_authenticated, set_temp_pending, clear_session,
verify_login, verify_totp, verify_recovery_code,
check_rate_limit, record_failure, record_success,
require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password,
load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie,
rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE,
)
from backend.models import (
get_auth_config, update_auth_config, set_temp_password, clear_temp_password,
@@ -37,6 +38,7 @@ from backend.models import (
get_users, get_user, create_user, update_user, delete_user,
is_temp_account, is_admin_user, finalize_temp_account,
find_user_by_credential_id, bump_all_session_versions,
bump_user_session_version, revoke_user_tokens, revoke_all_tokens,
)
from backend.webauthn_mgr import (
begin_registration, complete_registration,
@@ -53,9 +55,15 @@ def _cur() -> int:
# ════════════════════════════════════════════
# CSRF / Origin Check
# AUTH CONTEXT / CSRF
# ════════════════════════════════════════════
@api.before_request
def _load_token():
"""Resolve the access token (Authorization header) into g.user_id / view / temp."""
load_auth_context()
@api.before_request
def _check_origin():
"""Protect state-changing requests against CSRF by verifying Origin/Referer.
@@ -199,6 +207,8 @@ def api_login():
data = request.get_json() or {}
code = data.get("code", "")
view = data.get("view", "control")
if view not in ("control", "display"):
view = "control" # 未认证端点:非法值降级,避免触发 DB CHECK 约束 500
result = verify_login(code)
if result is None:
@@ -213,21 +223,26 @@ def api_login():
if temp_account:
# Temp account: server-held temp TOTP matched → force registration flow.
record_success()
set_temp_pending(view, uid)
tokens = issue_tokens(uid, view=view, temp=True)
resp["access_token"] = tokens["access_token"]
resp["must_configure_mfa"] = True
else:
cfg = get_auth_config(user_id=uid)
if not cfg.get("totp_enabled"):
return jsonify({"error": "TOTP 尚未启用"}), 403
record_success()
set_authenticated(view, uid)
tokens = issue_tokens(uid, view=view)
resp["access_token"] = tokens["access_token"]
else: # temp_password (Root initial bootstrap)
record_success()
set_temp_pending(view, uid)
tokens = issue_tokens(uid, view=view, temp=True)
resp["access_token"] = tokens["access_token"]
resp["must_configure_mfa"] = True
return jsonify(resp)
response = jsonify(resp)
set_refresh_cookie(response, tokens["refresh_token"])
return response
# ─── Recovery Code Login ─────────────────────
@@ -242,12 +257,16 @@ def api_login_recovery():
data = request.get_json() or {}
code = data.get("code", "")
view = data.get("view", "control")
if view not in ("control", "display"):
view = "control"
uid = verify_recovery_code(code)
if uid is not None:
record_success()
set_authenticated(view, uid)
return jsonify({"ok": True})
tokens = issue_tokens(uid, view=view)
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
record_failure()
return jsonify({"error": "恢复码错误或已使用"}), 401
@@ -356,6 +375,8 @@ def api_webauthn_login_complete():
data = request.get_json() or {}
view = data.get("view", "control")
if view not in ("control", "display"):
view = "control"
credential = data.get("credential", {})
credential_b64 = credential.get("id", "")
@@ -392,8 +413,10 @@ def api_webauthn_login_complete():
record_failure()
return jsonify({"error": "Passkey 凭证不匹配"}), 401
record_success()
set_authenticated(view, uid)
return jsonify({"ok": True})
tokens = issue_tokens(uid, view=view)
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
except ValueError as e:
record_failure()
return jsonify({"error": str(e)}), 401
@@ -401,12 +424,36 @@ def api_webauthn_login_complete():
return jsonify({"error": f"Passkey 验证失败: {e}"}), 500
# ─── Logout ──────────────────────────────────
# ─── Refresh / Logout ───────────────────────
@api.route("/api/auth/refresh", methods=["POST"])
def api_refresh():
"""Exchange the httpOnly refresh cookie for a fresh access+refresh pair.
The refresh token is rotated: the old one is consumed immediately.
CSRF: not exempted from the Origin/Referer check (cookie-authenticated).
"""
raw = request.cookies.get(REFRESH_COOKIE)
if not raw:
return jsonify({"error": "Unauthorized", "login_required": True}), 401
tokens = rotate_refresh(raw)
if not tokens:
response = jsonify({"error": "Unauthorized", "login_required": True})
clear_refresh_cookie(response)
return response, 401
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
@api.route("/api/auth/logout", methods=["POST"])
def api_logout():
clear_session()
return jsonify({"ok": True})
"""Revoke this device's refresh token and clear its cookie.
Other devices of the same account are unaffected.
"""
logout_current_device()
response = jsonify({"ok": True})
clear_refresh_cookie(response)
return response
# ─── Auth Status ─────────────────────────────
@@ -484,9 +531,11 @@ def api_register_request():
@api.route("/mfa-setup")
def mfa_setup_page():
"""MFA configuration page. Shown after temp-password / temp-account login."""
if not is_temp_pending() and not is_authenticated():
return redirect(url_for("api.login_page", next="/mfa-setup"))
"""MFA configuration page. Shown after temp-password / temp-account login.
Authenticated via the httpOnly refresh cookie (page navigation).
"""
if not load_page_context():
return redirect(url_for("api.login_page", next="/mfa-setup", view="control"))
return render_template("mfa_setup.html")
@@ -532,8 +581,12 @@ def api_webauthn_register_complete():
if is_temp_account(uid):
finalize_temp_account(uid)
finalized = True
# The user proved access to the account — mark the session authenticated.
set_authenticated(session.get("mfa_view", "control"), uid)
# The user proved access to the account — issue a formal token pair.
tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
response = jsonify({"ok": True, "finalized": True,
"access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
return jsonify({"ok": True, "finalized": finalized})
except ValueError as e:
return jsonify({"error": str(e)}), 400
@@ -602,13 +655,19 @@ def api_totp_verify():
if is_temp_account(uid):
finalize_temp_account(uid)
finalized = True
set_authenticated(session.get("mfa_view", "control"), uid)
return jsonify({
resp = {
"ok": True,
"recovery_codes": get_recovery_codes(user_id=uid),
"finalized": finalized,
})
}
if finalized:
tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
resp["access_token"] = tokens["access_token"]
response = jsonify(resp)
set_refresh_cookie(response, tokens["refresh_token"])
return response
return jsonify(resp)
return jsonify({"error": "验证码错误,请重试"}), 400
@@ -631,8 +690,10 @@ def api_mfa_complete():
finalize_temp_account(uid)
clear_temp_password(user_id=uid)
update_auth_config(uid, must_configure_mfa=0)
set_authenticated(session.get("mfa_view", "control"), uid)
return jsonify({"ok": True})
tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
# ─── Recovery Codes ──────────────────────────
@@ -977,6 +1038,20 @@ def admin_provision_passkey_complete(user_id):
return jsonify({"error": f"Passkey 注册失败: {e}"}), 500
@api.route("/api/admin/kick_all_users", methods=["POST"])
@admin_required
def admin_kick_all_users():
"""Root: kick EVERY user's control + display devices. All sessions end
immediately (including Root's own devices). High-risk → re-auth required.
"""
if not require_reauth():
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
bump_all_session_versions() # invalidates every access token
revoke_all_tokens() # invalidates every refresh token
current_app.event_bus.publish("kick_all", {"reason": "admin_kick_all"})
return jsonify({"ok": True})
# ════════════════════════════════════════════
# DISPLAY PAGES
# ════════════════════════════════════════════
@@ -984,11 +1059,13 @@ def admin_provision_passkey_complete(user_id):
@api.route("/")
@api.route("/index")
def display_page():
# 页面导航无法发送 Authorization header → 用 refresh cookie 鉴权。
# custom 模式同样需要登录(避免匿名访问泄露账户自定义 HTML)。
if not load_page_context():
return redirect(url_for("api.login_page", next=request.path))
cfg = get_display_config(user_id=_cur())
if cfg.get("mode") == "custom":
return render_template("display_custom.html", custom_html=cfg.get("custom_html", ""))
if not is_authenticated() and not is_temp_pending():
return redirect(url_for("api.login_page", next=request.path))
return render_template("display.html")
@@ -997,8 +1074,13 @@ def display_page():
# ════════════════════════════════════════════
@api.route("/control")
@login_required
def control_page():
"""Control panel. Page navigation authenticates via the refresh cookie;
temp-pending users are sent to MFA setup."""
if not load_page_context():
return redirect(url_for("api.login_page", next="/control", view="control"))
if getattr(g, "temp_pending", False):
return redirect(url_for("api.mfa_setup_page"))
return render_template("control.html")
@@ -1006,12 +1088,49 @@ def control_page():
# DISPLAY / SSE / STATE
# ════════════════════════════════════════════
# One-time, short-lived tickets used to open the SSE stream (EventSource
# cannot send Authorization headers). The ticket is consumed on connect.
_SSE_TICKETS: dict = {} # ticket -> {"user_id": int, "view": str, "expires": float}
_SSE_TICKET_TTL = 60
def _clean_sse_tickets():
now = _time.time()
expired = [k for k, v in _SSE_TICKETS.items() if v["expires"] < now]
for k in expired:
_SSE_TICKETS.pop(k, None)
@api.route("/api/events/ticket", methods=["POST"])
@display_login_required
def sse_ticket():
"""Issue a one-time ticket for opening the SSE stream."""
_clean_sse_tickets()
from backend.models import get_user_session_version
ticket = secrets.token_urlsafe(24)
_SSE_TICKETS[ticket] = {
"user_id": _cur(),
"view": getattr(g, "auth_view", "display"),
"session_version": get_user_session_version(_cur()),
"expires": _time.time() + _SSE_TICKET_TTL,
}
return jsonify({"ticket": ticket}), 201
@api.route("/api/events")
def sse_stream():
"""Server-Sent Events for real-time updates (scoped to the current user)."""
if not is_authenticated() and not is_temp_pending():
"""Server-Sent Events for real-time updates (scoped to the current user).
Authenticated via a one-time ticket from /api/events/ticket.
"""
ticket = request.args.get("ticket", "")
entry = _SSE_TICKETS.pop(ticket, None) if ticket else None
if not entry or entry["expires"] < _time.time():
return jsonify({"error": "Unauthorized", "login_required": True}), 401
uid = _cur()
# A ticket is void once the user was kicked / deleted (version bumped).
from backend.models import get_user_session_version
if get_user_session_version(entry["user_id"]) != entry.get("session_version"):
return jsonify({"error": "Unauthorized", "login_required": True}), 401
uid = entry["user_id"]
def generate():
q = current_app.event_bus.subscribe(user_id=uid)
@@ -1055,6 +1174,7 @@ def get_display_state():
update_schedule = weather_mgr.get_next_update_times(user_id=uid)
minutely = weather_mgr.get_cached_minutely(user_id=uid)
warnings = weather_mgr.get_cached_warnings(user_id=uid)
from backend.time_util import get_tz_str
return jsonify({
"events": events,
@@ -1067,6 +1187,7 @@ def get_display_state():
"update_schedule": update_schedule,
"minutely": minutely,
"warnings": warnings,
"timezone": get_tz_str(),
})
@@ -1133,13 +1254,15 @@ def force_refresh_display():
@api.route("/api/display/kick_all", methods=["POST"])
@admin_required
def kick_all_displays():
"""Kick ALL sessions (including all users) by bumping every user's version.
Old session cookies become invalid immediately. Also broadcasts SSE.
@login_required
def kick_own_devices():
"""Kick every device logged into the CURRENT account — control AND display
devices, including this control device itself. All sessions end immediately.
"""
bump_all_session_versions()
current_app.event_bus.publish("kick_all", {})
uid = _cur()
bump_user_session_version(uid) # invalidates every access token of this user
revoke_user_tokens(uid) # invalidates every refresh token of this user
current_app.event_bus.publish("kick_all", {"reason": "self_kick"}, user_id=uid)
return jsonify({"ok": True})