From 530b8a8269860166e41b09b9e379da10b89fc81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=86OvO?= <1261686748@qq.com> Date: Thu, 13 Aug 2026 12:50:18 +0800 Subject: [PATCH] Passkey bug fix in gunicorn --- backend/routes.py | 25 ++++++++++++- backend/webauthn_mgr.py | 35 ++++++++++++------ frontend/static/js/control.js | 17 +++++++++ tests/test_multi_user.py | 70 ++++++++++++++++++++++++++++++++++- 4 files changed, 131 insertions(+), 16 deletions(-) diff --git a/backend/routes.py b/backend/routes.py index 7dd80a0..cf3c5e1 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -544,7 +544,7 @@ def api_webauthn_register_complete(): uid = _cur() try: - result = complete_registration(request, credential) + result = complete_registration(request, credential, user_id=uid) if result.get("user_id") != uid: return jsonify({"error": "注册上下文不匹配,请重试"}), 400 update_auth_config( @@ -569,6 +569,27 @@ def api_webauthn_register_complete(): return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 +@api.route("/api/auth/passkey", methods=["DELETE"]) +@login_required +def api_remove_passkey(): + """Remove the current user's Passkey (re-auth required). + + If this leaves the account with no MFA method at all, the account is + flagged must_configure_mfa so the next login forces MFA setup again + (same guard as a fresh temp account). + """ + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + uid = _cur() + cfg = get_auth_config(user_id=uid) + if not cfg.get("passkey_credential_id"): + return jsonify({"error": "未配置 Passkey"}), 400 + update_auth_config(uid, passkey_credential_id="", passkey_public_key="") + if not has_mfa_configured(user_id=uid): + update_auth_config(uid, must_configure_mfa=1) + return jsonify({"ok": True}) + + # ─── TOTP Setup ────────────────────────────── @api.route("/api/auth/totp/setup", methods=["POST"]) @@ -974,7 +995,7 @@ def admin_provision_passkey_complete(user_id): return jsonify({"error": "用户不存在"}), 404 try: - result = complete_registration(request, credential) + result = complete_registration(request, credential, user_id=user_id) if result.get("user_id") != user_id: return jsonify({"error": "注册上下文不匹配,请重试"}), 400 update_auth_config( diff --git a/backend/webauthn_mgr.py b/backend/webauthn_mgr.py index 9c3b91c..bda03d0 100644 --- a/backend/webauthn_mgr.py +++ b/backend/webauthn_mgr.py @@ -153,32 +153,43 @@ def begin_registration(request, user_id: int, display_name: str = "", return _serialize_options(options) -def complete_registration(request, credential: dict) -> dict: +def complete_registration(request, credential: dict, user_id: int = None) -> dict: """Complete WebAuthn registration. Returns {'ok': True, 'user_id': int, 'credential_id': str, 'public_key': str} or raises an exception on failure. + + user_id: explicit target user. Always pass it from the route when known — + admin provisioning runs inside the ADMIN's session (its uid differs from + the target user, so deriving the challenge key from the session would never + match). When omitted, it is derived from credential.user_name ("user_5" → 5), + then from the authenticated request context. """ rp_id = _get_rp_id(request) origin = _get_origin(request) - user_name = credential.get("user_name", "") - # Derive the target user id from the registration challenge key. - expected_challenge = pop_webauthn_challenge(f"reg_{user_name}", db_path=_challenge_db()) - if not expected_challenge: - # Self-registration fallback: the browser may not echo the user handle, - # so resolve the target from the authenticated request context. + uid = user_id + if uid is None: + uname = credential.get("user_name", "") + if isinstance(uname, str) and uname.startswith("user_"): + try: + uid = int(uname.split("_")[-1]) + except (ValueError, IndexError): + uid = None + if uid is None: try: from backend.auth import current_user_id uid = current_user_id() - if uid is not None: - expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db()) - user_name = f"user_{uid}" except Exception: - pass + uid = None + if uid is None: + raise ValueError("No registration challenge found. Please restart registration.") + + # Challenge key must match begin_registration's f"reg_{user_id}" exactly. + expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db()) if not expected_challenge: raise ValueError("No registration challenge found. Please restart registration.") - user_id = int(user_name.split("_")[-1]) + user_id = uid try: # Library expects rawId field (browser-native camelCase, base64url string) diff --git a/frontend/static/js/control.js b/frontend/static/js/control.js index 291c7c6..8de37fa 100644 --- a/frontend/static/js/control.js +++ b/frontend/static/js/control.js @@ -930,6 +930,23 @@ }); }); + // ─── Passkey Removal (from settings) ────── + var removePasskeyBtn = $('mfa-remove-passkey'); + if (removePasskeyBtn) { + removePasskeyBtn.addEventListener('click', function () { + _reauthThen(function () { + api('DELETE', '/api/auth/passkey', null, function (res) { + if (res.ok) { + toast('✅ Passkey 已移除'); + loadMFAStatus(); + } else { + toast('❌ ' + (res.error || '移除失败')); + } + }); + }); + }); + } + // ─── TOTP Setup ─────────────────────────── $('mfa-setup-totp').addEventListener('click', function () { _reauthThen(function () { diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py index a58bfda..1564c5a 100644 --- a/tests/test_multi_user.py +++ b/tests/test_multi_user.py @@ -655,8 +655,10 @@ orig_complete_auth = routes_mod.complete_authentication routes_mod.begin_registration = lambda request, user_id, display_name="", existing_credential_id=None: { "challenge": "AA", "user": {"id": b64url(f"user_{user_id}".encode().hex()), "name": f"user_{user_id}", "displayName": display_name}, } -routes_mod.complete_registration = lambda request, credential: { - "ok": True, "user_id": int(credential.get("user_name", "user_0").split("_")[-1]), +routes_mod.complete_registration = lambda request, credential, user_id=None: { + "ok": True, + "user_id": user_id if user_id is not None + else int(credential.get("user_name", "user_0").split("_")[-1]), "credential_id": CRED_HEX, "public_key": "abcdef", } routes_mod.begin_authentication = lambda request, allow_credential_ids=None, challenge_key="login": {"challenge": "BB"} @@ -723,6 +725,20 @@ routes_mod.complete_registration = orig_complete_reg routes_mod.begin_authentication = orig_begin_auth routes_mod.complete_authentication = orig_complete_auth +# 移除 Passkey(Root 自助,TOTP reauth) +rr = reauth_totp(root15, root15_secret) +r = root15.delete("/api/auth/passkey") +check("移除 Passkey 成功", r.status_code == 200, str(r.get_json())) +cfg = get_auth_config(db15, 0) +check("移除后凭据清空", cfg["passkey_credential_id"] == "" and cfg["passkey_public_key"] == "") +check("仍有 TOTP 不强制重配 MFA", cfg["must_configure_mfa"] == 0) +r = root15.delete("/api/auth/passkey") +check("未配置时重复移除 → 400", r.status_code == 400) +# 未 reauth 的会话移除 → 403 +fresh15, _ = do_login(app15.test_client(), pyotp.TOTP(root15_secret).now()) +r = fresh15.delete("/api/auth/passkey") +check("移除未 reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required")) + print() print("=" * 60) print("TEST 14: Scheduler thread context (per-user timezone, no request context)") @@ -1158,6 +1174,56 @@ check("下一个请求立即读到新时区(无陈旧缓存)", r.get_json()[ st = c22.get("/api/display/state").get_json() check("display/state 返回新时区", st.get("timezone") == "America/New_York") +print() +print("=" * 60) +print("TEST 23: Passkey 注册 challenge key 匹配(admin 代配非 Root 用户,真实 webauthn_mgr)") +print("=" * 60) +from backend import webauthn_mgr as wm +import base64 as _b64 + +app23, db23 = fresh_app("t23") +root23 = root_client(app23, db23) +root23_secret = get_auth_config(db23, 0)["totp_secret"] +rr = reauth_totp(root23, root23_secret) +r = root23.post("/api/admin/users", json={"remark": "PK Target", "mode": "admin_fills"}) +target_id = r.get_json()["user"]["id"] + +# 真实 begin_registration(admin 代配,目标用户非 Root) +with app23.test_request_context(): + from flask import request as _req + opts = wm.begin_registration(_req, user_id=target_id, display_name="PK Target") +challenge_b64 = opts["challenge"] +got = pop_webauthn_challenge(f"reg_{target_id}", db_path=db23) +check("challenge 存于 reg_(key 拼装一致)", got == challenge_b64) + +# 用显式 user_id 的 complete_registration(mock 掉 verify,验证 key 匹配路径) +with app23.test_request_context(): + _req2 = _req + wm.begin_registration(_req2, user_id=target_id, display_name="PK Target") +class _FakeVer: + credential_id = bytes.fromhex("aabb" * 8) + credential_public_key = bytes.fromhex("ccdd" * 8) +_orig_verify = wm.verify_registration_response +wm.verify_registration_response = lambda **kw: _FakeVer() +try: + with app23.test_request_context(): + res = wm.complete_registration(_req2, { + "id": "qqq", "rawId": "qqq", "user_name": f"user_{target_id}", + }, user_id=target_id) + check("admin 代配 complete 显式 user_id 命中 challenge", res["user_id"] == target_id) +finally: + wm.verify_registration_response = _orig_verify + +# 反向验证:若 key 不匹配(如用错 user_id)→ 明确报错 +with app23.test_request_context(): + wm.begin_registration(_req2, user_id=target_id, display_name="PK Target") +try: + with app23.test_request_context(): + wm.complete_registration(_req2, {"id": "qqq", "rawId": "qqq"}, user_id=0) + check("错误 user_id 被拒绝", False, "没有抛错") +except ValueError as e: + check("错误 user_id 被拒绝(challenge 不匹配)", "No registration challenge found" in str(e)) + print() print("=" * 60) print(f"RESULT: {PASS} passed, {FAIL} failed")