Spaces:
Running
Running
Upload managed_auth.py
Browse files- managed_auth.py +116 -108
managed_auth.py
CHANGED
|
@@ -4,19 +4,16 @@
|
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import base64
|
|
|
|
| 7 |
import html
|
| 8 |
import io
|
| 9 |
-
import json
|
| 10 |
import os
|
| 11 |
import time
|
| 12 |
from datetime import datetime, timezone
|
| 13 |
from typing import Any, Callable
|
| 14 |
-
from urllib.parse import unquote
|
| 15 |
-
|
| 16 |
import qrcode
|
| 17 |
import requests
|
| 18 |
import streamlit as st
|
| 19 |
-
import streamlit.components.v1 as components
|
| 20 |
from qrcode.constants import ERROR_CORRECT_M
|
| 21 |
|
| 22 |
try:
|
|
@@ -26,6 +23,11 @@ except ImportError: # Safe fallback during a delayed dependency rebuild.
|
|
| 26 |
|
| 27 |
from i18n import tr
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# The authentication endpoint should normally respond within a few seconds.
|
| 31 |
# Use a direct connection first (ignoring inherited proxy variables), then one
|
|
@@ -37,8 +39,8 @@ AUTO_LOGIN_POLL_INTERVAL_SECONDS = 2.0
|
|
| 37 |
TRANSIENT_HTTP_STATUS = {502, 503, 504}
|
| 38 |
DEFAULT_PUBLIC_URL = "https://sqhcn-smartpaper.hf.space"
|
| 39 |
ACCESS_QR_FILENAME = "SmartPaper_Access_QR.png"
|
| 40 |
-
|
| 41 |
-
|
| 42 |
|
| 43 |
_DIRECT_SESSION = requests.Session()
|
| 44 |
_DIRECT_SESSION.trust_env = False
|
|
@@ -203,96 +205,93 @@ def _clear_session(*, clear_restore: bool = False) -> None:
|
|
| 203 |
st.session_state.pop("sp_restore_expires_at", None)
|
| 204 |
|
| 205 |
|
| 206 |
-
def
|
| 207 |
-
"""
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
except Exception:
|
| 214 |
-
# Compatibility fallback: never interrupt the approved login page.
|
| 215 |
-
return ""
|
| 216 |
|
| 217 |
|
| 218 |
-
def
|
| 219 |
-
token: str | None,
|
| 220 |
*,
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
)
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
(() =
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
const cookieText = `${{name}}=${{encodeURIComponent(value)}}; Max-Age=${{maxAge}}; Path=/; Secure; SameSite=Strict`;
|
| 245 |
-
|
| 246 |
-
let targetDocument = document;
|
| 247 |
-
let cookieActionSucceeded = false;
|
| 248 |
-
|
| 249 |
-
try {{
|
| 250 |
-
if (window.parent && window.parent.document) {{
|
| 251 |
-
targetDocument = window.parent.document;
|
| 252 |
-
}}
|
| 253 |
-
targetDocument.cookie = cookieText;
|
| 254 |
-
const cookiePresent = targetDocument.cookie
|
| 255 |
-
.split("; ")
|
| 256 |
-
.some((entry) => entry.startsWith(`${{name}}=`));
|
| 257 |
-
cookieActionSucceeded = maxAge === 0
|
| 258 |
-
? !cookiePresent
|
| 259 |
-
: cookiePresent;
|
| 260 |
-
}} catch (error) {{
|
| 261 |
-
console.error("SmartPaper restore-cookie action failed", error);
|
| 262 |
-
}}
|
| 263 |
-
|
| 264 |
-
if (!cookieActionSucceeded) {{
|
| 265 |
-
console.error("SmartPaper restore-cookie verification failed");
|
| 266 |
-
}}
|
| 267 |
-
|
| 268 |
-
if (reloadParent && cookieActionSucceeded) {{
|
| 269 |
-
window.setTimeout(() => {{
|
| 270 |
-
try {{
|
| 271 |
-
window.parent.location.reload();
|
| 272 |
-
}} catch (error) {{
|
| 273 |
-
window.location.reload();
|
| 274 |
-
}}
|
| 275 |
-
}}, 180);
|
| 276 |
-
}}
|
| 277 |
-
}})();
|
| 278 |
-
</script>
|
| 279 |
-
"""
|
| 280 |
-
components.html(script, height=0, width=0)
|
| 281 |
|
| 282 |
|
| 283 |
-
def
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
)
|
|
|
|
| 291 |
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
|
|
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
def _verify_existing_session() -> bool:
|
| 298 |
session_token = st.session_state.get("sp_session_token")
|
|
@@ -379,35 +378,41 @@ def _store_authenticated_session(result: dict[str, Any]) -> None:
|
|
| 379 |
st.session_state["sp_restore_expires_at"] = result.get(
|
| 380 |
"restore_expires_at"
|
| 381 |
)
|
| 382 |
-
|
|
|
|
| 383 |
_clear_login_request()
|
| 384 |
|
| 385 |
|
| 386 |
-
def _try_restore_authenticated_session() -> bool:
|
| 387 |
"""Restore a short-lived session after refresh without another QR scan."""
|
| 388 |
-
|
| 389 |
-
st.session_state.get("sp_restore_token") or
|
| 390 |
).strip()
|
| 391 |
-
if not
|
| 392 |
return False
|
| 393 |
try:
|
| 394 |
-
result = _post("/session/restore", {"restore_token":
|
| 395 |
except (AuthServiceTimeoutError, AuthServiceConnectionError):
|
|
|
|
| 396 |
return False
|
| 397 |
except Exception:
|
| 398 |
st.session_state.pop("sp_restore_token", None)
|
| 399 |
st.session_state.pop("sp_restore_expires_at", None)
|
| 400 |
-
|
| 401 |
return False
|
| 402 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
st.session_state.pop("sp_restore_token", None)
|
| 404 |
st.session_state.pop("sp_restore_expires_at", None)
|
| 405 |
-
|
| 406 |
return False
|
| 407 |
_store_authenticated_session(result)
|
| 408 |
return True
|
| 409 |
|
| 410 |
-
|
| 411 |
def _public_url() -> str:
|
| 412 |
"""Return the permanent, shareable SmartPaper address."""
|
| 413 |
value = _runtime_value("SMARTPAPER_PUBLIC_URL") or DEFAULT_PUBLIC_URL
|
|
@@ -633,10 +638,9 @@ def _poll_login_once(
|
|
| 633 |
|
| 634 |
if status == "authenticated":
|
| 635 |
# Store the authenticated Streamlit session first and rerun within the
|
| 636 |
-
# same browser session. On the authenticated rerun, the
|
| 637 |
-
#
|
| 638 |
-
#
|
| 639 |
-
# to persist for future manual refreshes.
|
| 640 |
_store_authenticated_session(result)
|
| 641 |
status_box.success(tr(language, "login_auto_success"))
|
| 642 |
st.rerun()
|
|
@@ -746,7 +750,7 @@ def logout_alipay_session(
|
|
| 746 |
) -> None:
|
| 747 |
session_token = str(st.session_state.get("sp_session_token") or "").strip()
|
| 748 |
restore_token = str(
|
| 749 |
-
st.session_state.get("sp_restore_token") or
|
| 750 |
).strip()
|
| 751 |
user_id = str((st.session_state.get("sp_user") or {}).get("id") or "")
|
| 752 |
task = st.session_state.get("sp_generation_task") or {}
|
|
@@ -776,13 +780,14 @@ def logout_alipay_session(
|
|
| 776 |
except Exception:
|
| 777 |
pass
|
| 778 |
|
| 779 |
-
#
|
| 780 |
-
#
|
| 781 |
-
|
|
|
|
| 782 |
_clear_session(clear_restore=True)
|
| 783 |
_clear_login_request()
|
| 784 |
_clear_generation_session_state()
|
| 785 |
-
st.
|
| 786 |
|
| 787 |
|
| 788 |
def render_logged_in_account(
|
|
@@ -923,11 +928,14 @@ def require_alipay_login(
|
|
| 923 |
language: str = "zh",
|
| 924 |
) -> dict[str, Any]:
|
| 925 |
"""Require Alipay authentication and return the verified user."""
|
| 926 |
-
|
| 927 |
if _verify_existing_session():
|
| 928 |
return dict(st.session_state.get("sp_user", {}))
|
| 929 |
-
if _try_restore_authenticated_session():
|
| 930 |
-
|
|
|
|
|
|
|
|
|
|
| 931 |
|
| 932 |
st.markdown(
|
| 933 |
f"<h3 style='text-align:center;'>"
|
|
|
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import base64
|
| 7 |
+
import hashlib
|
| 8 |
import html
|
| 9 |
import io
|
|
|
|
| 10 |
import os
|
| 11 |
import time
|
| 12 |
from datetime import datetime, timezone
|
| 13 |
from typing import Any, Callable
|
|
|
|
|
|
|
| 14 |
import qrcode
|
| 15 |
import requests
|
| 16 |
import streamlit as st
|
|
|
|
| 17 |
from qrcode.constants import ERROR_CORRECT_M
|
| 18 |
|
| 19 |
try:
|
|
|
|
| 23 |
|
| 24 |
from i18n import tr
|
| 25 |
|
| 26 |
+
try:
|
| 27 |
+
from smartpaper_restore_bridge import restore_bridge
|
| 28 |
+
except Exception: # Fail open to the approved QR login page.
|
| 29 |
+
restore_bridge = None
|
| 30 |
+
|
| 31 |
|
| 32 |
# The authentication endpoint should normally respond within a few seconds.
|
| 33 |
# Use a direct connection first (ignoring inherited proxy variables), then one
|
|
|
|
| 39 |
TRANSIENT_HTTP_STATUS = {502, 503, 504}
|
| 40 |
DEFAULT_PUBLIC_URL = "https://sqhcn-smartpaper.hf.space"
|
| 41 |
ACCESS_QR_FILENAME = "SmartPaper_Access_QR.png"
|
| 42 |
+
RESTORE_BRIDGE_COMPONENT_KEY = "sp_restore_bridge"
|
| 43 |
+
RESTORE_DELETE_PENDING_KEY = "sp_restore_delete_pending"
|
| 44 |
|
| 45 |
_DIRECT_SESSION = requests.Session()
|
| 46 |
_DIRECT_SESSION.trust_env = False
|
|
|
|
| 205 |
st.session_state.pop("sp_restore_expires_at", None)
|
| 206 |
|
| 207 |
|
| 208 |
+
def _restore_command_id(action: str, token: str = "") -> str:
|
| 209 |
+
"""Return a non-secret identifier used to reject stale component values."""
|
| 210 |
+
normalized_action = str(action or "read").strip().lower()
|
| 211 |
+
if normalized_action != "write":
|
| 212 |
+
return normalized_action
|
| 213 |
+
digest = hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()[:16]
|
| 214 |
+
return f"write:{digest}"
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
+
def _call_restore_bridge(
|
|
|
|
| 218 |
*,
|
| 219 |
+
action: str,
|
| 220 |
+
token: str = "",
|
| 221 |
+
expires_at: str = "",
|
| 222 |
+
) -> dict[str, Any] | None:
|
| 223 |
+
"""Call the invisible browser bridge and ignore stale/asynchronous values."""
|
| 224 |
+
if restore_bridge is None:
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
command_id = _restore_command_id(action, token)
|
| 228 |
+
try:
|
| 229 |
+
result = restore_bridge(
|
| 230 |
+
action=action,
|
| 231 |
+
command_id=command_id,
|
| 232 |
+
token=token,
|
| 233 |
+
expires_at=expires_at,
|
| 234 |
+
key=RESTORE_BRIDGE_COMPONENT_KEY,
|
| 235 |
)
|
| 236 |
+
except Exception:
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
if not isinstance(result, dict):
|
| 240 |
+
return None
|
| 241 |
+
if str(result.get("command_id") or "") != command_id:
|
| 242 |
+
return None
|
| 243 |
+
if str(result.get("action") or "") != action:
|
| 244 |
+
return None
|
| 245 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
|
| 248 |
+
def _queue_restore_delete() -> None:
|
| 249 |
+
st.session_state[RESTORE_DELETE_PENDING_KEY] = True
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _sync_restore_bridge() -> tuple[str, bool]:
|
| 253 |
+
"""Synchronize recovery-token state with browser localStorage.
|
| 254 |
+
|
| 255 |
+
Returns ``(token, suppress_restore)``. ``suppress_restore`` is true while
|
| 256 |
+
an explicit logout/delete command is pending, preventing a stale browser
|
| 257 |
+
token from restoring the just-closed session.
|
| 258 |
+
"""
|
| 259 |
+
if bool(st.session_state.get(RESTORE_DELETE_PENDING_KEY)):
|
| 260 |
+
result = _call_restore_bridge(action="delete")
|
| 261 |
+
if restore_bridge is None or (
|
| 262 |
+
result
|
| 263 |
+
and str(result.get("status") or "")
|
| 264 |
+
in {"deleted", "missing", "error"}
|
| 265 |
+
):
|
| 266 |
+
st.session_state.pop(RESTORE_DELETE_PENDING_KEY, None)
|
| 267 |
+
return "", True
|
| 268 |
+
|
| 269 |
+
session_token = str(
|
| 270 |
+
st.session_state.get("sp_restore_token") or ""
|
| 271 |
+
).strip()
|
| 272 |
+
if session_token:
|
| 273 |
+
_call_restore_bridge(
|
| 274 |
+
action="write",
|
| 275 |
+
token=session_token,
|
| 276 |
+
expires_at=str(
|
| 277 |
+
st.session_state.get("sp_restore_expires_at") or ""
|
| 278 |
+
),
|
| 279 |
)
|
| 280 |
+
return session_token, False
|
| 281 |
|
| 282 |
+
result = _call_restore_bridge(action="read")
|
| 283 |
+
if not result or str(result.get("status") or "") != "found":
|
| 284 |
+
return "", False
|
| 285 |
|
| 286 |
+
browser_token = str(result.get("token") or "").strip()
|
| 287 |
+
if not browser_token:
|
| 288 |
+
return "", False
|
| 289 |
|
| 290 |
+
st.session_state["sp_restore_token"] = browser_token
|
| 291 |
+
st.session_state["sp_restore_expires_at"] = str(
|
| 292 |
+
result.get("expires_at") or ""
|
| 293 |
+
)
|
| 294 |
+
return browser_token, False
|
| 295 |
|
| 296 |
def _verify_existing_session() -> bool:
|
| 297 |
session_token = st.session_state.get("sp_session_token")
|
|
|
|
| 378 |
st.session_state["sp_restore_expires_at"] = result.get(
|
| 379 |
"restore_expires_at"
|
| 380 |
)
|
| 381 |
+
# A newly authenticated session supersedes any older pending delete.
|
| 382 |
+
st.session_state.pop(RESTORE_DELETE_PENDING_KEY, None)
|
| 383 |
_clear_login_request()
|
| 384 |
|
| 385 |
|
| 386 |
+
def _try_restore_authenticated_session(restore_token: str = "") -> bool:
|
| 387 |
"""Restore a short-lived session after refresh without another QR scan."""
|
| 388 |
+
token = str(
|
| 389 |
+
restore_token or st.session_state.get("sp_restore_token") or ""
|
| 390 |
).strip()
|
| 391 |
+
if not token:
|
| 392 |
return False
|
| 393 |
try:
|
| 394 |
+
result = _post("/session/restore", {"restore_token": token})
|
| 395 |
except (AuthServiceTimeoutError, AuthServiceConnectionError):
|
| 396 |
+
# Preserve the browser token during a temporary network interruption.
|
| 397 |
return False
|
| 398 |
except Exception:
|
| 399 |
st.session_state.pop("sp_restore_token", None)
|
| 400 |
st.session_state.pop("sp_restore_expires_at", None)
|
| 401 |
+
_queue_restore_delete()
|
| 402 |
return False
|
| 403 |
+
if (
|
| 404 |
+
not result.get("ok")
|
| 405 |
+
or not result.get("session_token")
|
| 406 |
+
or not result.get("restore_token")
|
| 407 |
+
or not result.get("user")
|
| 408 |
+
):
|
| 409 |
st.session_state.pop("sp_restore_token", None)
|
| 410 |
st.session_state.pop("sp_restore_expires_at", None)
|
| 411 |
+
_queue_restore_delete()
|
| 412 |
return False
|
| 413 |
_store_authenticated_session(result)
|
| 414 |
return True
|
| 415 |
|
|
|
|
| 416 |
def _public_url() -> str:
|
| 417 |
"""Return the permanent, shareable SmartPaper address."""
|
| 418 |
value = _runtime_value("SMARTPAPER_PUBLIC_URL") or DEFAULT_PUBLIC_URL
|
|
|
|
| 638 |
|
| 639 |
if status == "authenticated":
|
| 640 |
# Store the authenticated Streamlit session first and rerun within the
|
| 641 |
+
# same browser session. On the authenticated rerun, the invisible
|
| 642 |
+
# local-storage bridge persists the recovery token without forcing a
|
| 643 |
+
# full page reload, so the writing interface opens immediately.
|
|
|
|
| 644 |
_store_authenticated_session(result)
|
| 645 |
status_box.success(tr(language, "login_auto_success"))
|
| 646 |
st.rerun()
|
|
|
|
| 750 |
) -> None:
|
| 751 |
session_token = str(st.session_state.get("sp_session_token") or "").strip()
|
| 752 |
restore_token = str(
|
| 753 |
+
st.session_state.get("sp_restore_token") or ""
|
| 754 |
).strip()
|
| 755 |
user_id = str((st.session_state.get("sp_user") or {}).get("id") or "")
|
| 756 |
task = st.session_state.get("sp_generation_task") or {}
|
|
|
|
| 780 |
except Exception:
|
| 781 |
pass
|
| 782 |
|
| 783 |
+
# Revoke server state first, then queue a browser-storage deletion. The
|
| 784 |
+
# next run renders the normal login page while the invisible component
|
| 785 |
+
# removes localStorage; no full-page JavaScript reload is required.
|
| 786 |
+
_queue_restore_delete()
|
| 787 |
_clear_session(clear_restore=True)
|
| 788 |
_clear_login_request()
|
| 789 |
_clear_generation_session_state()
|
| 790 |
+
st.rerun()
|
| 791 |
|
| 792 |
|
| 793 |
def render_logged_in_account(
|
|
|
|
| 928 |
language: str = "zh",
|
| 929 |
) -> dict[str, Any]:
|
| 930 |
"""Require Alipay authentication and return the verified user."""
|
| 931 |
+
bridge_token, suppress_restore = _sync_restore_bridge()
|
| 932 |
if _verify_existing_session():
|
| 933 |
return dict(st.session_state.get("sp_user", {}))
|
| 934 |
+
if not suppress_restore and _try_restore_authenticated_session(bridge_token):
|
| 935 |
+
# The Worker rotates restore tokens. Rerun within the same Streamlit
|
| 936 |
+
# session so the bridge receives and persists the newly issued token
|
| 937 |
+
# before any later browser refresh.
|
| 938 |
+
st.rerun()
|
| 939 |
|
| 940 |
st.markdown(
|
| 941 |
f"<h3 style='text-align:center;'>"
|