import crypto from 'node:crypto' import type { JsonObject } from '../../types/json.js' import { runtimeConfig } from '../../config/runtime.js' import { countActiveAdminUsers, createAdminUser, getAdminUserById, getAdminUserByUsername, listAdminUsers, updateAdminUser, } from '../../repositories/admin-user-repo.js' 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>> export type AdminSession = { sessionId: string userId: number username: string role: AdminRole expiresAt: string sessionVersion: number } type AdminRole = 'admin' | 'operator' | 'support' type AdminUserStatus = 'active' | 'disabled' export async function ensureAdminUsersBootstrapped(): Promise { ensureAdminAuthConfigured() const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : [] for (const configuredUser of configuredUsers) { const username = String(configuredUser?.username || '').trim().toLowerCase() const password = String(configuredUser?.password || '').trim() const role = normalizeAdminRole(configuredUser?.role) if (!username || !password) { continue } if (await getAdminUserByUsername(username)) { continue } const now = nowIso() await createAdminUser({ username, passwordHash: hashAdminPassword(password), role, status: 'active', createdAt: now, updatedAt: now, }) } } export async function loginAdmin( username: unknown, password: unknown, meta: { ip?: string userAgent?: string location?: string } = {}, ): Promise { 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', }) } 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', }) } 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 { ensureAdminAuthConfigured() const normalizedToken = String(token || '').trim() if (!normalizedToken) { throw createHttpError('未登录或登录已失效', { statusCode: 401, errorCode: 'admin_auth_required', }) } const [encodedPayload, signature] = normalizedToken.split('.') if (!encodedPayload || !signature) { throw createHttpError('后台登录态无效', { statusCode: 401, errorCode: 'admin_auth_invalid', }) } const expectedSignature = signPayload(encodedPayload) if (!safeCompare(signature, expectedSignature)) { throw createHttpError('后台登录态无效', { statusCode: 401, errorCode: 'admin_auth_invalid', }) } let payload: JsonObject | null = null try { payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) } catch { throw createHttpError('后台登录态无效', { statusCode: 401, errorCode: 'admin_auth_invalid', }) } const expiresAt = String(payload?.exp || '').trim() if (!expiresAt || Date.parse(expiresAt) <= Date.now()) { throw createHttpError('后台登录已过期,请重新登录', { statusCode: 401, errorCode: 'admin_auth_expired', }) } const userId = Number(payload?.uid || 0) const user = await getAdminUserById(userId) if (!user || user.status !== 'active') { throw createHttpError('后台账号已不可用,请重新登录', { statusCode: 401, errorCode: 'admin_auth_user_invalid', }) } const tokenSessionVersion = Number(payload?.ver || 0) const currentSessionVersion = normalizeAdminSessionVersion(user.session_version) if (tokenSessionVersion !== currentSessionVersion) { throw createHttpError('后台登录态已失效,请重新登录', { statusCode: 401, errorCode: 'admin_auth_stale', }) } return { sessionId: String(payload?.sid || '').trim(), userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), expiresAt, sessionVersion: currentSessionVersion, } } export async function getAdminSessionSummary(token: unknown): Promise { const session = await verifyAdminSessionToken(token) return { authenticated: true, expiresAt: session.expiresAt, user: { userId: session.userId, username: session.username, role: session.role, }, } } export function requireAdminRole(session: { role?: string } | null | undefined, allowedRoles: string[]): void { if (session && allowedRoles.includes(session.role || '')) { return } throw createHttpError('当前账号没有此操作权限', { statusCode: 403, errorCode: 'admin_permission_denied', }) } export async function getAdminUserList(query: JsonObject = {}): Promise { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listAdminUsers({ page, pageSize, username: String(query.username || '').trim(), role: normalizeRoleQuery(query.role), status: normalizeStatusQuery(query.status), }) return { items: items.map(mapAdminUser), pagination: { page, pageSize, total }, } } export async function createManagedAdminUser(payload: JsonObject = {}): Promise { const username = normalizeUsername(payload.username) const password = normalizePassword(payload.password) if (!username) { throw createHttpError('缺少后台账号', { statusCode: 400, errorCode: 'admin_user_username_required', }) } if (!password) { throw createHttpError('缺少后台密码', { statusCode: 400, errorCode: 'admin_user_password_required', }) } validateUsername(username) validatePassword(password) if (await getAdminUserByUsername(username)) { throw createHttpError('后台账号已存在', { statusCode: 409, errorCode: 'admin_user_exists', }) } const now = nowIso() const created = await createAdminUser({ username, passwordHash: hashAdminPassword(password), role: normalizeAdminRole(payload.role), status: normalizeAdminUserStatus(payload.status || 'active'), createdAt: now, updatedAt: now, }) if (!created) { throw createHttpError('后台用户创建失败', { statusCode: 500, errorCode: 'admin_user_create_failed', }) } return { user: mapAdminUser(created), } } export async function updateManagedAdminUserRole( userId: number | string, payload: JsonObject = {}, session: AdminSession, ): Promise { const user = await getRequiredAdminUser(userId) const role = normalizeAdminRole(payload.role) if (user.role === role) { return { user: mapAdminUser(user), } } await ensureAdminUserChangeAllowed(user, { nextRole: role }, session) const updated = await updateAdminUser(user.id, { role, session_version: nextAdminSessionVersion(user), updated_at: nowIso(), }) if (!updated) { throw createHttpError('后台用户更新失败', { statusCode: 500, errorCode: 'admin_user_update_failed', }) } return { user: mapAdminUser(updated), } } export async function updateManagedAdminUserStatus( userId: number | string, payload: JsonObject = {}, session: AdminSession, ): Promise { const user = await getRequiredAdminUser(userId) const status = normalizeAdminUserStatus(payload.status) if (user.status === status) { return { user: mapAdminUser(user), } } await ensureAdminUserChangeAllowed(user, { nextStatus: status }, session) const updated = await updateAdminUser(user.id, { status, session_version: nextAdminSessionVersion(user), updated_at: nowIso(), }) if (!updated) { throw createHttpError('后台用户更新失败', { statusCode: 500, errorCode: 'admin_user_update_failed', }) } return { user: mapAdminUser(updated), } } export async function resetManagedAdminUserPassword(userId: number | string, payload: JsonObject = {}): Promise { const user = await getRequiredAdminUser(userId) const password = normalizePassword(payload.password) if (!password) { throw createHttpError('缺少新密码', { statusCode: 400, errorCode: 'admin_user_password_required', }) } validatePassword(password) const updated = await updateAdminUser(user.id, { password_hash: hashAdminPassword(password), session_version: nextAdminSessionVersion(user), updated_at: nowIso(), }) if (!updated) { throw createHttpError('后台用户更新失败', { statusCode: 500, errorCode: 'admin_user_update_failed', }) } return { user: mapAdminUser(updated), } } export function ensureAdminAuthConfigured(): void { const sessionSecret = String(runtimeConfig.admin?.sessionSecret || '').trim() const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : [] if (sessionSecret && configuredUsers.length > 0) { return } throw createHttpError('后台鉴权未配置,请先设置 admin.sessionSecret 和 admin.defaultUsers', { statusCode: 503, errorCode: 'admin_auth_not_configured', }) } function createAdminSession(user: AdminUserRow): JsonObject { const issuedAt = nowIso() const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12)) const payload = { sid: crypto.randomBytes(12).toString('hex'), uid: Number(user.id), usr: String(user.username || ''), role: normalizeAdminRole(user.role), ver: normalizeAdminSessionVersion(user.session_version), iat: issuedAt, exp: expiresAt, } const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') const signature = signPayload(encodedPayload) return { token: `${encodedPayload}.${signature}`, expiresAt, user: { userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), }, } } export function hashAdminPassword(password: string): string { const salt = crypto.randomBytes(16).toString('hex') const derived = crypto.scryptSync(password, salt, 64).toString('hex') return `scrypt$${salt}$${derived}` } function verifyAdminPassword(password: string, storedHash: string): boolean { const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') if (algorithm !== 'scrypt' || !salt || !expectedHash) { return false } const actualHash = crypto.scryptSync(password, salt, 64).toString('hex') return safeCompare(actualHash, expectedHash) } function signPayload(encodedPayload: string): string { return crypto .createHmac('sha256', String(runtimeConfig.admin.sessionSecret || '')) .update(encodedPayload) .digest('base64url') } export function normalizeAdminRole(role: unknown): AdminRole { const normalized = String(role || '').trim().toLowerCase() if (normalized === 'admin') { return 'admin' } if (normalized === 'support') { return 'support' } return 'operator' } export function normalizeAdminUserStatus(status: unknown): AdminUserStatus { return String(status || '').trim().toLowerCase() === 'disabled' ? 'disabled' : 'active' } function safeCompare(input: unknown, expected: unknown): boolean { const left = Buffer.from(String(input || ''), 'utf8') const right = Buffer.from(String(expected || ''), 'utf8') if (left.length !== right.length) { return false } return crypto.timingSafeEqual(left, right) } function normalizeRoleQuery(role: unknown): string { const normalized = String(role || '').trim().toLowerCase() return ['admin', 'operator', 'support'].includes(normalized) ? normalized : '' } function normalizeStatusQuery(status: unknown): string { const normalized = String(status || '').trim().toLowerCase() return ['active', 'disabled'].includes(normalized) ? normalized : '' } function normalizeUsername(username: unknown): string { return String(username || '').trim().toLowerCase() } function normalizePassword(password: unknown): string { return String(password || '').trim() } function validateUsername(username: string): void { if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) { throw createHttpError('后台账号格式无效,需为 3-32 位字母数字或 ._-', { statusCode: 400, errorCode: 'admin_user_username_invalid', }) } } function validatePassword(password: string): void { if (password.length < 8) { throw createHttpError('后台密码至少 8 位', { statusCode: 400, errorCode: 'admin_user_password_invalid', }) } } function normalizeAdminSessionVersion(value: unknown): number { const parsed = Number(value) return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 } function nextAdminSessionVersion(user: AdminUserRow): number { return normalizeAdminSessionVersion(user.session_version) + 1 } async function getRequiredAdminUser(userId: number | string): Promise { const user = await getAdminUserById(Number(userId)) if (!user) { throw createHttpError('后台用户不存在', { statusCode: 404, errorCode: 'admin_user_not_found', }) } return user } async function ensureAdminUserChangeAllowed( user: AdminUserRow, options: { nextRole?: AdminRole, nextStatus?: AdminUserStatus } = {}, session: AdminSession, ): Promise { const nextRole = options.nextRole || user.role const nextStatus = options.nextStatus || user.status if (session?.userId === Number(user.id) && (nextRole !== 'admin' || nextStatus !== 'active')) { throw createHttpError('不能停用或降级当前登录账号', { statusCode: 409, errorCode: 'admin_user_self_change_not_allowed', }) } if (user.role === 'admin' && (nextRole !== 'admin' || nextStatus !== 'active') && await countActiveAdminUsers() <= 1) { throw createHttpError('至少保留一个启用中的管理员账号', { statusCode: 409, errorCode: 'admin_user_last_admin_not_allowed', }) } } function mapAdminUser(user: AdminUserRow): JsonObject { return { userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), status: normalizeAdminUserStatus(user.status), createdAt: user.created_at, 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 }