feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
@@ -20,6 +21,15 @@ from flask_login import login_required, login_user, logout_user
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.accounts_service import load_user_accounts
|
||||
from services.models import User
|
||||
from services.passkeys import (
|
||||
encode_credential_id,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_authentication_options,
|
||||
normalize_device_name,
|
||||
verify_authentication,
|
||||
)
|
||||
from services.state import (
|
||||
check_ip_request_rate,
|
||||
check_email_rate_limit,
|
||||
@@ -50,6 +60,7 @@ _CAPTCHA_FONT_PATHS = [
|
||||
]
|
||||
_CAPTCHA_FONT = None
|
||||
_CAPTCHA_FONT_LOCK = threading.Lock()
|
||||
_USER_PASSKEY_LOGIN_SESSION_KEY = "user_passkey_login_state"
|
||||
|
||||
|
||||
def _get_json_payload() -> dict:
|
||||
@@ -194,6 +205,19 @@ def _send_login_security_alert_if_needed(user: dict, username: str, client_ip: s
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/register", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def register():
|
||||
@@ -538,6 +562,166 @@ def generate_captcha():
|
||||
return jsonify({"error": "验证码服务暂不可用,请联系管理员安装PIL库"}), 503
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/passkeys/login/options", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def user_passkey_login_options():
|
||||
"""用户 Passkey 登录:获取 assertion challenge。"""
|
||||
data = _get_json_payload()
|
||||
username = str(data.get("username", "") or "").strip()
|
||||
client_ip = get_rate_limit_ip()
|
||||
mode = "named" if username else "discoverable"
|
||||
username_key = f"passkey:{username}" if username else "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
|
||||
|
||||
user_id = 0
|
||||
allow_credential_ids = []
|
||||
if mode == "named":
|
||||
user = database.get_user_by_username(username)
|
||||
if not user or user.get("status") != "approved":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "账号或Passkey不可用"}), 400
|
||||
|
||||
user_id = int(user["id"])
|
||||
passkeys = database.list_passkeys("user", user_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] 生成登录 challenge 失败(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[_USER_PASSKEY_LOGIN_SESSION_KEY] = {
|
||||
"mode": mode,
|
||||
"username": username,
|
||||
"user_id": int(user_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})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/passkeys/login/verify", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def user_passkey_login_verify():
|
||||
"""用户 Passkey 登录:校验 assertion 并登录。"""
|
||||
data = _get_json_payload()
|
||||
request_username = str(data.get("username", "") or "").strip()
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
state = session.get(_USER_PASSKEY_LOGIN_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_USER_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(_USER_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(_USER_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"passkey:{expected_username}" if mode == "named" else "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 "") != "user":
|
||||
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("user_id") or 0):
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey与账号不匹配"}), 401
|
||||
|
||||
try:
|
||||
parsed_credential, 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
|
||||
|
||||
user_id = int(passkey.get("owner_id") or 0)
|
||||
user = database.get_user_by_id(user_id)
|
||||
if not user or user.get("status") != "approved":
|
||||
return jsonify({"error": "账号不可用"}), 401
|
||||
|
||||
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
|
||||
clear_login_failures(client_ip, username_key)
|
||||
user_login_key = f"passkey:{str(user.get('username') or '').strip()}"
|
||||
if user_login_key and user_login_key != username_key:
|
||||
clear_login_failures(client_ip, user_login_key)
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
|
||||
user_obj = User(user_id)
|
||||
login_user(user_obj)
|
||||
load_user_accounts(user_id)
|
||||
|
||||
resolved_username = str(user.get("username") or "").strip() or username or f"user-{user_id}"
|
||||
_send_login_security_alert_if_needed(user=user, username=resolved_username, client_ip=client_ip)
|
||||
return jsonify({"success": True, "credential_id": parsed_credential.id, "username": resolved_username})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/login", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def login():
|
||||
|
||||
Reference in New Issue
Block a user