358 lines
11 KiB
TypeScript
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()}`
|
|
}
|