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

639 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import os
import stat
import tempfile
import time
import database
from app_config import get_config
from app_logger import get_logger
from app_security import get_rate_limit_ip, require_ip_not_locked
from flask import current_app, jsonify, redirect, request, session, url_for
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,
check_login_ip_user_locked,
check_login_rate_limits,
get_login_failure_delay_seconds,
record_login_username_attempt,
check_ip_request_rate,
check_login_captcha_required,
clear_login_failures,
record_login_failure,
)
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:
try:
return time.time() > float(session.get("admin_reauth_until", 0) or 0)
except Exception:
return True
def _require_admin_reauth():
if _admin_reauth_required():
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():
"""调试配置信息(仅管理员可访问,生产环境应禁用)"""
if not current_app.debug:
return jsonify({"error": "调试端点已在生产环境禁用"}), 403
return jsonify(
{
"secret_key_set": bool(current_app.secret_key),
"secret_key_length": len(current_app.secret_key) if current_app.secret_key else 0,
"session_config": {
"SESSION_COOKIE_NAME": current_app.config.get("SESSION_COOKIE_NAME"),
"SESSION_COOKIE_SECURE": current_app.config.get("SESSION_COOKIE_SECURE"),
"SESSION_COOKIE_HTTPONLY": current_app.config.get("SESSION_COOKIE_HTTPONLY"),
"SESSION_COOKIE_SAMESITE": current_app.config.get("SESSION_COOKIE_SAMESITE"),
"PERMANENT_SESSION_LIFETIME": str(current_app.config.get("PERMANENT_SESSION_LIFETIME")),
},
"has_session": bool(session),
"cookies_received": list(request.cookies.keys()),
}
)
@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():
"""管理员登录支持JSON和form-data两种格式"""
if request.is_json:
data = request.json or {}
else:
data = request.form
username = data.get("username", "").strip()
password = data.get("password", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
need_captcha = data.get("need_captcha", False)
client_ip = get_rate_limit_ip()
username_key = username
scan_locked = record_login_username_attempt(client_ip, username_key)
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}"
if request.is_json:
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试", "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
if not allowed:
if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
captcha_required = check_login_captcha_required(client_ip, username_key) or scan_locked or bool(need_captcha)
if captcha_required:
if not captcha_session or not captcha_code:
if request.is_json:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page"))
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
record_login_failure(client_ip, username_key)
if request.is_json:
return jsonify({"error": message, "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page"))
admin = database.verify_admin(username, password)
if admin:
clear_login_failures(client_ip, username_key)
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
logger.info(f"[admin_login] 管理员 {username} 登录成功")
if request.is_json:
return jsonify({"success": True, "redirect": "/yuyx/admin"})
return redirect(url_for("pages.admin_page"))
record_login_failure(client_ip, username_key)
delay = get_login_failure_delay_seconds(client_ip, username_key)
if delay > 0:
time.sleep(delay)
logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
if request.is_json:
return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
return redirect(url_for("pages.admin_login_page"))
@admin_api_bp.route("/logout", methods=["POST"])
@admin_required
def admin_logout():
"""管理员登出"""
session.pop("admin_id", None)
session.pop("admin_username", None)
session.pop("admin_reauth_until", None)
session.pop("_user_id", None)
session.pop("_fresh", None)
session.pop("_id", None)
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():
"""管理员敏感操作二次确认"""
data = request.json or {}
password = (data.get("password") or "").strip()
if not password:
return jsonify({"error": "密码不能为空"}), 400
username = session.get("admin_username")
if not username:
return jsonify({"error": "未登录"}), 401
admin = database.verify_admin(username, password)
if not admin:
return jsonify({"error": "密码错误"}), 401
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
session.modified = True
return jsonify({"success": True, "expires_in": int(config.ADMIN_REAUTH_WINDOW_SECONDS)})
@admin_api_bp.route("/docker/restart", methods=["POST"])
@admin_required
def restart_docker_container():
"""重启Docker容器"""
import subprocess
try:
reauth_response = _require_admin_reauth()
if reauth_response:
return reauth_response
if not os.path.exists("/.dockerenv"):
return jsonify({"error": "当前不在Docker容器中运行"}), 400
logger.info("[系统] 管理员触发Docker容器重启")
restart_script = """
import os
import time
time.sleep(3)
os._exit(0)
"""
with tempfile.NamedTemporaryFile("w", suffix=".py", prefix="restart_container_", delete=False) as temp_file:
temp_file.write(restart_script)
script_path = temp_file.name
os.chmod(script_path, stat.S_IRUSR | stat.S_IWUSR)
subprocess.Popen(
["python3", script_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return jsonify({"success": True, "message": "容器将在3秒后重启请稍后刷新页面"})
except Exception as e:
logger.error(f"[系统] Docker容器重启失败: {str(e)}")
return jsonify({"error": f"重启失败: {str(e)}"}), 500
# ==================== 断点续传(管理员) ====================
@admin_api_bp.route("/checkpoint/paused")
@admin_required
def checkpoint_get_paused():
try:
user_id = request.args.get("user_id", type=int)
tasks = get_checkpoint_mgr().get_paused_tasks(user_id=user_id)
return jsonify({"success": True, "tasks": tasks})
except Exception as e:
logger.error(f"获取暂停任务失败: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@admin_api_bp.route("/checkpoint/<task_id>/resume", methods=["POST"])
@admin_required
def checkpoint_resume(task_id):
try:
checkpoint_mgr = get_checkpoint_mgr()
checkpoint = checkpoint_mgr.get_checkpoint(task_id)
if not checkpoint:
return jsonify({"success": False, "message": "任务不存在"}), 404
if checkpoint["status"] != "paused":
return jsonify({"success": False, "message": "任务未暂停"}), 400
if checkpoint_mgr.resume_task(task_id):
user_id = checkpoint["user_id"]
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
ok, msg = submit_account_task(
user_id=user_id,
account_id=checkpoint["account_id"],
browse_type=checkpoint["browse_type"],
enable_screenshot=True,
source="resumed",
)
if not ok:
return jsonify({"success": False, "message": msg}), 400
return jsonify({"success": True})
return jsonify({"success": False}), 500
except Exception as e:
logger.error(f"恢复任务失败: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@admin_api_bp.route("/checkpoint/<task_id>/abandon", methods=["POST"])
@admin_required
def checkpoint_abandon(task_id):
try:
if get_checkpoint_mgr().abandon_task(task_id):
return jsonify({"success": True})
return jsonify({"success": False}), 404
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500