581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
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<Awaited<ReturnType<typeof getAdminUserById>>>
|
|
|
|
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<void> {
|
|
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<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',
|
|
})
|
|
}
|
|
|
|
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<AdminSession> {
|
|
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<JsonObject> {
|
|
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<JsonObject> {
|
|
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<JsonObject> {
|
|
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<JsonObject> {
|
|
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<JsonObject> {
|
|
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<JsonObject> {
|
|
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<AdminUserRow> {
|
|
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<void> {
|
|
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
|
|
}
|