Flask Session -> Double Token
This commit is contained in:
+3
-11
@@ -22,8 +22,7 @@ def create_app(db_path: str = "data.db") -> Flask:
|
||||
template_folder="../frontend/templates",
|
||||
static_folder="../frontend/static",
|
||||
)
|
||||
# Generate a secure random key at startup.
|
||||
# Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts).
|
||||
# Generate a secure random key at startup (kept for compatibility).
|
||||
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)
|
||||
# Defensive check: ensure key is never empty
|
||||
if not app.config["SECRET_KEY"]:
|
||||
@@ -32,12 +31,8 @@ def create_app(db_path: str = "data.db") -> Flask:
|
||||
# Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
# Session hardening for production
|
||||
https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = https
|
||||
app.config["SESSION_PERMANENT"] = False
|
||||
# Dual-token auth: access token in Authorization header, refresh token in
|
||||
# an httpOnly cookie. No Flask session cookies are used anymore.
|
||||
|
||||
# Strict CORS: only allow same-origin requests (no wildcard for cookie auth)
|
||||
CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only
|
||||
@@ -45,9 +40,6 @@ def create_app(db_path: str = "data.db") -> Flask:
|
||||
# Content size limit: 5MB for uploads
|
||||
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
|
||||
|
||||
# Session lifetime: 24 hours idle timeout
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = 86400
|
||||
|
||||
# Global event bus
|
||||
app.event_bus = EventBus()
|
||||
|
||||
|
||||
+142
-78
@@ -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))
|
||||
|
||||
|
||||
|
||||
+290
-5
@@ -13,9 +13,33 @@ Multi-user design:
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
-- Global schema version (single row). All new columns must carry a DEFAULT.
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Auth tokens (dual-token auth): access (short-lived, per-request) and
|
||||
-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored.
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')),
|
||||
view TEXT NOT NULL DEFAULT 'display' CHECK(view IN ('control','display')),
|
||||
temp INTEGER NOT NULL DEFAULT 0,
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
reauth_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
@@ -195,6 +219,7 @@ USER_DATA_TABLES = [
|
||||
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
|
||||
"weather_minutely_cache", "weather_warning_cache", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state",
|
||||
"auth_tokens",
|
||||
]
|
||||
|
||||
|
||||
@@ -311,6 +336,7 @@ def delete_user(user_id: int, db_path: str = None):
|
||||
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
||||
"weather_warning_cache", "weather_config", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
||||
"auth_tokens",
|
||||
]
|
||||
try:
|
||||
for table in ordered:
|
||||
@@ -364,7 +390,8 @@ def get_temp_totp_secret(user_id: int, db_path: str = None) -> str:
|
||||
|
||||
def finalize_temp_account(user_id: int, db_path: str = None):
|
||||
"""Convert a temp account into a formal account:
|
||||
invalidates the temp TOTP and clears the temp flag.
|
||||
invalidates the temp TOTP, clears the temp flag, and revokes every
|
||||
temp=1 token (a temp refresh must never mint formal tokens afterwards).
|
||||
"""
|
||||
conn = get_db(_db_path(db_path))
|
||||
conn.execute(
|
||||
@@ -375,6 +402,10 @@ def finalize_temp_account(user_id: int, db_path: str = None):
|
||||
"UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?",
|
||||
(user_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM auth_tokens WHERE user_id=? AND temp=1",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -479,6 +510,211 @@ def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool
|
||||
return True
|
||||
|
||||
|
||||
# ─── Auth tokens (dual-token auth) ───────────
|
||||
# Access tokens are short-lived and validated on every request; refresh tokens
|
||||
# are long-lived and stored in an httpOnly cookie. Only SHA-256 hashes are
|
||||
# stored in the DB. Kicking a user/device = delete their tokens + bump the
|
||||
# user's session_version (which invalidates every outstanding access token).
|
||||
|
||||
ACCESS_TOKEN_TTL = 15 * 60 # 15 minutes
|
||||
REFRESH_TOKEN_TTL = 30 * 24 * 3600 # 30 days (sliding, renewed on each refresh)
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
import hashlib
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _prune_expired_tokens(conn):
|
||||
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (int(time.time()),))
|
||||
|
||||
|
||||
def create_auth_token(user_id: int, kind: str, view: str = "display",
|
||||
temp: bool = False, ttl: int = None,
|
||||
db_path: str = None) -> str:
|
||||
"""Create an auth token row and return the raw (unhashed) token.
|
||||
Only the SHA-256 hash is persisted."""
|
||||
import secrets as _secrets
|
||||
if ttl is None:
|
||||
ttl = ACCESS_TOKEN_TTL if kind == "access" else REFRESH_TOKEN_TTL
|
||||
raw = _secrets.token_urlsafe(48 if kind == "refresh" else 32)
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
_prune_expired_tokens(conn)
|
||||
ver_row = conn.execute(
|
||||
"SELECT session_version FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
ver = ver_row["session_version"] if ver_row else 0
|
||||
conn.execute(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
|
||||
"session_version, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, _hash_token(raw), kind, view, 1 if temp else 0, ver,
|
||||
int(time.time()) + ttl)
|
||||
)
|
||||
conn.commit()
|
||||
return raw
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _token_lookup(raw: str, kind: str, db_path: str = None) -> dict | None:
|
||||
"""Look up a token by its raw value. For access tokens the row is only
|
||||
valid while it matches the user's current session_version (kick support).
|
||||
Returns None when invalid/expired/user inactive/version bumped."""
|
||||
if not raw:
|
||||
return None
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
h = _hash_token(raw)
|
||||
row = conn.execute(
|
||||
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
|
||||
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind=?",
|
||||
(h, kind)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if row["expires_at"] <= int(time.time()):
|
||||
return None
|
||||
if row["user_status"] != "active":
|
||||
return None
|
||||
if kind == "access" and row["session_version"] != _cur_session_version(row["user_id"], conn):
|
||||
return None
|
||||
return dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _cur_session_version(user_id: int, conn) -> int:
|
||||
r = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return r["session_version"] if r else -1
|
||||
|
||||
|
||||
def find_access_token(raw: str, db_path: str = None) -> dict | None:
|
||||
"""Validate an access token. Returns the row dict or None."""
|
||||
return _token_lookup(raw, "access", db_path)
|
||||
|
||||
|
||||
def rotate_refresh_token(raw: str, db_path: str = None) -> dict | None:
|
||||
"""Atomically validate and rotate a refresh token (concurrency-safe).
|
||||
Returns a fresh {access_token, refresh_token} pair or None. The old refresh
|
||||
token is consumed inside a single transaction, so a replayed/concurrent
|
||||
duplicate can never mint two valid pairs."""
|
||||
if not raw:
|
||||
return None
|
||||
import secrets as _secrets
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
|
||||
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'",
|
||||
(_hash_token(raw),)
|
||||
).fetchone()
|
||||
if (not row or row["expires_at"] <= int(time.time())
|
||||
or row["user_status"] != "active"):
|
||||
conn.rollback()
|
||||
return None
|
||||
cur = conn.execute("DELETE FROM auth_tokens WHERE id=?", (row["id"],))
|
||||
if cur.rowcount == 0:
|
||||
conn.rollback()
|
||||
return None
|
||||
now = int(time.time())
|
||||
acc_raw = _secrets.token_urlsafe(32)
|
||||
ref_raw = _secrets.token_urlsafe(48)
|
||||
ver = row["session_version"]
|
||||
conn.execute(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
|
||||
"session_version, expires_at) VALUES (?, ?, 'access', ?, ?, ?, ?)",
|
||||
(row["user_id"], _hash_token(acc_raw), row["view"], row["temp"],
|
||||
ver, now + ACCESS_TOKEN_TTL)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
|
||||
"session_version, expires_at) VALUES (?, ?, 'refresh', ?, ?, ?, ?)",
|
||||
(row["user_id"], _hash_token(ref_raw), row["view"], row["temp"],
|
||||
ver, now + REFRESH_TOKEN_TTL)
|
||||
)
|
||||
conn.commit()
|
||||
return {"access_token": acc_raw, "refresh_token": ref_raw}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_refresh_token(raw: str, db_path: str = None) -> dict | None:
|
||||
"""Validate a refresh token (status check only; version is not compared
|
||||
here because a refresh token belongs to one device and is deleted on
|
||||
kick, not merely invalidated)."""
|
||||
if not raw:
|
||||
return None
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
h = _hash_token(raw)
|
||||
row = conn.execute(
|
||||
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
|
||||
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'",
|
||||
(h,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if row["expires_at"] <= int(time.time()):
|
||||
return None
|
||||
if row["user_status"] != "active":
|
||||
return None
|
||||
return dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def mark_token_reauth(token_row_id: int, db_path: str = None):
|
||||
"""Stamp the current access token row as re-authenticated (per-device)."""
|
||||
if not token_row_id:
|
||||
return
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
conn.execute("UPDATE auth_tokens SET reauth_at=? WHERE id=?",
|
||||
(int(time.time()), token_row_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_auth_token(raw: str, kind: str = None, db_path: str = None):
|
||||
"""Delete a single token row by raw value (logout of one device)."""
|
||||
if not raw:
|
||||
return
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
h = _hash_token(raw)
|
||||
if kind:
|
||||
conn.execute("DELETE FROM auth_tokens WHERE token_hash=? AND kind=?", (h, kind))
|
||||
else:
|
||||
conn.execute("DELETE FROM auth_tokens WHERE token_hash=?", (h,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_user_tokens(user_id: int, db_path: str = None):
|
||||
"""Delete every token of a user (kick all devices of one account).
|
||||
Callers should also bump_user_session_version() to invalidate access tokens."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
conn.execute("DELETE FROM auth_tokens WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_all_tokens(db_path: str = None):
|
||||
"""Delete every token of every user (Root: kick all users)."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
try:
|
||||
conn.execute("DELETE FROM auth_tokens")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Auth / MFA helpers (per user) ──────────
|
||||
|
||||
def get_auth_config(db_path: str = None, user_id: int = 0) -> dict:
|
||||
@@ -598,6 +834,9 @@ def _ensure_defaults(conn):
|
||||
import secrets
|
||||
import string
|
||||
|
||||
# Schema-version row (single row, id=1).
|
||||
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
||||
|
||||
# Root user (id 0)
|
||||
root = conn.execute("SELECT id FROM users WHERE id=0").fetchone()
|
||||
if root is None:
|
||||
@@ -659,6 +898,10 @@ def _ensure_defaults(conn):
|
||||
# Stores passwords that were just generated, for the startup script to print.
|
||||
_EMITTED_PASSWORDS: list = []
|
||||
|
||||
# Current schema version supported by this program. Bump it whenever the
|
||||
# SCHEMA changes so existing databases get rebuilt+migrated on next start.
|
||||
DB_VERSION = 1
|
||||
|
||||
|
||||
def _table_columns(conn, table_name):
|
||||
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
|
||||
@@ -694,17 +937,44 @@ def _migrate_auth_config(conn_new, conn_old):
|
||||
|
||||
|
||||
def init_db(db_path: str = "data.db"):
|
||||
"""Initialize the database.
|
||||
"""Initialize the database with schema-version aware migration.
|
||||
|
||||
- No database file: create it from the latest SCHEMA and create Root.
|
||||
- Existing database: rebuild it from the latest SCHEMA, migrate compatible
|
||||
data (legacy single-user data becomes Root's), fill missing columns with
|
||||
SCHEMA defaults, then replace the old file (keeping a backup).
|
||||
- Existing database:
|
||||
* no `meta.version` (legacy DB) → rebuild + migrate compatible data
|
||||
* DB version < DB_VERSION → rebuild + migrate
|
||||
* DB version == DB_VERSION → use as-is (fast path)
|
||||
* DB version > DB_VERSION → abort with an error (DB is newer
|
||||
than this program)
|
||||
|
||||
Rebuild copies compatible data (legacy single-user data becomes Root's)
|
||||
and fills missing columns with SCHEMA defaults.
|
||||
"""
|
||||
if not os.path.exists(db_path):
|
||||
conn = get_db(db_path)
|
||||
conn.executescript(SCHEMA)
|
||||
_ensure_defaults(conn)
|
||||
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
conn_probe = get_db(db_path)
|
||||
old_version = _read_db_version(conn_probe)
|
||||
conn_probe.close()
|
||||
|
||||
if old_version is not None and old_version > DB_VERSION:
|
||||
raise SystemExit(
|
||||
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
|
||||
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
|
||||
)
|
||||
|
||||
if old_version is not None and old_version == DB_VERSION:
|
||||
# Same version — no rebuild needed. Just ensure defaults exist.
|
||||
conn = get_db(db_path)
|
||||
_ensure_defaults(conn)
|
||||
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
@@ -764,6 +1034,10 @@ def init_db(db_path: str = "data.db"):
|
||||
# Now fill defaults for any tables that are still empty + Root bootstrap.
|
||||
_ensure_defaults(conn_new)
|
||||
|
||||
# Stamp the migrated database with the program's schema version.
|
||||
conn_new.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||
conn_new.commit()
|
||||
|
||||
conn_old.close()
|
||||
conn_new.close()
|
||||
|
||||
@@ -772,3 +1046,14 @@ def init_db(db_path: str = "data.db"):
|
||||
os.remove(backup_path)
|
||||
os.rename(db_path, backup_path)
|
||||
os.rename(new_db_path, db_path)
|
||||
|
||||
|
||||
def _read_db_version(conn) -> int | None:
|
||||
"""Read the schema version from the `meta` table.
|
||||
Returns None when the DB is legacy (no meta table / no version column).
|
||||
"""
|
||||
try:
|
||||
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
|
||||
return int(row["version"]) if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
+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})
|
||||
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@ def get_thread_user():
|
||||
|
||||
|
||||
def _ctx_user_id():
|
||||
"""Resolve the current user id: request session first, then thread-local."""
|
||||
"""Resolve the current user id: request auth context first, then thread-local."""
|
||||
try:
|
||||
from flask import has_request_context, session
|
||||
from flask import has_request_context, g
|
||||
if has_request_context():
|
||||
uid = session.get("user_id")
|
||||
uid = getattr(g, "user_id", None)
|
||||
if uid is not None:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
|
||||
@@ -155,10 +155,10 @@ def complete_registration(request, credential: dict) -> dict:
|
||||
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
|
||||
if not expected_challenge:
|
||||
# Self-registration fallback: the browser may not echo the user handle,
|
||||
# so resolve the target from the session user.
|
||||
# so resolve the target from the authenticated request context.
|
||||
try:
|
||||
from flask import session
|
||||
uid = session.get("user_id")
|
||||
from backend.auth import current_user_id
|
||||
uid = current_user_id()
|
||||
if uid is not None:
|
||||
challenge_key = f"reg_{uid}"
|
||||
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
|
||||
|
||||
Reference in New Issue
Block a user