#!/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/", 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//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//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