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 { 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): Promise { 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> { 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, ): Promise { 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 { 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()}` }