Files
order_site/apps/backend/src/services/admin/admin-device-auth-service.ts
T
yml2213 040100f290 feat(dcauth): 新增后台手机号与设备认证登录
登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、
登录日志、设备绑定与重置接口。
2026-10-21 20:46:24 +08:00

358 lines
11 KiB
TypeScript

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()}`
}