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