Harden auth risk controls and admin reauth
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
|
||||
import database
|
||||
import email_service
|
||||
from app_config import get_config
|
||||
from app_logger import get_logger
|
||||
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username
|
||||
from flask import Blueprint, jsonify, redirect, render_template, request, url_for
|
||||
@@ -17,17 +18,24 @@ from services.accounts_service import load_user_accounts
|
||||
from services.models import User
|
||||
from services.state import (
|
||||
check_ip_request_rate,
|
||||
check_email_rate_limit,
|
||||
check_login_ip_user_locked,
|
||||
check_login_rate_limits,
|
||||
check_login_captcha_required,
|
||||
clear_login_failures,
|
||||
get_login_failure_delay_seconds,
|
||||
record_failed_captcha,
|
||||
record_login_failure,
|
||||
record_login_username_attempt,
|
||||
safe_cleanup_expired_captcha,
|
||||
safe_delete_captcha,
|
||||
safe_set_captcha,
|
||||
safe_verify_and_consume_captcha,
|
||||
should_send_login_alert,
|
||||
)
|
||||
|
||||
logger = get_logger("app")
|
||||
config = get_config()
|
||||
|
||||
api_auth_bp = Blueprint("api_auth", __name__)
|
||||
|
||||
@@ -181,6 +189,9 @@ def resend_verify_email():
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
allowed, error_msg = check_email_rate_limit(email, "resend_verify")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
@@ -238,6 +249,9 @@ def forgot_password():
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
allowed, error_msg = check_email_rate_limit(email, "forgot_password")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
@@ -323,6 +337,15 @@ def request_password_reset():
|
||||
if not is_valid:
|
||||
return jsonify({"error": error_msg}), 400
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
if email:
|
||||
allowed, error_msg = check_email_rate_limit(email, "reset_request")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
user = database.get_user_by_username(username)
|
||||
|
||||
if user:
|
||||
@@ -416,31 +439,66 @@ def 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}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试", "need_captcha": True}), 429
|
||||
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg, "need_captcha": True}), 429
|
||||
|
||||
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:
|
||||
return jsonify({"error": error_msg, "need_captcha": True}), 429
|
||||
|
||||
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:
|
||||
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
|
||||
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)
|
||||
return jsonify({"error": message, "need_captcha": True}), 400
|
||||
|
||||
user = database.verify_user(username, password)
|
||||
if not user:
|
||||
record_login_failure(client_ip)
|
||||
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
|
||||
record_login_failure(client_ip, username_key)
|
||||
delay = get_login_failure_delay_seconds(client_ip, username_key)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip, username_key)}), 401
|
||||
|
||||
if user["status"] != "approved":
|
||||
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
|
||||
|
||||
clear_login_failures(client_ip)
|
||||
clear_login_failures(client_ip, username_key)
|
||||
user_obj = User(user["id"])
|
||||
login_user(user_obj)
|
||||
load_user_accounts(user["id"])
|
||||
|
||||
try:
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
context = database.record_login_context(user["id"], client_ip, user_agent)
|
||||
if context and (context.get("new_ip") or context.get("new_device")):
|
||||
if config.LOGIN_ALERT_ENABLED and should_send_login_alert(user["id"], client_ip):
|
||||
user_info = database.get_user_by_id(user["id"]) or {}
|
||||
if user_info.get("email") and user_info.get("email_verified"):
|
||||
if database.get_user_email_notify(user["id"]):
|
||||
email_service.send_security_alert_email(
|
||||
email=user_info.get("email"),
|
||||
username=user_info.get("username") or username,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
new_ip=context.get("new_ip", False),
|
||||
new_device=context.get("new_device", False),
|
||||
user_id=user["id"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from app_security import get_rate_limit_ip, require_ip_not_locked, validate_emai
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.state import check_ip_request_rate, safe_iter_task_status_items
|
||||
from services.state import check_email_rate_limit, check_ip_request_rate, safe_iter_task_status_items
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
@@ -164,6 +164,9 @@ def bind_user_email():
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
allowed, error_msg = check_email_rate_limit(email, "bind_email")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
|
||||
Reference in New Issue
Block a user