feat(dcauth): 新增后台手机号与设备认证登录

登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、
登录日志、设备绑定与重置接口。
This commit is contained in:
yml2213
2026-10-21 20:46:24 +08:00
parent 7542d547bf
commit 040100f290
36 changed files with 3128 additions and 41 deletions
@@ -6,10 +6,12 @@ import {
countActiveAdminUsers,
createAdminUser,
getAdminUserById,
getAdminUserByPhone,
getAdminUserByUsername,
listAdminUsers,
updateAdminUser,
} from '../../repositories/admin-user-repo.js'
import { resetAdminUserDevice as resetAdminUserDeviceRecord } from '../../repositories/admin-device-repo.js'
import {
createAdminSession as createAdminSessionRecord,
getActiveAdminSession,
@@ -21,6 +23,12 @@ import { createHttpError } from '../../utils/http.js'
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
import { recordAdminLoginLog } from './admin-login-log-service.js'
import { listAdminPermissionKeysForRole } from './admin-rbac-service.js'
import {
consumeAdminDeviceLogin,
createAdminLoginDeviceChallenge,
ensureAdminDeviceBinding,
} from './admin-device-auth-service.js'
import { maskPhone, normalizeAdminPhone, verifyAdminLoginSmsCode } from './admin-sms-service.js'
type AdminUserRow = NonNullable<Awaited<ReturnType<typeof getAdminUserById>>>
@@ -32,6 +40,7 @@ export type AdminSession = {
role: AdminRole
expiresAt: string
sessionVersion: number
deviceId: string
permissions: string[]
}
@@ -123,10 +132,18 @@ export async function loginAdmin(
})
}
if (String(user.phone || '').trim() && user.test_account !== true) {
throw createHttpError('该账号已启用安全登录,请使用账号密码 + 短信验证码 + 本机验证', {
statusCode: 403,
errorCode: 'admin_device_auth_required',
})
}
const session = createAdminSession(user)
const createdSession = await createAdminSessionRecord({
sessionId: String(session.sessionId || ''),
userId: Number(user.id),
deviceId: '',
issuedAt: String(session.issuedAt),
expiresAt: String(session.expiresAt),
now: nowIso(),
@@ -142,11 +159,157 @@ export async function loginAdmin(
username: String(user.username || normalizedUsername),
role: normalizeAdminRole(user.role),
success: true,
loginMethod: user.test_account === true ? 'password_test' : 'password',
...pickLoginMeta(meta),
})
return session
}
export async function prepareAdminLogin(
usernameValue: unknown,
passwordValue: unknown,
smsCodeValue: unknown,
meta: {
ip?: string
userAgent?: string
location?: string
apiBase: string
},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const normalizedUsername = normalizeUsername(usernameValue)
const normalizedPassword = String(passwordValue || '').trim()
if (!normalizedUsername || !normalizedPassword) {
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',
})
}
if (!String(user.phone || '').trim()) {
throw createHttpError('该账号尚未绑定手机号,请联系管理员', {
statusCode: 409,
errorCode: 'admin_phone_unbound',
})
}
await verifyAdminLoginSmsCode(Number(user.id), smsCodeValue)
const challenge = await createAdminLoginDeviceChallenge(Number(user.id), meta.apiBase)
return {
...challenge,
phoneMasked: maskPhone(user.phone),
app: 'order_site',
}
}
export async function completeAdminLogin(
payload: JsonObject = {},
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const challengeId = String(payload.challengeId || '').trim()
const pollToken = String(payload.pollToken || '').trim()
const deviceTicket = String(payload.deviceTicket || '').trim()
if (!challengeId || !pollToken || !deviceTicket) {
throw createHttpError('缺少设备认证票据', {
statusCode: 400,
errorCode: 'admin_device_ticket_required',
})
}
const challenge = await getAdminDeviceChallengeForLogin(challengeId)
const user = await getAdminUserById(Number(challenge.user_id))
if (!user || user.status !== 'active') {
throw createHttpError('后台账号已不可用,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_user_invalid',
})
}
const consumed = await consumeAdminDeviceLogin(Number(user.id), {
challengeId,
pollToken,
deviceTicket,
})
await ensureAdminDeviceBinding(Number(user.id), consumed)
return createAdminLoginSession(user, consumed.device_id, meta, {
machineCodeMask: consumed.machine_code_mask,
})
}
async function createAdminLoginSession(
user: AdminUserRow,
deviceId: string,
meta: {
ip?: string
userAgent?: string
location?: string
},
extra: { machineCodeMask?: string } = {},
): Promise<JsonObject> {
const session = createAdminSession(user, deviceId)
const createdSession = await createAdminSessionRecord({
sessionId: String(session.sessionId || ''),
userId: Number(user.id),
deviceId,
issuedAt: String(session.issuedAt),
expiresAt: String(session.expiresAt),
now: nowIso(),
})
if (!createdSession) {
throw createHttpError('后台登录会话创建失败,请重试', {
statusCode: 503,
errorCode: 'admin_session_create_failed',
})
}
await recordAdminLoginLog({
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
success: true,
loginMethod: 'sms_device',
machineCodeMask: String(extra.machineCodeMask || ''),
...pickLoginMeta(meta),
})
return session
}
async function getAdminDeviceChallengeForLogin(challengeId: string) {
const { getAdminDeviceChallenge } = await import('../../repositories/admin-device-repo.js')
const challenge = await getAdminDeviceChallenge(challengeId)
if (!challenge || !challenge.user_id) {
throw createHttpError('设备认证会话不存在,请重新登录', {
statusCode: 404,
errorCode: 'admin_device_challenge_not_found',
})
}
return challenge
}
export async function verifyAdminSessionToken(token: unknown): Promise<AdminSession> {
ensureAdminAuthConfigured()
@@ -236,6 +399,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
role: normalizeAdminRole(user.role),
expiresAt,
sessionVersion: currentSessionVersion,
deviceId: String(activeSession.device_id || ''),
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
}
}
@@ -257,6 +421,7 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
username: session.username,
displayName: session.displayName,
role: session.role,
deviceId: session.deviceId,
permissions: session.permissions,
},
}
@@ -297,6 +462,7 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
const username = normalizeUsername(payload.username)
const displayName = normalizeDisplayName(payload.displayName, username) || username
const password = normalizePassword(payload.password)
const phone = normalizeAdminPhone(payload.phone)
if (!username) {
throw createHttpError('缺少后台账号', {
@@ -321,6 +487,12 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
errorCode: 'admin_user_exists',
})
}
if (phone && (await getAdminUserByPhone(phone))) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const created = await createAdminUser({
@@ -329,6 +501,10 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
passwordHash: hashAdminPassword(password),
role: normalizeAdminRole(payload.role),
status: normalizeAdminUserStatus(payload.status || 'active'),
phone,
phoneBoundAt: phone ? now : null,
phoneBoundBy: null,
testAccount: payload.testAccount === true,
createdAt: now,
updatedAt: now,
})
@@ -430,6 +606,92 @@ export async function updateManagedAdminUserDisplayName(
}
}
export async function updateManagedAdminUserPhone(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const phone = normalizeAdminPhone(payload.phone)
if (!phone) {
throw createHttpError('请输入手机号', {
statusCode: 400,
errorCode: 'admin_phone_required',
})
}
if (phone === String(user.phone || '').trim()) {
return { user: mapAdminUser(user) }
}
const existing = await getAdminUserByPhone(phone)
if (existing && Number(existing.id) !== Number(user.id)) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const updated = await updateAdminUser(user.id, {
phone,
phone_bound_at: now,
phone_bound_by: session.userId,
session_version: nextAdminSessionVersion(user),
updated_at: now,
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return { user: mapAdminUser(updated) }
}
export async function updateManagedAdminUserTestAccount(
userId: number | string,
payload: JsonObject = {},
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const testAccount = payload.testAccount === true
const updated = await updateAdminUser(user.id, {
test_account: testAccount,
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
testAccount,
}
}
export async function resetManagedAdminUserDevice(
userId: number | string,
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const reset = await resetAdminUserDeviceRecord(user.id, session.userId, nowIso())
const updated = await updateAdminUser(user.id, {
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
reset,
}
}
export async function resetManagedAdminUserPassword(
userId: number | string,
payload: JsonObject = {},
@@ -478,7 +740,7 @@ export function ensureAdminAuthConfigured(): void {
})
}
function createAdminSession(user: AdminUserRow): JsonObject {
function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
const issuedAt = nowIso()
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12))
const payload = {
@@ -486,6 +748,7 @@ function createAdminSession(user: AdminUserRow): JsonObject {
uid: Number(user.id),
usr: String(user.username || ''),
role: normalizeAdminRole(user.role),
dev: String(deviceId || ''),
ver: normalizeAdminSessionVersion(user.session_version),
iat: issuedAt,
exp: expiresAt,
@@ -513,7 +776,7 @@ export function hashAdminPassword(password: string): string {
return `scrypt$${salt}$${derived}`
}
function verifyAdminPassword(password: string, storedHash: string): boolean {
export function verifyAdminPassword(password: string, storedHash: string): boolean {
const [algorithm, salt, expectedHash] = String(storedHash || '').split('$')
if (algorithm !== 'scrypt' || !salt || !expectedHash) {
return false
@@ -586,6 +849,7 @@ function normalizeStatusQuery(status: unknown): string {
function normalizeUsername(username: unknown): string {
return String(username || '')
.trim()
.normalize('NFKC')
.toLowerCase()
}
@@ -615,8 +879,8 @@ function normalizePassword(password: unknown): string {
}
function validateUsername(username: string): void {
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
throw createHttpError('后台账号格式无效,需为 3-32 位字母数字或 ._-', {
if (!/^[\p{L}\p{N}._-]{2,32}$/u.test(username)) {
throw createHttpError('后台账号格式无效,需为 2-32 位中文、字母、数字或 ._-', {
statusCode: 400,
errorCode: 'admin_user_username_invalid',
})
@@ -688,11 +952,29 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
status: normalizeAdminUserStatus(user.status),
phone: String(user.phone || ''),
phoneMasked: maskPhone(user.phone),
phoneBoundAt: user.phone_bound_at || null,
testAccount: user.test_account === true,
deviceBound: Boolean(user.device_id),
deviceId: String(user.device_id || ''),
deviceIdMasked: maskDeviceId(user.device_id),
machineCodeMask: String(user.machine_code_mask || ''),
deviceBoundAt: user.device_bound_at || null,
deviceLastUsedAt: user.device_last_used_at || null,
createdAt: user.created_at,
updatedAt: user.updated_at,
}
}
function maskDeviceId(value: unknown): string {
const deviceId = String(value || '')
.trim()
.toUpperCase()
if (deviceId.length <= 8) return deviceId
return `${deviceId.slice(0, 6)}-****-${deviceId.slice(-4)}`
}
function pickLoginMeta(
meta: {
ip?: string