636 lines
25 KiB
Python
636 lines
25 KiB
Python
#!/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)
|
||
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
|