feat: 完成 Passkey 能力与前后台加载优化

更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
2026-02-15 23:51:12 +08:00
parent ebfac7266b
commit 7007f5f6f5
129 changed files with 3747 additions and 432 deletions

View File

@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import os
import stat
import tempfile
@@ -16,6 +17,19 @@ from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.accounts_service import load_user_accounts
from services.checkpoints import get_checkpoint_mgr
from services.passkeys import (
MAX_PASSKEYS_PER_OWNER,
encode_credential_id,
get_credential_transports,
get_expected_origins,
get_rp_id,
is_challenge_valid,
make_authentication_options,
make_registration_options,
normalize_device_name,
verify_authentication,
verify_registration,
)
from services.state import (
safe_get_user_accounts_snapshot,
safe_verify_and_consume_captcha,
@@ -32,6 +46,8 @@ from services.tasks import submit_account_task
logger = get_logger("app")
config = get_config()
_ADMIN_PASSKEY_LOGIN_SESSION_KEY = "admin_passkey_login_state"
_ADMIN_PASSKEY_REGISTER_SESSION_KEY = "admin_passkey_register_state"
def _admin_reauth_required() -> bool:
@@ -46,6 +62,27 @@ def _require_admin_reauth():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
return None
def _parse_credential_payload(data: dict) -> dict | None:
credential = data.get("credential")
if isinstance(credential, dict):
return credential
if isinstance(credential, str):
try:
parsed = json.loads(credential)
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
return None
def _truncate_text(value, max_len: int = 300) -> str:
text = str(value or "").strip()
if len(text) > max_len:
return f"{text[:max_len]}..."
return text
@admin_api_bp.route("/debug-config", methods=["GET"])
@admin_required
def debug_config():
@@ -70,6 +107,169 @@ def debug_config():
)
@admin_api_bp.route("/passkeys/login/options", methods=["POST"])
@require_ip_not_locked
def admin_passkey_login_options():
"""管理员 Passkey 登录:获取 assertion challenge。"""
data = request.get_json(silent=True) or {}
username = str(data.get("username", "") or "").strip()
client_ip = get_rate_limit_ip()
mode = "named" if username else "discoverable"
username_key = f"admin-passkey:{username}" if username else "admin-passkey:discoverable"
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
if is_locked:
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
if not allowed:
return jsonify({"error": error_msg}), 429
admin_id = 0
allow_credential_ids = []
if mode == "named":
admin_row = database.get_admin_by_username(username)
if not admin_row:
record_login_failure(client_ip, username_key)
return jsonify({"error": "账号或Passkey不可用"}), 400
admin_id = int(admin_row["id"])
passkeys = database.list_passkeys("admin", admin_id)
if not passkeys:
record_login_failure(client_ip, username_key)
return jsonify({"error": "该管理员尚未绑定Passkey"}), 400
allow_credential_ids = [str(item.get("credential_id") or "").strip() for item in passkeys if item.get("credential_id")]
try:
rp_id = get_rp_id(request)
expected_origins = get_expected_origins(request)
except Exception as e:
logger.warning(f"[passkey] 管理员登录 options 失败(mode={mode}, username={username or '-'}) : {e}")
return jsonify({"error": "Passkey配置异常请联系管理员"}), 500
options = make_authentication_options(rp_id=rp_id, allow_credential_ids=allow_credential_ids)
challenge = str(options.get("challenge") or "").strip()
if not challenge:
return jsonify({"error": "生成Passkey挑战失败"}), 500
session[_ADMIN_PASSKEY_LOGIN_SESSION_KEY] = {
"mode": mode,
"username": username,
"admin_id": int(admin_id),
"challenge": challenge,
"rp_id": rp_id,
"expected_origins": expected_origins,
"username_key": username_key,
"created_at": time.time(),
}
session.modified = True
return jsonify({"publicKey": options})
@admin_api_bp.route("/passkeys/login/verify", methods=["POST"])
@require_ip_not_locked
def admin_passkey_login_verify():
"""管理员 Passkey 登录:校验 assertion 并登录。"""
data = request.get_json(silent=True) or {}
request_username = str(data.get("username", "") or "").strip()
credential = _parse_credential_payload(data)
if not credential:
return jsonify({"error": "Passkey参数缺失"}), 400
state = session.get(_ADMIN_PASSKEY_LOGIN_SESSION_KEY) or {}
if not state:
return jsonify({"error": "Passkey挑战不存在或已过期请重试"}), 400
if not is_challenge_valid(state.get("created_at")):
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
return jsonify({"error": "Passkey挑战已过期请重试"}), 400
mode = str(state.get("mode") or "named")
if mode not in {"named", "discoverable"}:
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
return jsonify({"error": "Passkey状态异常请重试"}), 400
expected_username = str(state.get("username") or "").strip()
username = expected_username
if mode == "named":
if not expected_username:
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
return jsonify({"error": "Passkey状态异常请重试"}), 400
if request_username and request_username != expected_username:
return jsonify({"error": "用户名与挑战不匹配,请重试"}), 400
else:
username = request_username
client_ip = get_rate_limit_ip()
username_key = str(state.get("username_key") or "").strip() or (
f"admin-passkey:{expected_username}" if mode == "named" else "admin-passkey:discoverable"
)
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
if is_locked:
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
credential_id = str(credential.get("id") or credential.get("rawId") or "").strip()
if not credential_id:
return jsonify({"error": "Passkey参数无效"}), 400
passkey = database.get_passkey_by_credential_id(credential_id)
if not passkey:
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey不存在或已删除"}), 401
if str(passkey.get("owner_type") or "") != "admin":
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey不属于管理员账号"}), 401
if mode == "named" and int(passkey.get("owner_id") or 0) != int(state.get("admin_id") or 0):
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey与管理员账号不匹配"}), 401
try:
_, verified = verify_authentication(
credential=credential,
expected_challenge=str(state.get("challenge") or ""),
expected_rp_id=str(state.get("rp_id") or ""),
expected_origins=list(state.get("expected_origins") or []),
credential_public_key=str(passkey.get("public_key") or ""),
credential_current_sign_count=int(passkey.get("sign_count") or 0),
)
verified_credential_id = encode_credential_id(verified.credential_id)
if verified_credential_id != str(passkey.get("credential_id") or ""):
raise ValueError("credential_id mismatch")
except Exception as e:
logger.warning(f"[passkey] 管理员登录验签失败(mode={mode}, username={expected_username or request_username or '-'}) : {e}")
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey验证失败"}), 401
admin_id = int(passkey.get("owner_id") or 0)
admin_row = database.get_admin_by_id(admin_id)
if not admin_row:
return jsonify({"error": "管理员账号不存在"}), 401
admin_username = str(admin_row.get("username") or "").strip() or username or f"admin-{admin_id}"
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
clear_login_failures(client_ip, username_key)
admin_login_key = f"admin-passkey:{admin_username}"
if admin_login_key and admin_login_key != username_key:
clear_login_failures(client_ip, admin_login_key)
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
session.pop("admin_id", None)
session.pop("admin_username", None)
session["admin_id"] = admin_id
session["admin_username"] = admin_username
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
session.permanent = True
session.modified = True
return jsonify({"success": True, "redirect": "/yuyx/admin", "username": admin_username})
@admin_api_bp.route("/login", methods=["POST"])
@require_ip_not_locked
def admin_login():
@@ -161,6 +361,164 @@ def admin_logout():
return jsonify({"success": True})
@admin_api_bp.route("/admin/passkeys", methods=["GET"])
@admin_required
def list_admin_passkeys():
admin_id = int(session.get("admin_id") or 0)
rows = database.list_passkeys("admin", admin_id)
items = []
for row in rows:
credential_id = str(row.get("credential_id") or "")
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
items.append(
{
"id": int(row.get("id")),
"device_name": str(row.get("device_name") or ""),
"credential_id_preview": preview,
"created_at": row.get("created_at"),
"last_used_at": row.get("last_used_at"),
"transports": str(row.get("transports") or ""),
}
)
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
@admin_api_bp.route("/admin/passkeys/register/options", methods=["POST"])
@admin_required
def admin_passkey_register_options():
admin_id = int(session.get("admin_id") or 0)
admin_username = str(session.get("admin_username") or "").strip() or f"admin-{admin_id}"
count = database.count_passkeys("admin", admin_id)
if count >= MAX_PASSKEYS_PER_OWNER:
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
data = request.get_json(silent=True) or {}
device_name = normalize_device_name(data.get("device_name"))
existing = database.list_passkeys("admin", admin_id)
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
try:
rp_id = get_rp_id(request)
expected_origins = get_expected_origins(request)
options = make_registration_options(
rp_id=rp_id,
rp_name="知识管理平台",
user_name=admin_username,
user_display_name=admin_username,
user_id_bytes=f"admin:{admin_id}".encode("utf-8"),
exclude_credential_ids=exclude_credential_ids,
)
except Exception as e:
logger.warning(f"[passkey] 管理员注册 options 失败(admin_id={admin_id}): {e}")
return jsonify({"error": "生成Passkey挑战失败"}), 500
challenge = str(options.get("challenge") or "").strip()
if not challenge:
return jsonify({"error": "生成Passkey挑战失败"}), 500
session[_ADMIN_PASSKEY_REGISTER_SESSION_KEY] = {
"admin_id": admin_id,
"challenge": challenge,
"rp_id": rp_id,
"expected_origins": expected_origins,
"device_name": device_name,
"created_at": time.time(),
}
session.modified = True
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
@admin_api_bp.route("/admin/passkeys/register/verify", methods=["POST"])
@admin_required
def admin_passkey_register_verify():
admin_id = int(session.get("admin_id") or 0)
state = session.get(_ADMIN_PASSKEY_REGISTER_SESSION_KEY) or {}
if not state:
return jsonify({"error": "Passkey挑战不存在或已过期请重试"}), 400
if int(state.get("admin_id") or 0) != admin_id:
return jsonify({"error": "Passkey挑战与当前管理员不匹配"}), 400
if not is_challenge_valid(state.get("created_at")):
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
return jsonify({"error": "Passkey挑战已过期请重试"}), 400
data = request.get_json(silent=True) or {}
credential = _parse_credential_payload(data)
if not credential:
return jsonify({"error": "Passkey参数缺失"}), 400
count = database.count_passkeys("admin", admin_id)
if count >= MAX_PASSKEYS_PER_OWNER:
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
try:
verified = verify_registration(
credential=credential,
expected_challenge=str(state.get("challenge") or ""),
expected_rp_id=str(state.get("rp_id") or ""),
expected_origins=list(state.get("expected_origins") or []),
)
except Exception as e:
logger.warning(f"[passkey] 管理员注册验签失败(admin_id={admin_id}): {e}")
return jsonify({"error": "Passkey验证失败请重试"}), 400
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
created_id = database.create_passkey(
"admin",
admin_id,
credential_id=encode_credential_id(verified.credential_id),
public_key=encode_credential_id(verified.credential_public_key),
sign_count=int(verified.sign_count or 0),
device_name=device_name,
transports=get_credential_transports(credential),
aaguid=str(verified.aaguid or ""),
)
if not created_id:
return jsonify({"error": "该Passkey已绑定或保存失败"}), 400
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
return jsonify({"success": True, "id": int(created_id)})
@admin_api_bp.route("/admin/passkeys/<int:passkey_id>", methods=["DELETE"])
@admin_required
def delete_admin_passkey(passkey_id):
admin_id = int(session.get("admin_id") or 0)
ok = database.delete_passkey("admin", admin_id, int(passkey_id))
if ok:
return jsonify({"success": True})
return jsonify({"error": "设备不存在或已删除"}), 404
@admin_api_bp.route("/admin/passkeys/client-error", methods=["POST"])
@admin_required
def report_admin_passkey_client_error():
"""上报管理员端浏览器 Passkey 失败详情,便于排查兼容性问题。"""
data = request.get_json(silent=True) or {}
error_name = _truncate_text(data.get("name"), 120)
error_message = _truncate_text(data.get("message"), 400)
error_code = _truncate_text(data.get("code"), 120)
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
stage = _truncate_text(data.get("stage"), 80)
source = _truncate_text(data.get("source"), 80)
admin_id = int(session.get("admin_id") or 0)
logger.warning(
"[passkey][client-error][admin] admin_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
admin_id,
stage or "-",
source or "-",
error_name or "-",
error_code or "-",
error_message or "-",
ua or "-",
)
return jsonify({"success": True})
@admin_api_bp.route("/admin/reauth", methods=["POST"])
@admin_required
def admin_reauth():