Flask Session -> Double Token
This commit is contained in:
+159
-36
@@ -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})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user