feat(dcauth): 新增后台手机号与设备认证登录
登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、 登录日志、设备绑定与重置接口。
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user