概览页增加后台登录记录
登录成功/失败写入 admin_login_logs,系统概览下方展示全部账号的时间、IP、设备等信息。
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
|
||||
import { recordAdminLoginLog } from './admin-login-log-service.js'
|
||||
|
||||
type AdminUserRow = NonNullable<Awaited<ReturnType<typeof getAdminUserById>>>
|
||||
|
||||
@@ -58,13 +59,27 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginAdmin(username: unknown, password: unknown): Promise<JsonObject> {
|
||||
export async function loginAdmin(
|
||||
username: unknown,
|
||||
password: unknown,
|
||||
meta: {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
} = {},
|
||||
): Promise<JsonObject> {
|
||||
ensureAdminAuthConfigured()
|
||||
|
||||
const normalizedUsername = String(username || '').trim().toLowerCase()
|
||||
const normalizedPassword = String(password || '').trim()
|
||||
|
||||
if (!normalizedUsername || !normalizedPassword) {
|
||||
await recordAdminLoginLog({
|
||||
username: normalizedUsername,
|
||||
success: false,
|
||||
failureReason: 'missing_credentials',
|
||||
...pickLoginMeta(meta),
|
||||
})
|
||||
throw createHttpError('缺少后台账号或密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_credentials_required',
|
||||
@@ -73,13 +88,29 @@ export async function loginAdmin(username: unknown, password: unknown): Promise<
|
||||
|
||||
const user = await getAdminUserByUsername(normalizedUsername)
|
||||
if (!user || user.status !== 'active' || !verifyAdminPassword(normalizedPassword, user.password_hash)) {
|
||||
await recordAdminLoginLog({
|
||||
userId: user ? Number(user.id) : null,
|
||||
username: normalizedUsername,
|
||||
role: user ? normalizeAdminRole(user.role) : '',
|
||||
success: false,
|
||||
failureReason: 'invalid_credentials',
|
||||
...pickLoginMeta(meta),
|
||||
})
|
||||
throw createHttpError('账号或密码错误', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_login_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return createAdminSession(user)
|
||||
const session = createAdminSession(user)
|
||||
await recordAdminLoginLog({
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || normalizedUsername),
|
||||
role: normalizeAdminRole(user.role),
|
||||
success: true,
|
||||
...pickLoginMeta(meta),
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
export async function verifyAdminSessionToken(token: unknown): Promise<AdminSession> {
|
||||
@@ -523,3 +554,27 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
|
||||
updatedAt: user.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function pickLoginMeta(meta: {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
} = {}) {
|
||||
const result: {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
} = {}
|
||||
|
||||
if (typeof meta.ip === 'string' && meta.ip.trim()) {
|
||||
result.ip = meta.ip.trim()
|
||||
}
|
||||
if (typeof meta.userAgent === 'string' && meta.userAgent.trim()) {
|
||||
result.userAgent = meta.userAgent.trim()
|
||||
}
|
||||
if (typeof meta.location === 'string' && meta.location.trim()) {
|
||||
result.location = meta.location.trim()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Request } from 'express'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
import {
|
||||
createAdminLoginLog,
|
||||
listAdminLoginLogs,
|
||||
} from '../../repositories/admin-login-log-repo.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
normalizeDateQuery,
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
} from './admin-query-utils.js'
|
||||
|
||||
type RecordAdminLoginInput = {
|
||||
userId?: number | null
|
||||
username?: string
|
||||
role?: string
|
||||
success?: boolean
|
||||
failureReason?: string
|
||||
ip?: string
|
||||
location?: string
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
|
||||
try {
|
||||
return await createAdminLoginLog({
|
||||
userId: input.userId ?? null,
|
||||
username: String(input.username || '').trim(),
|
||||
role: String(input.role || '').trim(),
|
||||
ip: String(input.ip || '').trim(),
|
||||
location: String(input.location || '').trim(),
|
||||
userAgent: String(input.userAgent || '').trim(),
|
||||
success: input.success !== false,
|
||||
failureReason: String(input.failureReason || '').trim(),
|
||||
createdAt: nowIso(),
|
||||
})
|
||||
} catch (error) {
|
||||
logWarn('[admin/login-log]', '写入登录记录失败,已跳过', {
|
||||
username: input.username,
|
||||
error,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordAdminLoginFromRequest(
|
||||
req: Request,
|
||||
input: Omit<RecordAdminLoginInput, 'ip' | 'userAgent' | 'location'> = {},
|
||||
) {
|
||||
return recordAdminLoginLog({
|
||||
...input,
|
||||
ip: resolveClientIp(req),
|
||||
userAgent: resolveUserAgent(req),
|
||||
location: resolveClientLocation(req),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAdminLoginLogs(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listAdminLoginLogs({
|
||||
page,
|
||||
pageSize,
|
||||
username: String(query.username || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
logId: Number(item.id),
|
||||
userId: Number(item.user_id || 0) || null,
|
||||
username: String(item.username || '').trim(),
|
||||
role: String(item.role || '').trim(),
|
||||
ip: String(item.ip || '').trim(),
|
||||
location: String(item.location || '').trim(),
|
||||
userAgent: String(item.user_agent || '').trim(),
|
||||
success: item.success !== false,
|
||||
failureReason: String(item.failure_reason || '').trim(),
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveClientIp(req: Request) {
|
||||
const forwarded = String(req.headers['x-forwarded-for'] || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.find(Boolean)
|
||||
const realIp = String(req.headers['x-real-ip'] || '').trim()
|
||||
const ip = String(forwarded || realIp || req.ip || req.socket?.remoteAddress || '').trim()
|
||||
return ip.replace(/^::ffff:/, '')
|
||||
}
|
||||
|
||||
export function resolveUserAgent(req: Request) {
|
||||
return String(req.headers['user-agent'] || '').trim()
|
||||
}
|
||||
|
||||
/** 优先读常见代理/CDN 地理位置头,没有则留空。 */
|
||||
export function resolveClientLocation(req: Request) {
|
||||
const country = firstHeader(req, [
|
||||
'cf-ipcountry',
|
||||
'x-vercel-ip-country',
|
||||
'cloudfront-viewer-country',
|
||||
'x-country-code',
|
||||
])
|
||||
const city = firstHeader(req, [
|
||||
'cf-ipcity',
|
||||
'x-vercel-ip-city',
|
||||
'x-city',
|
||||
])
|
||||
const region = firstHeader(req, [
|
||||
'cf-region',
|
||||
'x-vercel-ip-country-region',
|
||||
'x-region',
|
||||
])
|
||||
|
||||
return [country, region, city].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function firstHeader(req: Request, names: string[]) {
|
||||
for (const name of names) {
|
||||
const value = String(req.headers[name] || '').trim()
|
||||
if (value && value.toUpperCase() !== 'XX') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user