Harden auth risk controls and admin reauth
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
let lastToastKey = ''
|
let lastToastKey = ''
|
||||||
let lastToastAt = 0
|
let lastToastAt = 0
|
||||||
@@ -24,6 +24,29 @@ export const api = axios.create({
|
|||||||
withCredentials: true,
|
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) => {
|
api.interceptors.request.use((config) => {
|
||||||
const method = String(config?.method || 'GET').toUpperCase()
|
const method = String(config?.method || 'GET').toUpperCase()
|
||||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
@@ -38,11 +61,21 @@ api.interceptors.request.use((config) => {
|
|||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status
|
||||||
const payload = error?.response?.data
|
const payload = error?.response?.data
|
||||||
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
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) {
|
if (status === 401) {
|
||||||
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||||
const pathname = window.location?.pathname || ''
|
const pathname = window.location?.pathname || ''
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ function emailTypeLabel(type) {
|
|||||||
reset: '密码重置',
|
reset: '密码重置',
|
||||||
bind: '邮箱绑定',
|
bind: '邮箱绑定',
|
||||||
task_complete: '任务完成',
|
task_complete: '任务完成',
|
||||||
|
security_alert: '安全告警',
|
||||||
}
|
}
|
||||||
return map[type] || type
|
return map[type] || type
|
||||||
}
|
}
|
||||||
@@ -701,6 +702,7 @@ onMounted(refreshAll)
|
|||||||
<el-option label="密码重置" value="reset" />
|
<el-option label="密码重置" value="reset" />
|
||||||
<el-option label="邮箱绑定" value="bind" />
|
<el-option label="邮箱绑定" value="bind" />
|
||||||
<el-option label="任务完成" value="task_complete" />
|
<el-option label="任务完成" value="task_complete" />
|
||||||
|
<el-option label="安全告警" value="security_alert" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
|
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
|
||||||
<el-option label="全部状态" value="" />
|
<el-option label="全部状态" value="" />
|
||||||
|
|||||||
@@ -189,6 +189,23 @@ class Config:
|
|||||||
MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB
|
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_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_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
|
@classmethod
|
||||||
def validate(cls):
|
def validate(cls):
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ from db.users import (
|
|||||||
set_user_vip,
|
set_user_vip,
|
||||||
verify_user,
|
verify_user,
|
||||||
)
|
)
|
||||||
|
from db.security import record_login_context
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ config = get_config()
|
|||||||
DB_FILE = config.DB_FILE
|
DB_FILE = config.DB_FILE
|
||||||
|
|
||||||
# 数据库版本 (用于迁移管理)
|
# 数据库版本 (用于迁移管理)
|
||||||
DB_VERSION = 11
|
DB_VERSION = 12
|
||||||
|
|
||||||
|
|
||||||
# ==================== 系统配置缓存(P1 / O-03) ====================
|
# ==================== 系统配置缓存(P1 / O-03) ====================
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ def migrate_database(conn, target_version: int) -> None:
|
|||||||
if current_version < 11:
|
if current_version < 11:
|
||||||
_migrate_to_v11(conn)
|
_migrate_to_v11(conn)
|
||||||
current_version = 11
|
current_version = 11
|
||||||
|
if current_version < 12:
|
||||||
|
_migrate_to_v12(conn)
|
||||||
|
current_version = 12
|
||||||
|
|
||||||
if current_version != int(target_version):
|
if current_version != int(target_version):
|
||||||
set_current_version(conn, int(target_version))
|
set_current_version(conn, int(target_version))
|
||||||
@@ -472,7 +475,47 @@ def _migrate_to_v11(conn):
|
|||||||
)
|
)
|
||||||
updated = cursor.rowcount
|
updated = cursor.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
print(f" ✓ 已将 {updated} 个 pending 用户迁移为 approved")
|
print(f" ✓ 已将 {updated} 个 pending 用户迁移为 approved")
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
print(f" ⚠️ v11 迁移跳过: {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()
|
||||||
|
|||||||
33
db/schema.py
33
db/schema.py
@@ -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(
|
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_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_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_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_user_id ON accounts(user_id)")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")
|
||||||
|
|||||||
76
db/security.py
Normal file
76
db/security.py
Normal file
@@ -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_RESET = 'reset' # 密码重置
|
||||||
EMAIL_TYPE_BIND = 'bind' # 邮箱绑定
|
EMAIL_TYPE_BIND = 'bind' # 邮箱绑定
|
||||||
EMAIL_TYPE_TASK_COMPLETE = 'task_complete' # 任务完成通知
|
EMAIL_TYPE_TASK_COMPLETE = 'task_complete' # 任务完成通知
|
||||||
|
EMAIL_TYPE_SECURITY_ALERT = 'security_alert' # 安全告警
|
||||||
|
|
||||||
# Token有效期(秒)
|
# Token有效期(秒)
|
||||||
TOKEN_EXPIRE_REGISTER = 24 * 60 * 60 # 注册验证: 24小时
|
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)
|
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:
|
class EmailQueue:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import datetime
|
|||||||
import database
|
import database
|
||||||
import email_service
|
import email_service
|
||||||
import requests
|
import requests
|
||||||
|
from app_config import get_config
|
||||||
from app_logger import get_logger
|
from app_logger import get_logger
|
||||||
from app_security import (
|
from app_security import (
|
||||||
get_rate_limit_ip,
|
get_rate_limit_ip,
|
||||||
@@ -32,6 +33,10 @@ from services.state import (
|
|||||||
safe_iter_task_status_items,
|
safe_iter_task_status_items,
|
||||||
safe_remove_user_accounts,
|
safe_remove_user_accounts,
|
||||||
safe_verify_and_consume_captcha,
|
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_ip_request_rate,
|
||||||
check_login_captcha_required,
|
check_login_captcha_required,
|
||||||
clear_login_failures,
|
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
|
from services.time_utils import BEIJING_TZ, get_beijing_now
|
||||||
|
|
||||||
logger = get_logger("app")
|
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"])
|
@admin_api_bp.route("/debug-config", methods=["GET"])
|
||||||
@@ -83,13 +102,29 @@ def admin_login():
|
|||||||
need_captcha = data.get("need_captcha", False)
|
need_captcha = data.get("need_captcha", False)
|
||||||
|
|
||||||
client_ip = get_rate_limit_ip()
|
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")
|
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||||
if not allowed:
|
if not allowed:
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
return jsonify({"error": error_msg, "need_captcha": True}), 429
|
return jsonify({"error": error_msg, "need_captcha": True}), 429
|
||||||
return redirect(url_for("pages.admin_login_page"))
|
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 captcha_required:
|
||||||
if not captcha_session or not captcha_code:
|
if not captcha_session or not captcha_code:
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
@@ -97,18 +132,19 @@ def admin_login():
|
|||||||
return redirect(url_for("pages.admin_login_page"))
|
return redirect(url_for("pages.admin_login_page"))
|
||||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||||
if not success:
|
if not success:
|
||||||
record_login_failure(client_ip)
|
record_login_failure(client_ip, username_key)
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
return jsonify({"error": message, "need_captcha": True}), 400
|
return jsonify({"error": message, "need_captcha": True}), 400
|
||||||
return redirect(url_for("pages.admin_login_page"))
|
return redirect(url_for("pages.admin_login_page"))
|
||||||
|
|
||||||
admin = database.verify_admin(username, password)
|
admin = database.verify_admin(username, password)
|
||||||
if admin:
|
if admin:
|
||||||
clear_login_failures(client_ip)
|
clear_login_failures(client_ip, username_key)
|
||||||
session.pop("admin_id", None)
|
session.pop("admin_id", None)
|
||||||
session.pop("admin_username", None)
|
session.pop("admin_username", None)
|
||||||
session["admin_id"] = admin["id"]
|
session["admin_id"] = admin["id"]
|
||||||
session["admin_username"] = admin["username"]
|
session["admin_username"] = admin["username"]
|
||||||
|
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
|
|
||||||
@@ -118,7 +154,10 @@ def admin_login():
|
|||||||
return jsonify({"success": True, "redirect": "/yuyx/admin"})
|
return jsonify({"success": True, "redirect": "/yuyx/admin"})
|
||||||
return redirect(url_for("pages.admin_page"))
|
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} 登录失败 - 用户名或密码错误")
|
logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
|
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_id", None)
|
||||||
session.pop("admin_username", None)
|
session.pop("admin_username", None)
|
||||||
|
session.pop("admin_reauth_until", None)
|
||||||
return jsonify({"success": True})
|
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(管理员) ====================
|
# ==================== 公告管理API(管理员) ====================
|
||||||
|
|
||||||
|
|
||||||
@@ -761,6 +823,9 @@ def restart_docker_container():
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
reauth_response = _require_admin_reauth()
|
||||||
|
if reauth_response:
|
||||||
|
return reauth_response
|
||||||
if not os.path.exists("/.dockerenv"):
|
if not os.path.exists("/.dockerenv"):
|
||||||
return jsonify({"error": "当前不在Docker容器中运行"}), 400
|
return jsonify({"error": "当前不在Docker容器中运行"}), 400
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import jsonify, request, session
|
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")
|
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_api_bp.route("/update/status", methods=["GET"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_update_status_api():
|
def get_update_status_api():
|
||||||
@@ -146,6 +154,8 @@ def request_update_check_api():
|
|||||||
def request_update_run_api():
|
def request_update_run_api():
|
||||||
"""请求宿主机 Update-Agent 执行一键更新并重启服务。"""
|
"""请求宿主机 Update-Agent 执行一键更新并重启服务。"""
|
||||||
ensure_update_dirs()
|
ensure_update_dirs()
|
||||||
|
if _admin_reauth_required():
|
||||||
|
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
|
||||||
if _has_pending_request():
|
if _has_pending_request():
|
||||||
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409
|
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import time
|
|||||||
|
|
||||||
import database
|
import database
|
||||||
import email_service
|
import email_service
|
||||||
|
from app_config import get_config
|
||||||
from app_logger import get_logger
|
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 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
|
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.models import User
|
||||||
from services.state import (
|
from services.state import (
|
||||||
check_ip_request_rate,
|
check_ip_request_rate,
|
||||||
|
check_email_rate_limit,
|
||||||
|
check_login_ip_user_locked,
|
||||||
|
check_login_rate_limits,
|
||||||
check_login_captcha_required,
|
check_login_captcha_required,
|
||||||
clear_login_failures,
|
clear_login_failures,
|
||||||
|
get_login_failure_delay_seconds,
|
||||||
record_failed_captcha,
|
record_failed_captcha,
|
||||||
record_login_failure,
|
record_login_failure,
|
||||||
|
record_login_username_attempt,
|
||||||
safe_cleanup_expired_captcha,
|
safe_cleanup_expired_captcha,
|
||||||
safe_delete_captcha,
|
safe_delete_captcha,
|
||||||
safe_set_captcha,
|
safe_set_captcha,
|
||||||
safe_verify_and_consume_captcha,
|
safe_verify_and_consume_captcha,
|
||||||
|
should_send_login_alert,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger("app")
|
logger = get_logger("app")
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
api_auth_bp = Blueprint("api_auth", __name__)
|
api_auth_bp = Blueprint("api_auth", __name__)
|
||||||
|
|
||||||
@@ -181,6 +189,9 @@ def resend_verify_email():
|
|||||||
|
|
||||||
client_ip = get_rate_limit_ip()
|
client_ip = get_rate_limit_ip()
|
||||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
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:
|
if not allowed:
|
||||||
return jsonify({"error": error_msg}), 429
|
return jsonify({"error": error_msg}), 429
|
||||||
|
|
||||||
@@ -238,6 +249,9 @@ def forgot_password():
|
|||||||
|
|
||||||
client_ip = get_rate_limit_ip()
|
client_ip = get_rate_limit_ip()
|
||||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
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:
|
if not allowed:
|
||||||
return jsonify({"error": error_msg}), 429
|
return jsonify({"error": error_msg}), 429
|
||||||
|
|
||||||
@@ -323,6 +337,15 @@ def request_password_reset():
|
|||||||
if not is_valid:
|
if not is_valid:
|
||||||
return jsonify({"error": error_msg}), 400
|
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)
|
user = database.get_user_by_username(username)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
@@ -416,31 +439,66 @@ def login():
|
|||||||
need_captcha = data.get("need_captcha", False)
|
need_captcha = data.get("need_captcha", False)
|
||||||
|
|
||||||
client_ip = get_rate_limit_ip()
|
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")
|
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return jsonify({"error": error_msg, "need_captcha": True}), 429
|
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 captcha_required:
|
||||||
if not captcha_session or not captcha_code:
|
if not captcha_session or not captcha_code:
|
||||||
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
|
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
|
||||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||||
if not success:
|
if not success:
|
||||||
record_login_failure(client_ip)
|
record_login_failure(client_ip, username_key)
|
||||||
return jsonify({"error": message, "need_captcha": True}), 400
|
return jsonify({"error": message, "need_captcha": True}), 400
|
||||||
|
|
||||||
user = database.verify_user(username, password)
|
user = database.verify_user(username, password)
|
||||||
if not user:
|
if not user:
|
||||||
record_login_failure(client_ip)
|
record_login_failure(client_ip, username_key)
|
||||||
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
|
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":
|
if user["status"] != "approved":
|
||||||
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
|
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
|
||||||
|
|
||||||
clear_login_failures(client_ip)
|
clear_login_failures(client_ip, username_key)
|
||||||
user_obj = User(user["id"])
|
user_obj = User(user["id"])
|
||||||
login_user(user_obj)
|
login_user(user_obj)
|
||||||
load_user_accounts(user["id"])
|
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})
|
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 import Blueprint, jsonify, request
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from routes.pages import render_app_spa_or_legacy
|
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")
|
logger = get_logger("app")
|
||||||
|
|
||||||
@@ -164,6 +164,9 @@ def bind_user_email():
|
|||||||
|
|
||||||
client_ip = get_rate_limit_ip()
|
client_ip = get_rate_limit_ip()
|
||||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
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:
|
if not allowed:
|
||||||
return jsonify({"error": error_msg}), 429
|
return jsonify({"error": error_msg}), 429
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from app_config import get_config
|
from app_config import get_config
|
||||||
@@ -423,48 +424,291 @@ def cleanup_expired_ip_request_rates(now_ts: Optional[float] = None) -> int:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
# ==================== 登录失败追踪(触发验证码) ====================
|
# ==================== 登录风控(验证码/限流/延迟/锁定) ====================
|
||||||
|
|
||||||
_login_failures: Dict[str, Dict[str, Any]] = {}
|
_login_failures: Dict[str, Dict[str, Any]] = {}
|
||||||
_login_failures_lock = threading.RLock()
|
_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]:
|
def _get_login_captcha_config() -> Tuple[int, int]:
|
||||||
return int(config.LOGIN_CAPTCHA_AFTER_FAILURES), int(config.LOGIN_CAPTCHA_WINDOW_SECONDS)
|
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()
|
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 "")
|
ip_key = str(ip_address or "")
|
||||||
with _login_failures_lock:
|
user_key = str(username or "").strip().lower()
|
||||||
data = _login_failures.get(ip_key)
|
if not ip_key or not user_key:
|
||||||
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
|
return False
|
||||||
data = {"first_failed": now_ts, "count": 0}
|
|
||||||
_login_failures[ip_key] = data
|
with _login_scan_lock:
|
||||||
data["count"] = int(data.get("count", 0) or 0) + 1
|
data = _login_scan_state.get(ip_key)
|
||||||
if int(data["count"]) > max_failures * 5:
|
if not data or (now_ts - float(data.get("first_seen", 0) or 0)) > window_seconds:
|
||||||
data["count"] = max_failures * 5
|
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:
|
def is_login_scan_locked(ip_address: str) -> bool:
|
||||||
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:
|
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
max_failures, window_seconds = _get_login_captcha_config()
|
|
||||||
ip_key = str(ip_address or "")
|
ip_key = str(ip_address or "")
|
||||||
with _login_failures_lock:
|
with _login_scan_lock:
|
||||||
data = _login_failures.get(ip_key)
|
data = _login_scan_state.get(ip_key)
|
||||||
if not data:
|
if not data:
|
||||||
return False
|
return False
|
||||||
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
|
if now_ts >= float(data.get("scan_until", 0) or 0):
|
||||||
_login_failures.pop(ip_key, None)
|
|
||||||
return False
|
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(批次任务截图收集) ====================
|
# ==================== Batch screenshots(批次任务截图收集) ====================
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
{
|
{
|
||||||
"_email-BfqhxXOq.js": {
|
"_email-BsKBHU5S.js": {
|
||||||
"file": "assets/email-BfqhxXOq.js",
|
"file": "assets/email-BsKBHU5S.js",
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_tasks-BtWKY-g7.js": {
|
"_tasks-DpslJtm_.js": {
|
||||||
"file": "assets/tasks-BtWKY-g7.js",
|
"file": "assets/tasks-DpslJtm_.js",
|
||||||
"name": "tasks",
|
"name": "tasks",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_update-BrAMPxiF.js": {
|
"_update-DcFD-YxU.js": {
|
||||||
"file": "assets/update-BrAMPxiF.js",
|
"file": "assets/update-DcFD-YxU.js",
|
||||||
"name": "update",
|
"name": "update",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_users-CToznuvL.js": {
|
"_users-CC9BckjT.js": {
|
||||||
"file": "assets/users-CToznuvL.js",
|
"file": "assets/users-CC9BckjT.js",
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"index.html": {
|
"index.html": {
|
||||||
"file": "assets/index-Da0EvMWc.js",
|
"file": "assets/index-CdjS44Uj.js",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
"src": "index.html",
|
"src": "index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/AnnouncementsPage.vue": {
|
"src/pages/AnnouncementsPage.vue": {
|
||||||
"file": "assets/AnnouncementsPage-CbLi3NFK.js",
|
"file": "assets/AnnouncementsPage-Djmq3Wb7.js",
|
||||||
"name": "AnnouncementsPage",
|
"name": "AnnouncementsPage",
|
||||||
"src": "src/pages/AnnouncementsPage.vue",
|
"src": "src/pages/AnnouncementsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -59,20 +59,20 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/EmailPage.vue": {
|
"src/pages/EmailPage.vue": {
|
||||||
"file": "assets/EmailPage-CaUZghxJ.js",
|
"file": "assets/EmailPage-q6nJlTue.js",
|
||||||
"name": "EmailPage",
|
"name": "EmailPage",
|
||||||
"src": "src/pages/EmailPage.vue",
|
"src": "src/pages/EmailPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_email-BfqhxXOq.js",
|
"_email-BsKBHU5S.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/EmailPage-DD73oBux.css"
|
"assets/EmailPage-BxzHc6tN.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/FeedbacksPage.vue": {
|
"src/pages/FeedbacksPage.vue": {
|
||||||
"file": "assets/FeedbacksPage-DCz_21CH.js",
|
"file": "assets/FeedbacksPage-Drw6uvSR.js",
|
||||||
"name": "FeedbacksPage",
|
"name": "FeedbacksPage",
|
||||||
"src": "src/pages/FeedbacksPage.vue",
|
"src": "src/pages/FeedbacksPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -84,13 +84,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/LogsPage.vue": {
|
"src/pages/LogsPage.vue": {
|
||||||
"file": "assets/LogsPage-k6AvTEc_.js",
|
"file": "assets/LogsPage-DQd9IS3I.js",
|
||||||
"name": "LogsPage",
|
"name": "LogsPage",
|
||||||
"src": "src/pages/LogsPage.vue",
|
"src": "src/pages/LogsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-CToznuvL.js",
|
"_users-CC9BckjT.js",
|
||||||
"_tasks-BtWKY-g7.js",
|
"_tasks-DpslJtm_.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -98,22 +98,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/ReportPage.vue": {
|
"src/pages/ReportPage.vue": {
|
||||||
"file": "assets/ReportPage-BkB6FuHA.js",
|
"file": "assets/ReportPage-Dnk3wsl3.js",
|
||||||
"name": "ReportPage",
|
"name": "ReportPage",
|
||||||
"src": "src/pages/ReportPage.vue",
|
"src": "src/pages/ReportPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"_email-BfqhxXOq.js",
|
"_email-BsKBHU5S.js",
|
||||||
"_tasks-BtWKY-g7.js",
|
"_tasks-DpslJtm_.js",
|
||||||
"_update-BrAMPxiF.js"
|
"_update-DcFD-YxU.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/ReportPage-TpqQWWvU.css"
|
"assets/ReportPage-TpqQWWvU.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SettingsPage.vue": {
|
"src/pages/SettingsPage.vue": {
|
||||||
"file": "assets/SettingsPage-CeJoz6yA.js",
|
"file": "assets/SettingsPage-YOW1Apwk.js",
|
||||||
"name": "SettingsPage",
|
"name": "SettingsPage",
|
||||||
"src": "src/pages/SettingsPage.vue",
|
"src": "src/pages/SettingsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -125,12 +125,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SystemPage.vue": {
|
"src/pages/SystemPage.vue": {
|
||||||
"file": "assets/SystemPage-Dmtz_emI.js",
|
"file": "assets/SystemPage-DCcH_SAQ.js",
|
||||||
"name": "SystemPage",
|
"name": "SystemPage",
|
||||||
"src": "src/pages/SystemPage.vue",
|
"src": "src/pages/SystemPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_update-BrAMPxiF.js",
|
"_update-DcFD-YxU.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -138,12 +138,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/UsersPage.vue": {
|
"src/pages/UsersPage.vue": {
|
||||||
"file": "assets/UsersPage-JTbL8-nm.js",
|
"file": "assets/UsersPage-DhTO_5zp.js",
|
||||||
"name": "UsersPage",
|
"name": "UsersPage",
|
||||||
"src": "src/pages/UsersPage.vue",
|
"src": "src/pages/UsersPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-CToznuvL.js",
|
"_users-CC9BckjT.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
static/admin/assets/EmailPage-BxzHc6tN.css
Normal file
1
static/admin/assets/EmailPage-BxzHc6tN.css
Normal file
@@ -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}
|
|
||||||
1
static/admin/assets/EmailPage-q6nJlTue.js
Normal file
1
static/admin/assets/EmailPage-q6nJlTue.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
|
||||||
File diff suppressed because one or more lines are too long
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" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>后台管理 - 知识管理平台</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="./assets/index-EWm4DZW8.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1355,6 +1355,7 @@
|
|||||||
<option value="reset">密码重置</option>
|
<option value="reset">密码重置</option>
|
||||||
<option value="bind">邮箱绑定</option>
|
<option value="bind">邮箱绑定</option>
|
||||||
<option value="task_complete">任务完成</option>
|
<option value="task_complete">任务完成</option>
|
||||||
|
<option value="security_alert">安全告警</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="emailLogStatusFilter" onchange="loadEmailLogs()" style="padding: 6px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 12px;">
|
<select id="emailLogStatusFilter" onchange="loadEmailLogs()" style="padding: 6px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 12px;">
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
@@ -3424,7 +3425,8 @@
|
|||||||
'register': '注册验证',
|
'register': '注册验证',
|
||||||
'reset': '密码重置',
|
'reset': '密码重置',
|
||||||
'bind': '邮箱绑定',
|
'bind': '邮箱绑定',
|
||||||
'task_complete': '任务完成'
|
'task_complete': '任务完成',
|
||||||
|
'security_alert': '安全告警'
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>';
|
let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>';
|
||||||
|
|||||||
Reference in New Issue
Block a user