feat(dcauth): 移除机器码,改用纯设备码认证

- DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426
- 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示
- 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件
- Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态
- 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起
This commit is contained in:
yml2213
2026-10-21 20:46:25 +08:00
parent cb916d7750
commit ef36453694
28 changed files with 355 additions and 326 deletions
@@ -52,8 +52,6 @@ CREATE TABLE IF NOT EXISTS admin_user_devices (
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
public_key TEXT NOT NULL,
machine_code_hash TEXT NOT NULL,
machine_code_mask TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
bound_at TIMESTAMPTZ NOT NULL,
@@ -81,8 +79,6 @@ CREATE TABLE IF NOT EXISTS admin_device_challenges (
user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
device_id TEXT NOT NULL DEFAULT '',
public_key TEXT NOT NULL DEFAULT '',
machine_code_hash TEXT NOT NULL DEFAULT '',
machine_code_mask TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
device_ticket_hash TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
@@ -100,8 +96,6 @@ CREATE INDEX IF NOT EXISTS idx_admin_device_challenges_user
ALTER TABLE admin_login_logs
ADD COLUMN IF NOT EXISTS login_method TEXT NOT NULL DEFAULT 'password';
ALTER TABLE admin_login_logs
ADD COLUMN IF NOT EXISTS machine_code_mask TEXT NOT NULL DEFAULT '';
ALTER TABLE admin_sessions
ADD COLUMN IF NOT EXISTS device_id TEXT NOT NULL DEFAULT '';
@@ -109,8 +103,7 @@ ALTER TABLE admin_sessions
COMMENT ON COLUMN admin_users.phone IS '后台账号绑定手机号,用于登录短信验证码';
COMMENT ON COLUMN admin_users.test_account IS '技术测试账号:允许仅账号密码登录,跳过短信和 DCAuth 设备验证';
COMMENT ON TABLE admin_sms_codes IS '后台登录短信验证码(哈希存储、一次性消费)';
COMMENT ON TABLE admin_user_devices IS '后台账号绑定的 DCAuth 设备(公钥 + 机器码哈希)';
COMMENT ON TABLE admin_user_devices IS '后台账号绑定的 DCAuth 设备(公钥指纹)';
COMMENT ON TABLE admin_device_challenges IS 'DCAuth 设备认证 challenge 与一次性 ticket';
COMMENT ON COLUMN admin_login_logs.login_method IS 'password / sms_device';
COMMENT ON COLUMN admin_login_logs.machine_code_mask IS '登录设备机器码脱敏值';
COMMENT ON COLUMN admin_sessions.device_id IS '登录会话绑定的设备 ID';
@@ -11,8 +11,6 @@ export type AdminDeviceChallengeRow = {
user_id: number | null
device_id: string
public_key: string
machine_code_hash: string
machine_code_mask: string
client_version: string
device_ticket_hash: string
expires_at: string
@@ -28,8 +26,6 @@ export type AdminUserDeviceRow = {
user_id: number
device_id: string
public_key: string
machine_code_hash: string
machine_code_mask: string
client_version: string
status: string
bound_at: string
@@ -87,8 +83,6 @@ export async function authorizeAdminDeviceChallenge(input: {
app: string
deviceId: string
publicKey: string
machineCodeHash: string
machineCodeMask: string
clientVersion: string
now: string
}): Promise<AdminDeviceChallengeRow | null> {
@@ -99,27 +93,16 @@ export async function authorizeAdminDeviceChallenge(input: {
status = 'authorized',
device_id = $3,
public_key = $4,
machine_code_hash = $5,
machine_code_mask = $6,
client_version = $7,
authorized_at = $8,
updated_at = $8
client_version = $5,
authorized_at = $6,
updated_at = $6
WHERE challenge_id = $1
AND app = $2
AND status = 'pending'
AND expires_at > $8
AND expires_at > $6
RETURNING *
`,
[
input.challengeId,
input.app,
input.deviceId,
input.publicKey,
input.machineCodeHash,
input.machineCodeMask,
input.clientVersion,
input.now,
],
[input.challengeId, input.app, input.deviceId, input.publicKey, input.clientVersion, input.now],
)
return result.rows[0] || null
}
@@ -188,38 +171,26 @@ export async function bindAdminUserDevice(input: {
userId: number
deviceId: string
publicKey: string
machineCodeHash: string
machineCodeMask: string
clientVersion: string
now: string
}): Promise<AdminUserDeviceRow | null> {
const result = await query<AdminUserDeviceRow>(
`
INSERT INTO admin_user_devices (
user_id, device_id, public_key, machine_code_hash, machine_code_mask,
client_version, status, bound_at, last_used_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7, $7, $7)
user_id, device_id, public_key, client_version, status,
bound_at, last_used_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, 'active', $5, $5, $5, $5)
ON CONFLICT (user_id) WHERE status = 'active'
DO UPDATE SET
device_id = EXCLUDED.device_id,
public_key = EXCLUDED.public_key,
machine_code_hash = EXCLUDED.machine_code_hash,
machine_code_mask = EXCLUDED.machine_code_mask,
client_version = EXCLUDED.client_version,
bound_at = EXCLUDED.bound_at,
last_used_at = EXCLUDED.last_used_at,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
input.userId,
input.deviceId,
input.publicKey,
input.machineCodeHash,
input.machineCodeMask,
input.clientVersion,
input.now,
],
[input.userId, input.deviceId, input.publicKey, input.clientVersion, input.now],
)
return result.rows[0] || null
}
@@ -13,7 +13,6 @@ export type AdminLoginLogRow = {
success: boolean
failure_reason: string
login_method: string
machine_code_mask: string
created_at: string
}
@@ -27,7 +26,6 @@ type AdminLoginLogCreateInput = {
success?: boolean
failureReason?: string
loginMethod?: string
machineCodeMask?: string
createdAt: string
}
@@ -54,9 +52,8 @@ export async function createAdminLoginLog(
success,
failure_reason,
login_method,
machine_code_mask,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`,
[
@@ -69,7 +66,6 @@ export async function createAdminLoginLog(
input.success !== false,
String(input.failureReason || '').trim(),
String(input.loginMethod || 'password').trim() || 'password',
String(input.machineCodeMask || '').trim(),
input.createdAt,
],
)
@@ -15,7 +15,6 @@ type AdminUserRow = {
created_at: string
updated_at: string
device_id?: string | null
machine_code_mask?: string | null
device_bound_at?: string | null
device_last_used_at?: string | null
}
@@ -68,7 +67,6 @@ const ADMIN_USER_SELECT = `
SELECT
au.*,
d.device_id AS device_id,
d.machine_code_mask AS machine_code_mask,
d.bound_at AS device_bound_at,
d.last_used_at AS device_last_used_at
FROM admin_users au
@@ -256,9 +256,7 @@ export async function completeAdminLogin(
})
await ensureAdminDeviceBinding(Number(user.id), consumed)
return createAdminLoginSession(user, consumed.device_id, meta, {
machineCodeMask: consumed.machine_code_mask,
})
return createAdminLoginSession(user, consumed.device_id, meta)
}
async function createAdminLoginSession(
@@ -269,7 +267,6 @@ async function createAdminLoginSession(
userAgent?: string
location?: string
},
extra: { machineCodeMask?: string } = {},
): Promise<JsonObject> {
const session = createAdminSession(user, deviceId)
const createdSession = await createAdminSessionRecord({
@@ -292,7 +289,6 @@ async function createAdminLoginSession(
role: normalizeAdminRole(user.role),
success: true,
loginMethod: 'sms_device',
machineCodeMask: String(extra.machineCodeMask || ''),
...pickLoginMeta(meta),
})
return session
@@ -959,7 +955,6 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
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,
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildAdminDeviceCanonical,
isSupportedAdminDeviceClientVersion,
} from './admin-device-auth-service.js'
test('DCAuth v2 签名原文只包含设备码,不包含机器码', () => {
const canonical = buildAdminDeviceCanonical({
app: 'order_site',
challengeId: 'challenge-id',
challenge: 'challenge-value',
deviceId: 'D1-ABCD-EFGH-JKLM-NPQR',
timestamp: 1_700_000_000,
})
assert.equal(
canonical,
'dcauth.v2\norder_site\nchallenge-id\nchallenge-value\nD1-ABCD-EFGH-JKLM-NPQR\n1700000000',
)
assert.equal(canonical.includes('M1-'), false)
assert.equal(canonical.toLowerCase().includes('machine'), false)
})
test('DCAuth v2 及以上客户端才能使用新协议', () => {
assert.equal(isSupportedAdminDeviceClientVersion('2.0.0'), true)
assert.equal(isSupportedAdminDeviceClientVersion('2.3.1'), true)
assert.equal(isSupportedAdminDeviceClientVersion('1.1.1'), false)
assert.equal(isSupportedAdminDeviceClientVersion(''), false)
})
@@ -1,6 +1,5 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import {
authorizeAdminDeviceChallenge,
bindAdminUserDevice,
@@ -79,9 +78,6 @@ export async function authorizeAdminDevice(payload: Record<string, unknown>): Pr
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)
@@ -89,24 +85,24 @@ export async function authorizeAdminDevice(payload: Record<string, unknown>): Pr
.trim()
.slice(0, 32)
if (!challengeId || !challengeValue || !deviceId || !machineCode || !publicKey || !signature) {
if (!challengeId || !challengeValue || !deviceId || !publicKey || !signature) {
throw createHttpError('设备认证参数不完整', {
statusCode: 400,
errorCode: 'admin_device_payload_invalid',
})
}
if (!isSupportedAdminDeviceClientVersion(clientVersion)) {
throw createHttpError('DCAuth 客户端版本过低,请升级到 v2.0.0 或更高版本', {
statusCode: 426,
errorCode: 'admin_device_client_outdated',
})
}
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
@@ -146,15 +142,13 @@ export async function authorizeAdminDevice(payload: Record<string, unknown>): Pr
})
}
const canonical = [
'dcauth.v1',
const canonical = buildAdminDeviceCanonical({
app,
challengeId,
challengeValue,
challenge: challengeValue,
deviceId,
machineCode,
String(timestamp),
].join('\n')
timestamp,
})
const keyObject = crypto.createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]),
format: 'der',
@@ -173,8 +167,6 @@ export async function authorizeAdminDevice(payload: Record<string, unknown>): Pr
app,
deviceId,
publicKey,
machineCodeHash: hashMachineCode(machineCode),
machineCodeMask: maskMachineCode(machineCode),
clientVersion,
now: nowIso(),
})
@@ -224,7 +216,6 @@ export async function getAdminDeviceChallengeStatus(
status: 'authorized',
deviceTicket: ticket,
deviceId: challenge.device_id,
machineCodeMask: challenge.machine_code_mask,
}
}
const latest = await getAdminDeviceChallenge(challengeId)
@@ -233,7 +224,6 @@ export async function getAdminDeviceChallengeStatus(
return {
status: challenge.status,
deviceId: challenge.device_id || '',
machineCodeMask: challenge.machine_code_mask || '',
}
}
@@ -289,8 +279,6 @@ export async function ensureAdminDeviceBinding(
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(),
})
@@ -303,23 +291,26 @@ export async function ensureAdminDeviceBinding(
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 buildAdminDeviceCanonical(input: {
app: string
challengeId: string
challenge: string
deviceId: string
timestamp: number
}): string {
return [
'dcauth.v2',
input.app,
input.challengeId,
input.challenge,
input.deviceId,
String(input.timestamp),
].join('\n')
}
export function hashMachineCode(machineCode: string): string {
return crypto
.createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || 'dcauth'))
.update(
String(machineCode || '')
.trim()
.toUpperCase(),
)
.digest('hex')
export function isSupportedAdminDeviceClientVersion(clientVersion: string): boolean {
const major = Number(String(clientVersion || '').split('.')[0])
return Number.isInteger(major) && major >= 2
}
function hashToken(value: string): string {
@@ -16,7 +16,6 @@ type RecordAdminLoginInput = {
location?: string
userAgent?: string
loginMethod?: string
machineCodeMask?: string
}
export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
@@ -31,7 +30,6 @@ export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
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) {
@@ -79,7 +77,6 @@ export async function getAdminLoginLogs(query: JsonObject = {}) {
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,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { shouldEnforceAdminSmsDailyLimit } from './admin-sms-service.js'
test('Mock 短信不启用后台登录日限额', () => {
assert.equal(shouldEnforceAdminSmsDailyLimit('mock'), false)
assert.equal(shouldEnforceAdminSmsDailyLimit(' MOCK '), false)
assert.equal(shouldEnforceAdminSmsDailyLimit(undefined), false)
})
test('真实短信仍启用后台登录日限额', () => {
assert.equal(shouldEnforceAdminSmsDailyLimit('aliyun'), true)
})
@@ -67,6 +67,9 @@ export async function sendAdminLoginSmsCode(
}
const config = runtimeConfig.worker.sms
const provider = String(config.provider || 'mock')
.trim()
.toLowerCase()
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))
@@ -84,6 +87,7 @@ export async function sendAdminLoginSmsCode(
const dayRange = resolveChinaDayRange()
if (
shouldEnforceAdminSmsDailyLimit(provider) &&
(await countAdminSmsCodesOnDay(Number(user.id), dayRange.start, dayRange.end)) >= dailyLimit
) {
throw createHttpError('今日验证码发送次数已达上限,请明天再试', {
@@ -185,6 +189,14 @@ function generateSmsCode(): string {
return code
}
export function shouldEnforceAdminSmsDailyLimit(providerValue: unknown): boolean {
return (
String(providerValue || 'mock')
.trim()
.toLowerCase() !== 'mock'
)
}
function normalizeAdminUsername(value: unknown): string {
return String(value || '')
.trim()