GuXiaopeng commited on
Commit
9504d33
·
verified ·
1 Parent(s): fda6aa0

Upload managed_auth.py

Browse files
Files changed (1) hide show
  1. 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
- RESTORE_COOKIE_NAME = "__Host-smartpaper_restore"
41
- RESTORE_COOKIE_DEFAULT_DAYS = 30
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 _read_restore_cookie() -> str:
207
- """Read the restore cookie supplied with the initial Streamlit request."""
208
- try:
209
- context = getattr(st, "context", None)
210
- cookies = getattr(context, "cookies", {}) if context is not None else {}
211
- raw_value = str(cookies.get(RESTORE_COOKIE_NAME, "") or "").strip()
212
- return unquote(raw_value)
213
- except Exception:
214
- # Compatibility fallback: never interrupt the approved login page.
215
- return ""
216
 
217
 
218
- def _render_restore_cookie_command(
219
- token: str | None,
220
  *,
221
- expires_at: str | None = None,
222
- reload_parent: bool = False,
223
- ) -> None:
224
- """Write or delete the recovery cookie without changing visible UI."""
225
- safe_name = json.dumps(RESTORE_COOKIE_NAME)
226
- safe_token = json.dumps(str(token or ""))
227
- max_age = RESTORE_COOKIE_DEFAULT_DAYS * 24 * 60 * 60
228
- parsed_expiry = _parse_iso_timestamp(expires_at)
229
- if parsed_expiry is not None:
230
- max_age = max(
231
- 0,
232
- int((parsed_expiry - datetime.now(timezone.utc)).total_seconds()),
 
 
 
 
233
  )
234
- if not token:
235
- max_age = 0
236
-
237
- script = f"""
238
- <script>
239
- (() => {{
240
- const name = {safe_name};
241
- const value = {safe_token};
242
- const maxAge = {int(max_age)};
243
- const reloadParent = {str(bool(reload_parent)).lower()};
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 _sync_restore_cookie_to_browser(*, reload_parent: bool = False) -> None:
284
- token = str(st.session_state.get("sp_restore_token") or "").strip()
285
- if token:
286
- _render_restore_cookie_command(
287
- token,
288
- expires_at=st.session_state.get("sp_restore_expires_at"),
289
- reload_parent=reload_parent,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  )
 
291
 
 
 
 
292
 
293
- def _delete_restore_cookie(*, reload_parent: bool = False) -> None:
294
- _render_restore_cookie_command(None, reload_parent=reload_parent)
 
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
- _sync_restore_cookie_to_browser()
 
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
- restore_token = str(
389
- st.session_state.get("sp_restore_token") or _read_restore_cookie()
390
  ).strip()
391
- if not restore_token:
392
  return False
393
  try:
394
- result = _post("/session/restore", {"restore_token": 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
- _delete_restore_cookie()
401
  return False
402
- if not result.get("ok") or not result.get("session_token"):
 
 
 
 
 
403
  st.session_state.pop("sp_restore_token", None)
404
  st.session_state.pop("sp_restore_expires_at", None)
405
- _delete_restore_cookie()
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 hidden cookie
637
- # command is rendered without forcing another page reload, so the
638
- # writing interface opens immediately and the restore token has time
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 _read_restore_cookie()
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
- # Delete the browser token first, then let browser JavaScript reload the
780
- # page. This avoids interrupting the cookie deletion with st.rerun().
781
- _delete_restore_cookie(reload_parent=True)
 
782
  _clear_session(clear_restore=True)
783
  _clear_login_request()
784
  _clear_generation_session_state()
785
- st.stop()
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
- _sync_restore_cookie_to_browser()
927
  if _verify_existing_session():
928
  return dict(st.session_state.get("sp_user", {}))
929
- if _try_restore_authenticated_session():
930
- return dict(st.session_state.get("sp_user", {}))
 
 
 
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;'>"