feat(dcauth): 移除机器码,改用纯设备码认证
- DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426 - 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示 - 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件 - Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态 - 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user