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
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
authorizeAdminDeviceChallenge,
|
||||
bindAdminUserDevice,
|
||||
consumeAdminDeviceTicket,
|
||||
createAdminDeviceChallenge,
|
||||
getActiveAdminUserDevice,
|
||||
getAdminDeviceChallenge,
|
||||
issueAdminDeviceTicket,
|
||||
touchAdminUserDevice,
|
||||
type AdminDeviceChallengeRow,
|
||||
type AdminUserDeviceRow,
|
||||
} from '../../repositories/admin-device-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
export const ADMIN_DEVICE_APP = 'order_site'
|
||||
const CHALLENGE_TTL_SECONDS = 120
|
||||
const MAX_CLOCK_SKEW_SECONDS = 300
|
||||
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||
|
||||
export type AdminDeviceChallengeResult = {
|
||||
challengeId: string
|
||||
challenge: string
|
||||
expiresAt: string
|
||||
pollToken: string
|
||||
launchUrl: string
|
||||
}
|
||||
|
||||
export async function createAdminLoginDeviceChallenge(
|
||||
userId: number,
|
||||
apiBase: string,
|
||||
): Promise<AdminDeviceChallengeResult> {
|
||||
const challengeId = crypto.randomUUID()
|
||||
const challenge = crypto.randomBytes(32).toString('base64url')
|
||||
const pollToken = crypto.randomBytes(32).toString('base64url')
|
||||
const now = nowIso()
|
||||
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_SECONDS * 1000).toISOString()
|
||||
|
||||
const created = await createAdminDeviceChallenge({
|
||||
challengeId,
|
||||
app: ADMIN_DEVICE_APP,
|
||||
purpose: 'login',
|
||||
challenge,
|
||||
pollTokenHash: hashToken(pollToken),
|
||||
userId,
|
||||
expiresAt,
|
||||
now,
|
||||
})
|
||||
if (!created) {
|
||||
throw createHttpError('创建设备认证会话失败,请重试', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_device_challenge_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
challengeId,
|
||||
challenge,
|
||||
expiresAt,
|
||||
pollToken,
|
||||
launchUrl: buildLaunchUrl(apiBase, challengeId, challenge),
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeAdminDevice(payload: Record<string, unknown>): Promise<void> {
|
||||
const app = String(payload.app || '').trim()
|
||||
if (app !== ADMIN_DEVICE_APP) {
|
||||
throw createHttpError('设备认证应用不匹配', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_app_mismatch',
|
||||
})
|
||||
}
|
||||
|
||||
const challengeId = String(payload.challengeId || '').trim()
|
||||
const challengeValue = String(payload.challenge || '').trim()
|
||||
const deviceId = String(payload.deviceId || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const machineCode = String(payload.machineCode || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const publicKey = String(payload.publicKey || '').trim()
|
||||
const signature = String(payload.signature || '').trim()
|
||||
const timestamp = Number(payload.timestamp || 0)
|
||||
const clientVersion = String(payload.clientVersion || '')
|
||||
.trim()
|
||||
.slice(0, 32)
|
||||
|
||||
if (!challengeId || !challengeValue || !deviceId || !machineCode || !publicKey || !signature) {
|
||||
throw createHttpError('设备认证参数不完整', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_payload_invalid',
|
||||
})
|
||||
}
|
||||
if (!/^D1-[A-Z0-9-]{8,64}$/.test(deviceId)) {
|
||||
throw createHttpError('设备码格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_id_invalid',
|
||||
})
|
||||
}
|
||||
if (!/^M1-[A-Z0-9-]{8,64}$/.test(machineCode)) {
|
||||
throw createHttpError('机器码格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_machine_code_invalid',
|
||||
})
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(timestamp) ||
|
||||
Math.abs(Date.now() / 1000 - timestamp) > MAX_CLOCK_SKEW_SECONDS
|
||||
) {
|
||||
throw createHttpError('设备时间与服务器偏差过大,请校准系统时间', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_clock_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const challenge = await getAdminDeviceChallenge(challengeId)
|
||||
if (!challenge || challenge.app !== ADMIN_DEVICE_APP || challenge.purpose !== 'login') {
|
||||
throw createHttpError('设备认证会话不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_device_challenge_not_found',
|
||||
})
|
||||
}
|
||||
if (!safeEqual(challenge.challenge, challengeValue)) {
|
||||
throw createHttpError('设备认证 challenge 不匹配', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_challenge_mismatch',
|
||||
})
|
||||
}
|
||||
if (challenge.status !== 'pending' || new Date(challenge.expires_at).getTime() <= Date.now()) {
|
||||
throw createHttpError('设备认证会话已失效,请重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_device_challenge_expired',
|
||||
})
|
||||
}
|
||||
|
||||
const publicKeyBytes = decodeBase64Url(publicKey)
|
||||
const signatureBytes = decodeBase64Url(signature)
|
||||
if (publicKeyBytes.length !== 32 || signatureBytes.length !== 64) {
|
||||
throw createHttpError('设备公钥或签名格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_signature_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const canonical = [
|
||||
'dcauth.v1',
|
||||
app,
|
||||
challengeId,
|
||||
challengeValue,
|
||||
deviceId,
|
||||
machineCode,
|
||||
String(timestamp),
|
||||
].join('\n')
|
||||
const keyObject = crypto.createPublicKey({
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
})
|
||||
const verified = crypto.verify(null, Buffer.from(canonical, 'utf8'), keyObject, signatureBytes)
|
||||
if (!verified) {
|
||||
throw createHttpError('设备签名校验失败', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_device_signature_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const authorized = await authorizeAdminDeviceChallenge({
|
||||
challengeId,
|
||||
app,
|
||||
deviceId,
|
||||
publicKey,
|
||||
machineCodeHash: hashMachineCode(machineCode),
|
||||
machineCodeMask: maskMachineCode(machineCode),
|
||||
clientVersion,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!authorized) {
|
||||
throw createHttpError('设备认证会话已被使用,请重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_device_challenge_used',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminDeviceChallengeStatus(
|
||||
challengeIdValue: unknown,
|
||||
pollTokenValue: unknown,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const challengeId = String(challengeIdValue || '').trim()
|
||||
const pollToken = String(pollTokenValue || '').trim()
|
||||
if (!challengeId || !pollToken) {
|
||||
throw createHttpError('缺少设备认证参数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_poll_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const challenge = await getAdminDeviceChallenge(challengeId)
|
||||
if (!challenge) {
|
||||
return { status: 'rejected', message: '设备认证会话不存在' }
|
||||
}
|
||||
if (!safeEqual(challenge.poll_token_hash, hashToken(pollToken))) {
|
||||
throw createHttpError('设备认证轮询密钥无效', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_device_poll_forbidden',
|
||||
})
|
||||
}
|
||||
if (new Date(challenge.expires_at).getTime() <= Date.now() && challenge.status !== 'consumed') {
|
||||
return { status: 'expired', message: '设备认证会话已过期' }
|
||||
}
|
||||
if (challenge.status === 'authorized') {
|
||||
const ticket = crypto.randomBytes(32).toString('base64url')
|
||||
const issued = await issueAdminDeviceTicket({
|
||||
challengeId,
|
||||
ticketHash: hashToken(ticket),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (issued) {
|
||||
return {
|
||||
status: 'authorized',
|
||||
deviceTicket: ticket,
|
||||
deviceId: challenge.device_id,
|
||||
machineCodeMask: challenge.machine_code_mask,
|
||||
}
|
||||
}
|
||||
const latest = await getAdminDeviceChallenge(challengeId)
|
||||
return { status: latest?.status || 'pending' }
|
||||
}
|
||||
return {
|
||||
status: challenge.status,
|
||||
deviceId: challenge.device_id || '',
|
||||
machineCodeMask: challenge.machine_code_mask || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeAdminDeviceLogin(
|
||||
userId: number,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<AdminDeviceChallengeRow> {
|
||||
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 consumeAdminDeviceTicket({
|
||||
challengeId,
|
||||
pollTokenHash: hashToken(pollToken),
|
||||
ticketHash: hashToken(deviceTicket),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!challenge || Number(challenge.user_id) !== Number(userId)) {
|
||||
throw createHttpError('设备认证票据无效或已使用,请重新登录', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_device_ticket_invalid',
|
||||
})
|
||||
}
|
||||
return challenge
|
||||
}
|
||||
|
||||
export async function ensureAdminDeviceBinding(
|
||||
userId: number,
|
||||
challenge: AdminDeviceChallengeRow,
|
||||
): Promise<AdminUserDeviceRow> {
|
||||
const current = await getActiveAdminUserDevice(userId)
|
||||
if (current) {
|
||||
if (
|
||||
!safeEqual(current.device_id, challenge.device_id) ||
|
||||
!safeEqual(current.public_key, challenge.public_key)
|
||||
) {
|
||||
throw createHttpError('当前设备与账号绑定设备不一致,请联系管理员重置设备', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_device_mismatch',
|
||||
})
|
||||
}
|
||||
await touchAdminUserDevice(current.device_id, nowIso())
|
||||
return current
|
||||
}
|
||||
|
||||
const bound = await bindAdminUserDevice({
|
||||
userId,
|
||||
deviceId: challenge.device_id,
|
||||
publicKey: challenge.public_key,
|
||||
machineCodeHash: challenge.machine_code_hash,
|
||||
machineCodeMask: challenge.machine_code_mask,
|
||||
clientVersion: challenge.client_version,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!bound) {
|
||||
throw createHttpError('绑定本机设备失败,请重试', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_device_bind_failed',
|
||||
})
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
export function maskMachineCode(machineCode: string): string {
|
||||
const normalized = String(machineCode || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (normalized.length <= 10) return normalized
|
||||
return `${normalized.slice(0, 7)}-****-****-${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
export function hashMachineCode(machineCode: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || 'dcauth'))
|
||||
.update(
|
||||
String(machineCode || '')
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function hashToken(value: string): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(String(value || ''))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): Buffer {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
||||
throw createHttpError('设备签名编码无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_device_signature_invalid',
|
||||
})
|
||||
}
|
||||
return Buffer.from(value, 'base64url')
|
||||
}
|
||||
|
||||
function safeEqual(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 buildLaunchUrl(apiBase: string, challengeId: string, challenge: string): string {
|
||||
const query = new URLSearchParams({
|
||||
app: ADMIN_DEVICE_APP,
|
||||
challenge_id: challengeId,
|
||||
challenge,
|
||||
api: apiBase,
|
||||
})
|
||||
return `dcauth://authorize?${query.toString()}`
|
||||
}
|
||||
@@ -15,6 +15,8 @@ type RecordAdminLoginInput = {
|
||||
ip?: string
|
||||
location?: string
|
||||
userAgent?: string
|
||||
loginMethod?: string
|
||||
machineCodeMask?: string
|
||||
}
|
||||
|
||||
export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
|
||||
@@ -28,6 +30,8 @@ export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
|
||||
userAgent: String(input.userAgent || '').trim(),
|
||||
success: input.success !== false,
|
||||
failureReason: String(input.failureReason || '').trim(),
|
||||
loginMethod: String(input.loginMethod || 'password').trim() || 'password',
|
||||
machineCodeMask: String(input.machineCodeMask || '').trim(),
|
||||
createdAt: nowIso(),
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -74,6 +78,8 @@ export async function getAdminLoginLogs(query: JsonObject = {}) {
|
||||
userAgent: String(item.user_agent || '').trim(),
|
||||
success: item.success !== false,
|
||||
failureReason: String(item.failure_reason || '').trim(),
|
||||
loginMethod: String(item.login_method || 'password').trim(),
|
||||
machineCodeMask: String(item.machine_code_mask || '').trim(),
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
pagination: { page, pageSize, total },
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { getAdminUserByUsername } from '../../repositories/admin-user-repo.js'
|
||||
import {
|
||||
consumeAdminSmsCode,
|
||||
countAdminSmsCodesOnDay,
|
||||
createAdminSmsCode,
|
||||
getLatestAdminSmsCode,
|
||||
recordAdminSmsCodeFailure,
|
||||
} from '../../repositories/admin-sms-code-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getSmsProvider } from '../sms/index.js'
|
||||
import { verifyAdminPassword } from './admin-auth-service.js'
|
||||
import { recordAdminLoginLog } from './admin-login-log-service.js'
|
||||
|
||||
const MAX_VERIFY_ATTEMPTS = 5
|
||||
const SMS_PURPOSE = 'login'
|
||||
|
||||
export type AdminSmsMeta = {
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
export async function sendAdminLoginSmsCode(
|
||||
usernameValue: unknown,
|
||||
passwordValue: unknown,
|
||||
meta: AdminSmsMeta = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const username = normalizeAdminUsername(usernameValue)
|
||||
const password = String(passwordValue || '').trim()
|
||||
if (!username || !password) {
|
||||
throw createHttpError('缺少账号或密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_credentials_required',
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getAdminUserByUsername(username)
|
||||
if (
|
||||
!user ||
|
||||
user.status !== 'active' ||
|
||||
!verifyAdminPassword(password, String(user.password_hash || ''))
|
||||
) {
|
||||
await recordAdminLoginLog({
|
||||
userId: user ? Number(user.id) : null,
|
||||
username,
|
||||
role: user ? String(user.role || '') : '',
|
||||
success: false,
|
||||
failureReason: 'invalid_credentials',
|
||||
...pickMeta(meta),
|
||||
})
|
||||
throw createHttpError('账号或密码错误', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_login_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const phone = normalizeAdminPhone(user.phone)
|
||||
if (!phone) {
|
||||
throw createHttpError('该账号尚未绑定手机号,请联系管理员', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_phone_unbound',
|
||||
})
|
||||
}
|
||||
|
||||
const config = runtimeConfig.worker.sms
|
||||
const cooldownMs = Math.max(10, Number(config.cooldownSeconds || 60)) * 1000
|
||||
const dailyLimit = Math.max(1, Number(config.dailyLimit || 10))
|
||||
const ttlSeconds = Math.max(60, Number(config.codeTtlSeconds || 300))
|
||||
const latest = await getLatestAdminSmsCode(Number(user.id), SMS_PURPOSE)
|
||||
if (
|
||||
latest &&
|
||||
!latest.consumed_at &&
|
||||
new Date(latest.created_at).getTime() > Date.now() - cooldownMs
|
||||
) {
|
||||
throw createHttpError('验证码发送过于频繁,请稍后再试', {
|
||||
statusCode: 429,
|
||||
errorCode: 'admin_sms_cooldown',
|
||||
})
|
||||
}
|
||||
|
||||
const dayRange = resolveChinaDayRange()
|
||||
if (
|
||||
(await countAdminSmsCodesOnDay(Number(user.id), dayRange.start, dayRange.end)) >= dailyLimit
|
||||
) {
|
||||
throw createHttpError('今日验证码发送次数已达上限,请明天再试', {
|
||||
statusCode: 429,
|
||||
errorCode: 'admin_sms_daily_limit',
|
||||
})
|
||||
}
|
||||
|
||||
const code = generateSmsCode()
|
||||
await getSmsProvider().sendCode(phone, code)
|
||||
await createAdminSmsCode({
|
||||
userId: Number(user.id),
|
||||
phone,
|
||||
purpose: SMS_PURPOSE,
|
||||
codeHash: hashSmsCode(code),
|
||||
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
||||
requestIp: String(meta.ip || '').trim(),
|
||||
now: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
phoneMasked: maskPhone(phone),
|
||||
expiresInSeconds: ttlSeconds,
|
||||
provider: String(config.provider || 'mock'),
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAdminLoginSmsCode(userId: number, codeValue: unknown): Promise<void> {
|
||||
const code = String(codeValue || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('请输入短信验证码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_sms_code_required',
|
||||
})
|
||||
}
|
||||
|
||||
const latest = await getLatestAdminSmsCode(userId, SMS_PURPOSE)
|
||||
const valid = Boolean(
|
||||
latest &&
|
||||
!latest.consumed_at &&
|
||||
new Date(latest.expires_at).getTime() > Date.now() &&
|
||||
latest.code_hash === hashSmsCode(code),
|
||||
)
|
||||
if (!valid) {
|
||||
if (latest && !latest.consumed_at) {
|
||||
const failed = await recordAdminSmsCodeFailure(latest.id, nowIso(), MAX_VERIFY_ATTEMPTS)
|
||||
if (failed?.consumed_at) {
|
||||
throw createHttpError('验证码错误次数过多,请重新获取验证码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_sms_code_locked',
|
||||
})
|
||||
}
|
||||
}
|
||||
throw createHttpError('短信验证码错误或已过期', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_sms_code_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
if (!(await consumeAdminSmsCode(latest!.id, nowIso()))) {
|
||||
throw createHttpError('验证码已失效,请重新获取', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_sms_code_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAdminPhone(value: unknown): string {
|
||||
const phone = String(value || '').trim()
|
||||
if (!phone) return ''
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw createHttpError('手机号格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_phone_invalid',
|
||||
})
|
||||
}
|
||||
return phone
|
||||
}
|
||||
|
||||
export function maskPhone(phoneValue: unknown): string {
|
||||
const phone = String(phoneValue || '').trim()
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) return phone
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
}
|
||||
|
||||
export function hashSmsCode(code: string): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(String(code || ''))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function generateSmsCode(): string {
|
||||
let code = ''
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
code += String(crypto.randomInt(10))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
function normalizeAdminUsername(value: unknown): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function resolveChinaDayRange(): { start: string; end: string } {
|
||||
const now = new Date()
|
||||
const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
const year = chinaNow.getUTCFullYear()
|
||||
const month = chinaNow.getUTCMonth()
|
||||
const day = chinaNow.getUTCDate()
|
||||
const start = new Date(Date.UTC(year, month, day) - 8 * 60 * 60 * 1000)
|
||||
const end = new Date(Date.UTC(year, month, day + 1) - 8 * 60 * 60 * 1000)
|
||||
return { start: start.toISOString(), end: end.toISOString() }
|
||||
}
|
||||
|
||||
function pickMeta(meta: AdminSmsMeta) {
|
||||
const result: AdminSmsMeta = {}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user