Harden auth risk controls and admin reauth

This commit is contained in:
2025-12-26 21:07:47 +08:00
parent f90b0a4f11
commit e3b0c35da6
32 changed files with 741 additions and 92 deletions

View File

@@ -10,6 +10,7 @@ from datetime import datetime
import database
import email_service
import requests
from app_config import get_config
from app_logger import get_logger
from app_security import (
get_rate_limit_ip,
@@ -32,6 +33,10 @@ from services.state import (
safe_iter_task_status_items,
safe_remove_user_accounts,
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,
@@ -41,6 +46,20 @@ from services.tasks import get_task_scheduler, submit_account_task
from services.time_utils import BEIJING_TZ, get_beijing_now
logger = get_logger("app")
config = get_config()
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
@admin_api_bp.route("/debug-config", methods=["GET"])
@@ -83,13 +102,29 @@ def admin_login():
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"))
captcha_required = check_login_captcha_required(client_ip) or bool(need_captcha)
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:
@@ -97,18 +132,19 @@ def admin_login():
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)
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)
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
@@ -118,7 +154,10 @@ def admin_login():
return jsonify({"success": True, "redirect": "/yuyx/admin"})
return redirect(url_for("pages.admin_page"))
record_login_failure(client_ip)
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
@@ -131,9 +170,32 @@ 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/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)})
# ==================== 公告管理API管理员 ====================
@@ -761,6 +823,9 @@ def restart_docker_container():
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

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import time
import uuid
from flask import jsonify, request, session
@@ -65,6 +66,13 @@ def _parse_bool_field(data: dict, key: str) -> bool | None:
raise ValueError(f"{key} 必须是 0/1 或 true/false")
def _admin_reauth_required() -> bool:
try:
return time.time() > float(session.get("admin_reauth_until", 0) or 0)
except Exception:
return True
@admin_api_bp.route("/update/status", methods=["GET"])
@admin_required
def get_update_status_api():
@@ -146,6 +154,8 @@ def request_update_check_api():
def request_update_run_api():
"""请求宿主机 Update-Agent 执行一键更新并重启服务。"""
ensure_update_dirs()
if _admin_reauth_required():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
if _has_pending_request():
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409