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