Harden auth risk controls and admin reauth
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
let lastToastKey = ''
|
||||
let lastToastAt = 0
|
||||
@@ -24,6 +24,29 @@ export const api = axios.create({
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
let reauthPromise = null
|
||||
|
||||
async function ensureReauth() {
|
||||
if (reauthPromise) return reauthPromise
|
||||
reauthPromise = ElMessageBox.prompt('请输入管理员密码进行二次确认', '安全确认', {
|
||||
inputType: 'password',
|
||||
inputPlaceholder: '管理员密码',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
inputValidator: (v) => Boolean(String(v || '').trim()),
|
||||
inputErrorMessage: '密码不能为空',
|
||||
})
|
||||
.then(async (res) => {
|
||||
const password = String(res.value || '').trim()
|
||||
await api.post('/admin/reauth', { password })
|
||||
ElMessage.success('已通过安全确认')
|
||||
})
|
||||
.finally(() => {
|
||||
reauthPromise = null
|
||||
})
|
||||
return reauthPromise
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const method = String(config?.method || 'GET').toUpperCase()
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
@@ -38,11 +61,21 @@ api.interceptors.request.use((config) => {
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const status = error?.response?.status
|
||||
const payload = error?.response?.data
|
||||
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
||||
|
||||
if (payload?.code === 'reauth_required' && error?.config && !error.config.__reauth_retry) {
|
||||
try {
|
||||
error.config.__reauth_retry = true
|
||||
await ensureReauth()
|
||||
return api.request(error.config)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||
const pathname = window.location?.pathname || ''
|
||||
|
||||
@@ -473,6 +473,7 @@ function emailTypeLabel(type) {
|
||||
reset: '密码重置',
|
||||
bind: '邮箱绑定',
|
||||
task_complete: '任务完成',
|
||||
security_alert: '安全告警',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
@@ -701,6 +702,7 @@ onMounted(refreshAll)
|
||||
<el-option label="密码重置" value="reset" />
|
||||
<el-option label="邮箱绑定" value="bind" />
|
||||
<el-option label="任务完成" value="task_complete" />
|
||||
<el-option label="安全告警" value="security_alert" />
|
||||
</el-select>
|
||||
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
|
||||
<el-option label="全部状态" value="" />
|
||||
|
||||
@@ -189,6 +189,23 @@ class Config:
|
||||
MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB
|
||||
LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get('LOGIN_CAPTCHA_AFTER_FAILURES', '3'))
|
||||
LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get('LOGIN_CAPTCHA_WINDOW_SECONDS', '900'))
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get('LOGIN_RATE_LIMIT_WINDOW_SECONDS', '900'))
|
||||
LOGIN_IP_MAX_ATTEMPTS = int(os.environ.get('LOGIN_IP_MAX_ATTEMPTS', '60'))
|
||||
LOGIN_USERNAME_MAX_ATTEMPTS = int(os.environ.get('LOGIN_USERNAME_MAX_ATTEMPTS', '30'))
|
||||
LOGIN_IP_USERNAME_MAX_ATTEMPTS = int(os.environ.get('LOGIN_IP_USERNAME_MAX_ATTEMPTS', '12'))
|
||||
LOGIN_FAIL_DELAY_BASE_MS = int(os.environ.get('LOGIN_FAIL_DELAY_BASE_MS', '200'))
|
||||
LOGIN_FAIL_DELAY_MAX_MS = int(os.environ.get('LOGIN_FAIL_DELAY_MAX_MS', '1200'))
|
||||
LOGIN_ACCOUNT_LOCK_FAILURES = int(os.environ.get('LOGIN_ACCOUNT_LOCK_FAILURES', '6'))
|
||||
LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS = int(os.environ.get('LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS', '900'))
|
||||
LOGIN_ACCOUNT_LOCK_SECONDS = int(os.environ.get('LOGIN_ACCOUNT_LOCK_SECONDS', '600'))
|
||||
LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD = int(os.environ.get('LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD', '8'))
|
||||
LOGIN_SCAN_WINDOW_SECONDS = int(os.environ.get('LOGIN_SCAN_WINDOW_SECONDS', '600'))
|
||||
LOGIN_SCAN_COOLDOWN_SECONDS = int(os.environ.get('LOGIN_SCAN_COOLDOWN_SECONDS', '600'))
|
||||
EMAIL_RATE_LIMIT_MAX = int(os.environ.get('EMAIL_RATE_LIMIT_MAX', '6'))
|
||||
EMAIL_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get('EMAIL_RATE_LIMIT_WINDOW_SECONDS', '3600'))
|
||||
LOGIN_ALERT_ENABLED = os.environ.get('LOGIN_ALERT_ENABLED', 'true').lower() == 'true'
|
||||
LOGIN_ALERT_MIN_INTERVAL_SECONDS = int(os.environ.get('LOGIN_ALERT_MIN_INTERVAL_SECONDS', '3600'))
|
||||
ADMIN_REAUTH_WINDOW_SECONDS = int(os.environ.get('ADMIN_REAUTH_WINDOW_SECONDS', '600'))
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
|
||||
+2
-1
@@ -113,6 +113,7 @@ from db.users import (
|
||||
set_user_vip,
|
||||
verify_user,
|
||||
)
|
||||
from db.security import record_login_context
|
||||
|
||||
config = get_config()
|
||||
|
||||
@@ -120,7 +121,7 @@ config = get_config()
|
||||
DB_FILE = config.DB_FILE
|
||||
|
||||
# 数据库版本 (用于迁移管理)
|
||||
DB_VERSION = 11
|
||||
DB_VERSION = 12
|
||||
|
||||
|
||||
# ==================== 系统配置缓存(P1 / O-03) ====================
|
||||
|
||||
@@ -69,6 +69,9 @@ def migrate_database(conn, target_version: int) -> None:
|
||||
if current_version < 11:
|
||||
_migrate_to_v11(conn)
|
||||
current_version = 11
|
||||
if current_version < 12:
|
||||
_migrate_to_v12(conn)
|
||||
current_version = 12
|
||||
|
||||
if current_version != int(target_version):
|
||||
set_current_version(conn, int(target_version))
|
||||
@@ -472,7 +475,47 @@ def _migrate_to_v11(conn):
|
||||
)
|
||||
updated = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
if updated:
|
||||
print(f" ✓ 已将 {updated} 个 pending 用户迁移为 approved")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f" ⚠️ v11 迁移跳过: {e}")
|
||||
|
||||
|
||||
def _migrate_to_v12(conn):
|
||||
"""迁移到版本12 - 登录设备/IP记录表"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_fingerprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_ip TEXT DEFAULT '',
|
||||
UNIQUE (user_id, user_agent),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_ips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, ip),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -41,6 +41,37 @@ def ensure_schema(conn) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# 登录设备指纹表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_fingerprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_ip TEXT DEFAULT '',
|
||||
UNIQUE (user_id, user_agent),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 登录IP记录表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_ips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, ip),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 账号表(关联用户)
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -237,6 +268,8 @@ def ensure_schema(conn) -> None:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_vip_expire ON users(vip_expire_time)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
import db_pool
|
||||
from db.utils import get_cst_now_str
|
||||
|
||||
|
||||
def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict[str, bool]:
|
||||
"""记录登录环境信息,返回是否新设备/新IP。"""
|
||||
user_id = int(user_id)
|
||||
ip_text = str(ip_address or "").strip()[:64]
|
||||
ua_text = str(user_agent or "").strip()[:512]
|
||||
now_str = get_cst_now_str()
|
||||
|
||||
new_device = False
|
||||
new_ip = False
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if ua_text:
|
||||
cursor.execute(
|
||||
"SELECT id FROM login_fingerprints WHERE user_id = ? AND user_agent = ?",
|
||||
(user_id, ua_text),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE login_fingerprints
|
||||
SET last_seen = ?, last_ip = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_str, ip_text, row["id"] if isinstance(row, dict) else row[0]),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO login_fingerprints (user_id, user_agent, first_seen, last_seen, last_ip)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, ua_text, now_str, now_str, ip_text),
|
||||
)
|
||||
new_device = True
|
||||
|
||||
if ip_text:
|
||||
cursor.execute(
|
||||
"SELECT id FROM login_ips WHERE user_id = ? AND ip = ?",
|
||||
(user_id, ip_text),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE login_ips
|
||||
SET last_seen = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_str, row["id"] if isinstance(row, dict) else row[0]),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO login_ips (user_id, ip, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, ip_text, now_str, now_str),
|
||||
)
|
||||
new_ip = True
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"new_device": new_device, "new_ip": new_ip}
|
||||
@@ -80,6 +80,7 @@ EMAIL_TYPE_REGISTER = 'register' # 注册验证
|
||||
EMAIL_TYPE_RESET = 'reset' # 密码重置
|
||||
EMAIL_TYPE_BIND = 'bind' # 邮箱绑定
|
||||
EMAIL_TYPE_TASK_COMPLETE = 'task_complete' # 任务完成通知
|
||||
EMAIL_TYPE_SECURITY_ALERT = 'security_alert' # 安全告警
|
||||
|
||||
# Token有效期(秒)
|
||||
TOKEN_EXPIRE_REGISTER = 24 * 60 * 60 # 注册验证: 24小时
|
||||
@@ -1620,6 +1621,67 @@ def verify_bind_email_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
return verify_email_token(token, EMAIL_TYPE_BIND)
|
||||
|
||||
|
||||
def send_security_alert_email(
|
||||
email: str,
|
||||
username: str,
|
||||
ip_address: str,
|
||||
user_agent: str,
|
||||
new_ip: bool,
|
||||
new_device: bool,
|
||||
user_id: int = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""发送登录安全提醒邮件(低侵入,不影响登录)。"""
|
||||
settings = get_email_settings()
|
||||
if not settings.get("enabled", False):
|
||||
return {"success": False, "error": "邮件功能未启用"}
|
||||
|
||||
reason_parts = []
|
||||
if new_ip:
|
||||
reason_parts.append("新的登录IP")
|
||||
if new_device:
|
||||
reason_parts.append("新的登录设备")
|
||||
reason_text = "、".join(reason_parts) if reason_parts else "异常登录"
|
||||
|
||||
subject = "账号安全提醒"
|
||||
now_str = get_beijing_now_str()
|
||||
ip_text = ip_address or "未知"
|
||||
ua_text = user_agent or "未知"
|
||||
|
||||
text_body = (
|
||||
f"您好,{username}:\n\n"
|
||||
f"我们检测到 {reason_text}。\n"
|
||||
f"时间:{now_str}\n"
|
||||
f"IP:{ip_text}\n"
|
||||
f"设备信息:{ua_text}\n\n"
|
||||
"如果这不是您本人操作,请尽快修改密码并联系管理员。\n"
|
||||
)
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>账号安全提醒</h2>
|
||||
<p>您好,{username}:</p>
|
||||
<p>我们检测到 <strong>{reason_text}</strong>。</p>
|
||||
<ul>
|
||||
<li>时间:{now_str}</li>
|
||||
<li>IP:{ip_text}</li>
|
||||
<li>设备信息:{ua_text}</li>
|
||||
</ul>
|
||||
<p>如果这不是您本人操作,请尽快修改密码并联系管理员。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return send_email(
|
||||
to_email=email,
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
html_body=html_body,
|
||||
email_type=EMAIL_TYPE_SECURITY_ALERT,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
# ============ 异步发送队列 ============
|
||||
|
||||
class EmailQueue:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+63
-5
@@ -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})
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
+268
-24
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app_config import get_config
|
||||
@@ -423,48 +424,291 @@ def cleanup_expired_ip_request_rates(now_ts: Optional[float] = None) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
# ==================== 登录失败追踪(触发验证码) ====================
|
||||
# ==================== 登录风控(验证码/限流/延迟/锁定) ====================
|
||||
|
||||
_login_failures: Dict[str, Dict[str, Any]] = {}
|
||||
_login_failures_lock = threading.RLock()
|
||||
|
||||
_login_rate_limits: Dict[str, Dict[str, Any]] = {}
|
||||
_login_rate_limits_lock = threading.RLock()
|
||||
|
||||
_login_scan_state: Dict[str, Dict[str, Any]] = {}
|
||||
_login_scan_lock = threading.RLock()
|
||||
|
||||
_login_ip_user_locks: Dict[str, Dict[str, Any]] = {}
|
||||
_login_ip_user_lock = threading.RLock()
|
||||
|
||||
_login_alert_state: Dict[int, Dict[str, Any]] = {}
|
||||
_login_alert_lock = threading.RLock()
|
||||
|
||||
|
||||
def _normalize_login_key(kind: str, ip_address: str, username: Optional[str] = None) -> str:
|
||||
ip_key = str(ip_address or "")
|
||||
user_key = str(username or "").strip().lower()
|
||||
if kind == "ip":
|
||||
return f"ip:{ip_key}"
|
||||
if kind == "user":
|
||||
return f"user:{user_key}" if user_key else ""
|
||||
return f"ipuser:{ip_key}:{user_key}" if user_key else ""
|
||||
|
||||
|
||||
def _get_login_captcha_config() -> Tuple[int, int]:
|
||||
return int(config.LOGIN_CAPTCHA_AFTER_FAILURES), int(config.LOGIN_CAPTCHA_WINDOW_SECONDS)
|
||||
|
||||
|
||||
def record_login_failure(ip_address: str) -> None:
|
||||
def _get_login_rate_limit_config() -> Tuple[int, int, int, int]:
|
||||
return (
|
||||
int(config.LOGIN_IP_MAX_ATTEMPTS),
|
||||
int(config.LOGIN_USERNAME_MAX_ATTEMPTS),
|
||||
int(config.LOGIN_IP_USERNAME_MAX_ATTEMPTS),
|
||||
int(config.LOGIN_RATE_LIMIT_WINDOW_SECONDS),
|
||||
)
|
||||
|
||||
|
||||
def _get_login_lock_config() -> Tuple[int, int, int]:
|
||||
return (
|
||||
int(config.LOGIN_ACCOUNT_LOCK_FAILURES),
|
||||
int(config.LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS),
|
||||
int(config.LOGIN_ACCOUNT_LOCK_SECONDS),
|
||||
)
|
||||
|
||||
|
||||
def _get_login_scan_config() -> Tuple[int, int, int]:
|
||||
return (
|
||||
int(config.LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD),
|
||||
int(config.LOGIN_SCAN_WINDOW_SECONDS),
|
||||
int(config.LOGIN_SCAN_COOLDOWN_SECONDS),
|
||||
)
|
||||
|
||||
|
||||
def _get_or_reset_bucket(data: Optional[Dict[str, Any]], now_ts: float, window_seconds: int) -> Dict[str, Any]:
|
||||
if not data or (now_ts - float(data.get("window_start", 0) or 0)) > window_seconds:
|
||||
return {"window_start": now_ts, "count": 0}
|
||||
return data
|
||||
|
||||
|
||||
def record_login_username_attempt(ip_address: str, username: str) -> bool:
|
||||
now_ts = time.time()
|
||||
max_failures, window_seconds = _get_login_captcha_config()
|
||||
threshold, window_seconds, cooldown_seconds = _get_login_scan_config()
|
||||
ip_key = str(ip_address or "")
|
||||
with _login_failures_lock:
|
||||
data = _login_failures.get(ip_key)
|
||||
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
|
||||
data = {"first_failed": now_ts, "count": 0}
|
||||
_login_failures[ip_key] = data
|
||||
data["count"] = int(data.get("count", 0) or 0) + 1
|
||||
if int(data["count"]) > max_failures * 5:
|
||||
data["count"] = max_failures * 5
|
||||
user_key = str(username or "").strip().lower()
|
||||
if not ip_key or not user_key:
|
||||
return False
|
||||
|
||||
with _login_scan_lock:
|
||||
data = _login_scan_state.get(ip_key)
|
||||
if not data or (now_ts - float(data.get("first_seen", 0) or 0)) > window_seconds:
|
||||
data = {"first_seen": now_ts, "usernames": set(), "scan_until": 0}
|
||||
_login_scan_state[ip_key] = data
|
||||
|
||||
data["usernames"].add(user_key)
|
||||
if len(data["usernames"]) >= threshold:
|
||||
data["scan_until"] = max(float(data.get("scan_until", 0) or 0), now_ts + cooldown_seconds)
|
||||
|
||||
return now_ts < float(data.get("scan_until", 0) or 0)
|
||||
|
||||
|
||||
def clear_login_failures(ip_address: str) -> None:
|
||||
ip_key = str(ip_address or "")
|
||||
with _login_failures_lock:
|
||||
_login_failures.pop(ip_key, None)
|
||||
|
||||
|
||||
def check_login_captcha_required(ip_address: str) -> bool:
|
||||
def is_login_scan_locked(ip_address: str) -> bool:
|
||||
now_ts = time.time()
|
||||
max_failures, window_seconds = _get_login_captcha_config()
|
||||
ip_key = str(ip_address or "")
|
||||
with _login_failures_lock:
|
||||
data = _login_failures.get(ip_key)
|
||||
with _login_scan_lock:
|
||||
data = _login_scan_state.get(ip_key)
|
||||
if not data:
|
||||
return False
|
||||
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
|
||||
_login_failures.pop(ip_key, None)
|
||||
if now_ts >= float(data.get("scan_until", 0) or 0):
|
||||
return False
|
||||
return int(data.get("count", 0) or 0) >= max_failures
|
||||
return True
|
||||
|
||||
|
||||
def check_login_rate_limits(ip_address: str, username: str) -> Tuple[bool, Optional[str]]:
|
||||
now_ts = time.time()
|
||||
ip_max, user_max, ip_user_max, window_seconds = _get_login_rate_limit_config()
|
||||
ip_key = _normalize_login_key("ip", ip_address)
|
||||
user_key = _normalize_login_key("user", "", username)
|
||||
ip_user_key = _normalize_login_key("ipuser", ip_address, username)
|
||||
|
||||
def _check(key: str, max_requests: int) -> Tuple[bool, Optional[str]]:
|
||||
if not key or max_requests <= 0:
|
||||
return True, None
|
||||
data = _get_or_reset_bucket(_login_rate_limits.get(key), now_ts, window_seconds)
|
||||
if int(data.get("count", 0) or 0) >= max_requests:
|
||||
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return False, f"请求过于频繁,请{wait_hint}后再试"
|
||||
data["count"] = int(data.get("count", 0) or 0) + 1
|
||||
_login_rate_limits[key] = data
|
||||
return True, None
|
||||
|
||||
with _login_rate_limits_lock:
|
||||
allowed, msg = _check(ip_key, ip_max)
|
||||
if not allowed:
|
||||
return False, msg
|
||||
allowed, msg = _check(ip_user_key, ip_user_max)
|
||||
if not allowed:
|
||||
return False, msg
|
||||
allowed, msg = _check(user_key, user_max)
|
||||
if not allowed:
|
||||
return False, msg
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _update_login_failure(key: str, now_ts: float, window_seconds: int) -> int:
|
||||
data = _login_failures.get(key)
|
||||
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
|
||||
data = {"first_failed": now_ts, "count": 0}
|
||||
_login_failures[key] = data
|
||||
data["count"] = int(data.get("count", 0) or 0) + 1
|
||||
return int(data["count"])
|
||||
|
||||
|
||||
def record_login_failure(ip_address: str, username: Optional[str] = None) -> None:
|
||||
now_ts = time.time()
|
||||
max_failures, window_seconds = _get_login_captcha_config()
|
||||
lock_failures, lock_window, lock_seconds = _get_login_lock_config()
|
||||
ip_key = _normalize_login_key("ip", ip_address)
|
||||
user_key = _normalize_login_key("user", "", username or "")
|
||||
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
|
||||
|
||||
with _login_failures_lock:
|
||||
ip_count = _update_login_failure(ip_key, now_ts, window_seconds)
|
||||
user_count = _update_login_failure(user_key, now_ts, window_seconds)
|
||||
ip_user_count = _update_login_failure(ip_user_key, now_ts, window_seconds)
|
||||
|
||||
for key in (ip_key, user_key, ip_user_key):
|
||||
data = _login_failures.get(key)
|
||||
if data and int(data.get("count", 0) or 0) > max_failures * 5:
|
||||
data["count"] = max_failures * 5
|
||||
|
||||
if username:
|
||||
ip_user_lock_key = _normalize_login_key("ipuser", ip_address, username)
|
||||
with _login_ip_user_lock:
|
||||
if ip_user_count >= lock_failures:
|
||||
_login_ip_user_locks[ip_user_lock_key] = {
|
||||
"lock_until": now_ts + lock_seconds,
|
||||
"first_failed": now_ts - lock_window,
|
||||
}
|
||||
|
||||
|
||||
def clear_login_failures(ip_address: str, username: Optional[str] = None) -> None:
|
||||
ip_key = _normalize_login_key("ip", ip_address)
|
||||
user_key = _normalize_login_key("user", "", username or "")
|
||||
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
|
||||
with _login_failures_lock:
|
||||
_login_failures.pop(ip_key, None)
|
||||
_login_failures.pop(user_key, None)
|
||||
_login_failures.pop(ip_user_key, None)
|
||||
with _login_ip_user_lock:
|
||||
_login_ip_user_locks.pop(ip_user_key, None)
|
||||
|
||||
|
||||
def _get_login_failure_count(ip_address: str, username: Optional[str] = None) -> int:
|
||||
now_ts = time.time()
|
||||
_, window_seconds = _get_login_captcha_config()
|
||||
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
|
||||
with _login_failures_lock:
|
||||
data = _login_failures.get(ip_user_key)
|
||||
if not data:
|
||||
return 0
|
||||
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
|
||||
_login_failures.pop(ip_user_key, None)
|
||||
return 0
|
||||
return int(data.get("count", 0) or 0)
|
||||
|
||||
|
||||
def check_login_captcha_required(ip_address: str, username: Optional[str] = None) -> bool:
|
||||
now_ts = time.time()
|
||||
max_failures, window_seconds = _get_login_captcha_config()
|
||||
ip_key = _normalize_login_key("ip", ip_address)
|
||||
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
|
||||
|
||||
with _login_failures_lock:
|
||||
ip_data = _login_failures.get(ip_key)
|
||||
if ip_data and (now_ts - float(ip_data.get("first_failed", 0) or 0)) <= window_seconds:
|
||||
if int(ip_data.get("count", 0) or 0) >= max_failures:
|
||||
return True
|
||||
ip_user_data = _login_failures.get(ip_user_key)
|
||||
if ip_user_data and (now_ts - float(ip_user_data.get("first_failed", 0) or 0)) <= window_seconds:
|
||||
if int(ip_user_data.get("count", 0) or 0) >= max_failures:
|
||||
return True
|
||||
|
||||
if is_login_scan_locked(ip_address):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_login_ip_user_locked(ip_address: str, username: Optional[str]) -> Tuple[bool, int]:
|
||||
now_ts = time.time()
|
||||
if not username:
|
||||
return False, 0
|
||||
ip_user_key = _normalize_login_key("ipuser", ip_address, username)
|
||||
with _login_ip_user_lock:
|
||||
data = _login_ip_user_locks.get(ip_user_key)
|
||||
if not data:
|
||||
return False, 0
|
||||
lock_until = float(data.get("lock_until", 0) or 0)
|
||||
if now_ts >= lock_until:
|
||||
_login_ip_user_locks.pop(ip_user_key, None)
|
||||
return False, 0
|
||||
remaining = int(lock_until - now_ts)
|
||||
return True, max(1, remaining)
|
||||
|
||||
|
||||
def get_login_failure_delay_seconds(ip_address: str, username: Optional[str]) -> float:
|
||||
fail_count = _get_login_failure_count(ip_address, username)
|
||||
if fail_count <= 0:
|
||||
return 0.0
|
||||
base_ms = max(0, int(config.LOGIN_FAIL_DELAY_BASE_MS))
|
||||
max_ms = max(base_ms, int(config.LOGIN_FAIL_DELAY_MAX_MS))
|
||||
delay_ms = min(max_ms, int(base_ms * (1.6 ** max(0, fail_count - 1))))
|
||||
jitter = random.randint(0, max(50, int(base_ms * 0.3)))
|
||||
return float(delay_ms + jitter) / 1000.0
|
||||
|
||||
|
||||
def should_send_login_alert(user_id: int, ip_address: str) -> bool:
|
||||
now_ts = time.time()
|
||||
min_interval = int(config.LOGIN_ALERT_MIN_INTERVAL_SECONDS)
|
||||
with _login_alert_lock:
|
||||
data = _login_alert_state.get(int(user_id))
|
||||
if not data:
|
||||
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
|
||||
return True
|
||||
last_sent = float(data.get("last_sent", 0) or 0)
|
||||
last_ip = str(data.get("last_ip", "") or "")
|
||||
if ip_address and ip_address != last_ip:
|
||||
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
|
||||
return True
|
||||
if (now_ts - last_sent) >= min_interval:
|
||||
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ==================== 邮箱维度限流 ====================
|
||||
|
||||
_email_rate_limit: Dict[str, Dict[str, Any]] = {}
|
||||
_email_rate_limit_lock = threading.RLock()
|
||||
|
||||
|
||||
def check_email_rate_limit(email: str, action: str) -> Tuple[bool, Optional[str]]:
|
||||
now_ts = time.time()
|
||||
max_requests = int(config.EMAIL_RATE_LIMIT_MAX)
|
||||
window_seconds = int(config.EMAIL_RATE_LIMIT_WINDOW_SECONDS)
|
||||
email_key = str(email or "").strip().lower()
|
||||
if not email_key:
|
||||
return True, None
|
||||
key = f"{action}:{email_key}"
|
||||
|
||||
with _email_rate_limit_lock:
|
||||
data = _get_or_reset_bucket(_email_rate_limit.get(key), now_ts, window_seconds)
|
||||
if int(data.get("count", 0) or 0) >= max_requests:
|
||||
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return False, f"请求过于频繁,请{wait_hint}后再试"
|
||||
data["count"] = int(data.get("count", 0) or 0) + 1
|
||||
_email_rate_limit[key] = data
|
||||
return True, None
|
||||
|
||||
|
||||
# ==================== Batch screenshots(批次任务截图收集) ====================
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"_email-BfqhxXOq.js": {
|
||||
"file": "assets/email-BfqhxXOq.js",
|
||||
"_email-BsKBHU5S.js": {
|
||||
"file": "assets/email-BsKBHU5S.js",
|
||||
"name": "email",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_tasks-BtWKY-g7.js": {
|
||||
"file": "assets/tasks-BtWKY-g7.js",
|
||||
"_tasks-DpslJtm_.js": {
|
||||
"file": "assets/tasks-DpslJtm_.js",
|
||||
"name": "tasks",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_update-BrAMPxiF.js": {
|
||||
"file": "assets/update-BrAMPxiF.js",
|
||||
"_update-DcFD-YxU.js": {
|
||||
"file": "assets/update-DcFD-YxU.js",
|
||||
"name": "update",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_users-CToznuvL.js": {
|
||||
"file": "assets/users-CToznuvL.js",
|
||||
"_users-CC9BckjT.js": {
|
||||
"file": "assets/users-CC9BckjT.js",
|
||||
"name": "users",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-Da0EvMWc.js",
|
||||
"file": "assets/index-CdjS44Uj.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
@@ -47,7 +47,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/AnnouncementsPage.vue": {
|
||||
"file": "assets/AnnouncementsPage-CbLi3NFK.js",
|
||||
"file": "assets/AnnouncementsPage-Djmq3Wb7.js",
|
||||
"name": "AnnouncementsPage",
|
||||
"src": "src/pages/AnnouncementsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -59,20 +59,20 @@
|
||||
]
|
||||
},
|
||||
"src/pages/EmailPage.vue": {
|
||||
"file": "assets/EmailPage-CaUZghxJ.js",
|
||||
"file": "assets/EmailPage-q6nJlTue.js",
|
||||
"name": "EmailPage",
|
||||
"src": "src/pages/EmailPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_email-BfqhxXOq.js",
|
||||
"_email-BsKBHU5S.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
"assets/EmailPage-DD73oBux.css"
|
||||
"assets/EmailPage-BxzHc6tN.css"
|
||||
]
|
||||
},
|
||||
"src/pages/FeedbacksPage.vue": {
|
||||
"file": "assets/FeedbacksPage-DCz_21CH.js",
|
||||
"file": "assets/FeedbacksPage-Drw6uvSR.js",
|
||||
"name": "FeedbacksPage",
|
||||
"src": "src/pages/FeedbacksPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -84,13 +84,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/LogsPage.vue": {
|
||||
"file": "assets/LogsPage-k6AvTEc_.js",
|
||||
"file": "assets/LogsPage-DQd9IS3I.js",
|
||||
"name": "LogsPage",
|
||||
"src": "src/pages/LogsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-CToznuvL.js",
|
||||
"_tasks-BtWKY-g7.js",
|
||||
"_users-CC9BckjT.js",
|
||||
"_tasks-DpslJtm_.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -98,22 +98,22 @@
|
||||
]
|
||||
},
|
||||
"src/pages/ReportPage.vue": {
|
||||
"file": "assets/ReportPage-BkB6FuHA.js",
|
||||
"file": "assets/ReportPage-Dnk3wsl3.js",
|
||||
"name": "ReportPage",
|
||||
"src": "src/pages/ReportPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_email-BfqhxXOq.js",
|
||||
"_tasks-BtWKY-g7.js",
|
||||
"_update-BrAMPxiF.js"
|
||||
"_email-BsKBHU5S.js",
|
||||
"_tasks-DpslJtm_.js",
|
||||
"_update-DcFD-YxU.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/ReportPage-TpqQWWvU.css"
|
||||
]
|
||||
},
|
||||
"src/pages/SettingsPage.vue": {
|
||||
"file": "assets/SettingsPage-CeJoz6yA.js",
|
||||
"file": "assets/SettingsPage-YOW1Apwk.js",
|
||||
"name": "SettingsPage",
|
||||
"src": "src/pages/SettingsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -125,12 +125,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SystemPage.vue": {
|
||||
"file": "assets/SystemPage-Dmtz_emI.js",
|
||||
"file": "assets/SystemPage-DCcH_SAQ.js",
|
||||
"name": "SystemPage",
|
||||
"src": "src/pages/SystemPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_update-BrAMPxiF.js",
|
||||
"_update-DcFD-YxU.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -138,12 +138,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/UsersPage.vue": {
|
||||
"file": "assets/UsersPage-JTbL8-nm.js",
|
||||
"file": "assets/UsersPage-DhTO_5zp.js",
|
||||
"name": "UsersPage",
|
||||
"src": "src/pages/UsersPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-CToznuvL.js",
|
||||
"_users-CC9BckjT.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.page-stack[data-v-ff849557]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-ff849557]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-ff849557]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-ff849557]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-ff849557]{margin:0;font-size:14px;font-weight:800}.help[data-v-ff849557]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-ff849557]{overflow-x:auto}.stat-card[data-v-ff849557]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-ff849557]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-ff849557]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-ff849557]{color:#047857}.err[data-v-ff849557]{color:#b91c1c}.sub-stats[data-v-ff849557]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-ff849557]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-ff849557]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-ff849557]{font-size:12px}.dialog-actions[data-v-ff849557]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-ff849557]{flex:1}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.page-stack[data-v-03fa4932]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-03fa4932]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-03fa4932]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-03fa4932]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-03fa4932]{margin:0;font-size:14px;font-weight:800}.help[data-v-03fa4932]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-03fa4932]{overflow-x:auto}.stat-card[data-v-03fa4932]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-03fa4932]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-03fa4932]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-03fa4932]{color:#047857}.err[data-v-03fa4932]{color:#b91c1c}.sub-stats[data-v-03fa4932]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-03fa4932]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-03fa4932]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-03fa4932]{font-size:12px}.dialog-actions[data-v-03fa4932]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-03fa4932]{flex:1}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{S as m,_ as T,r as p,e as h,f as r,g as a,w as s,n as u,x as k,y as x,L as i,K as b}from"./index-Da0EvMWc.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),y=u("el-form-item"),v=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default};
|
||||
import{S as m,_ as T,r as p,e as u,f as h,g as k,h as r,j as a,w as s,p as x,L as i,K as b}from"./index-CdjS44Uj.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default};
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{S as n}from"./index-Da0EvMWc.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{i as a,o as b,l as c,c as f,e as u};
|
||||
import{S as n}from"./index-CdjS44Uj.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{o as a,i as b,l as c,c as f,e as u};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{S as a}from"./index-Da0EvMWc.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};
|
||||
import{S as a}from"./index-CdjS44Uj.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};
|
||||
@@ -1 +1 @@
|
||||
import{S as a}from"./index-Da0EvMWc.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u};
|
||||
import{S as a}from"./index-CdjS44Uj.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u};
|
||||
@@ -1 +1 @@
|
||||
import{S as t}from"./index-Da0EvMWc.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};
|
||||
import{S as t}from"./index-CdjS44Uj.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};
|
||||
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>后台管理 - 知识管理平台</title>
|
||||
<script type="module" crossorigin src="./assets/index-Da0EvMWc.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CdjS44Uj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-EWm4DZW8.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1355,6 +1355,7 @@
|
||||
<option value="reset">密码重置</option>
|
||||
<option value="bind">邮箱绑定</option>
|
||||
<option value="task_complete">任务完成</option>
|
||||
<option value="security_alert">安全告警</option>
|
||||
</select>
|
||||
<select id="emailLogStatusFilter" onchange="loadEmailLogs()" style="padding: 6px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 12px;">
|
||||
<option value="">全部状态</option>
|
||||
@@ -3424,7 +3425,8 @@
|
||||
'register': '注册验证',
|
||||
'reset': '密码重置',
|
||||
'bind': '邮箱绑定',
|
||||
'task_complete': '任务完成'
|
||||
'task_complete': '任务完成',
|
||||
'security_alert': '安全告警'
|
||||
};
|
||||
|
||||
let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>';
|
||||
|
||||
Reference in New Issue
Block a user