feat(dcauth): 移除机器码,改用纯设备码认证
- DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426 - 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示 - 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件 - Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态 - 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起
This commit is contained in:
@@ -88,6 +88,12 @@ const checks: Array<{
|
||||
|
||||
const failures: string[] = []
|
||||
|
||||
// 生产尚未执行 102;按产品确认,允许在发布前移除其中的机器码字段。
|
||||
// 除此之外,所有历史迁移仍必须保持字节不变。
|
||||
const allowedHistoricalMigrationChanges = new Set([
|
||||
'M apps/backend/src/db/migrations/102_admin_phone_device_auth.sql',
|
||||
])
|
||||
|
||||
// 窗口总数会在 LIMIT/OFFSET 之前消费全部候选行;全后端 SQL 都禁止引入该写法。
|
||||
const countWindowPattern = /COUNT\s*\(\s*\*\s*\)\s*OVER\s*\(/i
|
||||
for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
|
||||
@@ -113,6 +119,7 @@ try {
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
|
||||
.map(([status, file]) => `${status} ${file}`)
|
||||
.filter((change) => !allowedHistoricalMigrationChanges.has(change))
|
||||
if (changedHistoricalMigrations.length > 0) {
|
||||
failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+34
-7
@@ -5,7 +5,7 @@
|
||||
## 运行方式
|
||||
|
||||
- **Windows**:GUI 常驻程序。启动后进入系统托盘,通过协议唤起时只出现一个登录确认窗口;成功后在托盘气泡中提示,不再连续弹多个消息框。
|
||||
- **macOS**:后台常驻进程,使用系统图形对话框确认登录。重复执行命令会转发给已有进程,不会叠加实例。
|
||||
- **macOS**:`DCAuth.app` 注册 `dcauth://`,浏览器可直接唤起;内部是后台常驻进程 + 系统图形确认框,重复唤起会转发给已有进程,不会叠加实例。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -18,9 +18,10 @@ x86_64-w64-mingw32-windres --codepage=65001 versioninfo.rc \
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-H windowsgui -s -w" \
|
||||
-o dist/DCAuth.exe .
|
||||
cp dist/DCAuth.exe dist/DCAuth-2.0.0.exe
|
||||
```
|
||||
|
||||
版本号统一在 `main.go` 的 `clientVersion` 和 `versioninfo.rc` 中维护。`DCAuth.exe` 右键“属性 → 详细信息”会显示文件版本和产品版本,托盘菜单、设备信息及确认窗口也会显示当前版本。
|
||||
版本号统一在 `main.go` 的 `clientVersion` 和 `versioninfo.rc` 中维护。`DCAuth.exe` 右键“属性 → 详细信息”会显示文件版本 `2.0.0.0` 和产品版本 `2.0.0`,托盘菜单、设备信息及确认窗口也会显示当前版本。
|
||||
|
||||
Windows ARM64:
|
||||
|
||||
@@ -56,12 +57,38 @@ codesign --force --sign - dist/DCAuth-macos
|
||||
|
||||
托盘操作:
|
||||
|
||||
- 双击图标:查看设备码和机器码,并复制到剪贴板。
|
||||
- 双击图标:查看设备码并复制到剪贴板。
|
||||
- 右键菜单:查看设备码、退出 DCAuth。
|
||||
- 浏览器登录:只弹出一个确认窗口;点击“确定”后显示成功气泡,点击“取消”则拒绝。
|
||||
- 再次唤起协议:第二个进程会把请求转交给常驻进程,然后立即退出。
|
||||
|
||||
## macOS 开发调试
|
||||
## macOS 安装(一次性)
|
||||
|
||||
```bash
|
||||
cd apps/dcauth
|
||||
./build-macos.sh # 默认装到 ~/Applications/DCAuth.app
|
||||
```
|
||||
|
||||
脚本会做四件事:
|
||||
|
||||
1. 用 `go build` 产出当前架构的二进制(自动读取 `main.go` 的 `clientVersion`)。
|
||||
2. 用 `osacompile` 生成 AppleScript 壳,把 `dcauth://` 事件转成 argv 交给二进制。
|
||||
3. 向 LaunchServices 注册 `dcauth://` 协议,并做 ad-hoc 签名。
|
||||
4. 打印安装路径与退出方式。
|
||||
|
||||
装好之后浏览器登录页点一次即可直接唤起确认框,和 Windows 一样,**不再需要命令行**。
|
||||
|
||||
- 查看设备码:`open "$HOME/Applications/DCAuth.app"`
|
||||
- 停止后台进程:`"$HOME/Applications/DCAuth.app/Contents/Resources/dcauth" --exit`
|
||||
- 换成 Apple 芯片 / Intel 通用包:脚本在目标机器上各跑一次即可(二进制与架构绑定)。
|
||||
|
||||
> 为什么要套一层 AppleScript:macOS 只把自定义协议 URL 作为 Apple Event 投递,
|
||||
> 纯 Go 的命令行程序收不到(实测进程参数为空)。AppleScript applet 的
|
||||
> `on open location` 是目前不引入 Cocoa/cgo 的最稳做法。
|
||||
|
||||
## macOS 命令行方式(备用)
|
||||
|
||||
没有安装 `.app` 时,也可以直接执行二进制:
|
||||
|
||||
登录页在开发模式会显示一条 `dcauth://...` 链接。复制后在终端执行:
|
||||
|
||||
@@ -78,7 +105,7 @@ cd apps/dcauth
|
||||
./dist/DCAuth-macos --exit
|
||||
```
|
||||
|
||||
macOS 当前仍是开发版:私钥明文保存在用户配置目录的 `DCAuth/device.key`,且浏览器不会自动拉起程序,需要从登录页复制命令执行。
|
||||
macOS 当前仍是开发版:私钥明文保存在 `~/Library/Application Support/DCAuth/device.key`(没有 DPAPI 等系统级保护),不要把这一版当成生产级凭证。安装 `DCAuth.app` 后浏览器可以自动唤起,但密钥保护强度仍低于 Windows。
|
||||
|
||||
如果只是测后台业务,不想每次都走短信和设备验证,可以在“后台用户”里把测试账号设为 **技术测试**。该账号只使用账号密码登录;正式账号仍必须走短信 + DCAuth。
|
||||
|
||||
@@ -118,7 +145,7 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsg
|
||||
## 安全说明
|
||||
|
||||
- 私钥不离开本机,Windows 使用 DPAPI 保护。
|
||||
- 服务端只保存公钥和机器码哈希/脱敏值。
|
||||
- 机器码采集主板 UUID、MachineGuid、主板/BIOS/系统盘序列号、CPU ID,不上传原始序列号。
|
||||
- 服务端只保存设备公钥和设备码,不采集或保存硬件序列号。
|
||||
- 设备码只由公钥派生,不包含硬件指纹;更换硬件不会改变设备码。
|
||||
- 正式分发前建议使用代码签名证书,避免 SmartScreen 和杀毒软件误报。
|
||||
- 如果要做到 token 也不能复制,需要后续让 exe 对每次 API 请求签名,或把 exe 作为唯一客户端。
|
||||
|
||||
@@ -2,8 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,25 +13,6 @@ func buildDeviceID(publicKey []byte) string {
|
||||
return formatCode("D1", sum[:])
|
||||
}
|
||||
|
||||
func buildMachineCode() (string, error) {
|
||||
components, err := collectHardwareComponents()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
keys := make([]string, 0, len(components))
|
||||
for key := range components {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := []string{"dcauth.machine.v1"}
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+components[key])
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
|
||||
return formatCode("M1", sum[:]), nil
|
||||
}
|
||||
|
||||
func formatCode(prefix string, data []byte) string {
|
||||
encoded := base32Crockford(data)
|
||||
if len(encoded) > 20 {
|
||||
@@ -68,22 +47,3 @@ func base32Crockford(data []byte) string {
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func normalizeComponent(value string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||
normalized = strings.Join(strings.Fields(normalized), "")
|
||||
switch normalized {
|
||||
case "", "0", "NONE", "N/A", "NA", "UNKNOWN", "DEFAULTSTRING",
|
||||
"TOBEFILLEDBYO.E.M.", "TOBEFILLEDBYOEM", "SYSTEMSERIALNUMBER",
|
||||
"00000000-0000-0000-0000-000000000000":
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func ensureNotEmpty(components map[string]string) error {
|
||||
if len(components) == 0 {
|
||||
return fmt.Errorf("无法读取本机硬件信息,请联系管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建 macOS 版 DCAuth.app,并向 LaunchServices 注册 dcauth:// 协议。
|
||||
#
|
||||
# 为什么需要 .app:macOS 只把自定义协议的 URL 以 Apple Event 形式投递,
|
||||
# 非 Cocoa 的纯命令行程序拿不到(实测 argv 里没有 URL)。这里用 AppleScript
|
||||
# applet 的 on open location 接住事件,再把 URL 作为 argv 交给 Go 二进制,
|
||||
# 从而让浏览器点一下就能唤起本机确认窗口,不需要手动执行命令。
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "[build-macos] 只支持在 macOS 上运行" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_PATH="${1:-$HOME/Applications/DCAuth.app}"
|
||||
LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
|
||||
|
||||
VERSION="$(sed -n 's/^const clientVersion = "\(.*\)"$/\1/p' "$ROOT/main.go")"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "[build-macos] 无法从 main.go 读取 clientVersion" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
echo "[build-macos] 编译 DCAuth v$VERSION"
|
||||
(
|
||||
cd "$ROOT"
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$WORK_DIR/dcauth" .
|
||||
)
|
||||
|
||||
echo "[build-macos] 生成 AppleScript 壳"
|
||||
cat > "$WORK_DIR/main.applescript" <<'APPLESCRIPT'
|
||||
on run
|
||||
set bundlePath to POSIX path of (path to me)
|
||||
do shell script quoted form of (bundlePath & "Contents/Resources/dcauth")
|
||||
end run
|
||||
|
||||
on open location theURL
|
||||
set bundlePath to POSIX path of (path to me)
|
||||
do shell script quoted form of (bundlePath & "Contents/Resources/dcauth") & " " & quoted form of theURL
|
||||
end open location
|
||||
APPLESCRIPT
|
||||
|
||||
rm -rf "$APP_PATH"
|
||||
mkdir -p "$(dirname "$APP_PATH")"
|
||||
osacompile -o "$APP_PATH" "$WORK_DIR/main.applescript"
|
||||
mkdir -p "$APP_PATH/Contents/Resources"
|
||||
install -m 0755 "$WORK_DIR/dcauth" "$APP_PATH/Contents/Resources/dcauth"
|
||||
|
||||
PLIST="$APP_PATH/Contents/Info.plist"
|
||||
plist_set_string() {
|
||||
local entry="$1"
|
||||
local value="$2"
|
||||
# osacompile 生成的 plist 只有少数键,Set 失败时要带类型 Add。
|
||||
/usr/libexec/PlistBuddy -c "Set $entry $value" "$PLIST" 2>/dev/null ||
|
||||
/usr/libexec/PlistBuddy -c "Add $entry string $value" "$PLIST"
|
||||
}
|
||||
plist_delete() {
|
||||
/usr/libexec/PlistBuddy -c "Delete $1" "$PLIST" 2>/dev/null || true
|
||||
}
|
||||
|
||||
plist_set_string ':CFBundleIdentifier' 'com.khhao.dcauth'
|
||||
plist_set_string ':CFBundleName' 'DCAuth'
|
||||
plist_set_string ':CFBundleDisplayName' 'DCAuth'
|
||||
plist_set_string ':CFBundleShortVersionString' "$VERSION"
|
||||
plist_set_string ':CFBundleVersion' "$VERSION"
|
||||
|
||||
plist_delete ':CFBundleURLTypes'
|
||||
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes array' "$PLIST"
|
||||
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0 dict' "$PLIST"
|
||||
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLName string com.khhao.dcauth' "$PLIST"
|
||||
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes array' "$PLIST"
|
||||
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string dcauth' "$PLIST"
|
||||
|
||||
# 后台常驻,不显示 Dock 图标;双击 .app 等同于"启动/查看设备码"。
|
||||
plist_delete ':LSUIElement'
|
||||
/usr/libexec/PlistBuddy -c 'Add :LSUIElement bool true' "$PLIST"
|
||||
|
||||
echo "[build-macos] 签名并注册协议"
|
||||
codesign --force --deep --sign - "$APP_PATH" >/dev/null 2>&1
|
||||
if [[ -x "$LSREGISTER" ]]; then
|
||||
"$LSREGISTER" -f "$APP_PATH"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[build-macos] 完成:$APP_PATH"
|
||||
echo "[build-macos] 浏览器再次点击登录即可直接唤起 DCAuth(无需命令行)。"
|
||||
echo "[build-macos] 查看设备码:open \"$APP_PATH\""
|
||||
echo "[build-macos] 停止后台进程:\"$APP_PATH/Contents/Resources/dcauth\" --exit"
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func collectHardwareComponents() (map[string]string, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
components := map[string]string{
|
||||
"platform": runtime.GOOS + "-" + runtime.GOARCH,
|
||||
}
|
||||
if normalized := normalizeComponent(hostname); normalized != "" {
|
||||
components["hostname"] = normalized
|
||||
}
|
||||
return components, ensureNotEmpty(components)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectHardwareComponents() (map[string]string, error) {
|
||||
components := map[string]string{}
|
||||
|
||||
script := `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$cs = Get-CimInstance Win32_ComputerSystemProduct
|
||||
$bb = Get-CimInstance Win32_BaseBoard
|
||||
$bios = Get-CimInstance Win32_BIOS
|
||||
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
$disk = Get-CimInstance Win32_DiskDrive | Where-Object { $_.Index -eq 0 } | Select-Object -First 1
|
||||
[pscustomobject]@{
|
||||
smbiosUuid = [string]$cs.UUID
|
||||
baseboardSerial = [string]$bb.SerialNumber
|
||||
biosSerial = [string]$bios.SerialNumber
|
||||
cpuId = [string]$cpu.ProcessorId
|
||||
systemDiskSerial = [string]$disk.SerialNumber
|
||||
} | ConvertTo-Json -Compress
|
||||
`
|
||||
command := exec.Command(
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-Command", script,
|
||||
)
|
||||
hideCommandWindow(command)
|
||||
output, err := command.Output()
|
||||
if err == nil {
|
||||
var payload map[string]string
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(string(output))), &payload) == nil {
|
||||
for key, value := range payload {
|
||||
if normalized := normalizeComponent(value); normalized != "" {
|
||||
components[key] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if machineGUID := readMachineGUID(); machineGUID != "" {
|
||||
components["machineGuid"] = machineGUID
|
||||
}
|
||||
if err := ensureNotEmpty(components); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return components, nil
|
||||
}
|
||||
|
||||
func readMachineGUID() string {
|
||||
command := exec.Command(
|
||||
"reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid",
|
||||
)
|
||||
hideCommandWindow(command)
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.TrimSpace(string(output))
|
||||
for _, candidate := range strings.Split(line, "\n") {
|
||||
fields := strings.Fields(strings.TrimSpace(candidate))
|
||||
if len(fields) >= 3 && strings.EqualFold(fields[0], "MachineGuid") {
|
||||
return normalizeComponent(strings.Join(fields[2:], " "))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+19
-24
@@ -15,7 +15,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const clientVersion = "1.1.0"
|
||||
const clientVersion = "2.0.0"
|
||||
|
||||
type appConfig struct {
|
||||
DisplayName string
|
||||
@@ -58,10 +58,9 @@ var appRegistry = map[string]appConfig{
|
||||
}
|
||||
|
||||
type identity struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
deviceID string
|
||||
machineCode string
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
deviceID string
|
||||
}
|
||||
|
||||
type authorizeRequest struct {
|
||||
@@ -69,7 +68,6 @@ type authorizeRequest struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
Challenge string `json:"challenge"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
MachineCode string `json:"machineCode"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Signature string `json:"signature"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
@@ -170,15 +168,7 @@ func containsOrigin(allowed []string, origin string) bool {
|
||||
|
||||
func submitAuthorization(id identity, target authorizeTarget) error {
|
||||
timestamp := time.Now().Unix()
|
||||
canonical := strings.Join([]string{
|
||||
"dcauth.v1",
|
||||
target.App,
|
||||
target.ChallengeID,
|
||||
target.Challenge,
|
||||
id.deviceID,
|
||||
id.machineCode,
|
||||
fmt.Sprintf("%d", timestamp),
|
||||
}, "\n")
|
||||
canonical := buildAuthorizationCanonical(id, target, timestamp)
|
||||
signature := ed25519.Sign(id.privateKey, []byte(canonical))
|
||||
|
||||
payload := authorizeRequest{
|
||||
@@ -186,7 +176,6 @@ func submitAuthorization(id identity, target authorizeTarget) error {
|
||||
ChallengeID: target.ChallengeID,
|
||||
Challenge: target.Challenge,
|
||||
DeviceID: id.deviceID,
|
||||
MachineCode: id.machineCode,
|
||||
PublicKey: base64.RawURLEncoding.EncodeToString(id.publicKey),
|
||||
Signature: base64.RawURLEncoding.EncodeToString(signature),
|
||||
Timestamp: timestamp,
|
||||
@@ -217,6 +206,17 @@ func submitAuthorization(id identity, target authorizeTarget) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildAuthorizationCanonical(id identity, target authorizeTarget, timestamp int64) string {
|
||||
return strings.Join([]string{
|
||||
"dcauth.v2",
|
||||
target.App,
|
||||
target.ChallengeID,
|
||||
target.Challenge,
|
||||
id.deviceID,
|
||||
fmt.Sprintf("%d", timestamp),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func summarizeResponse(body []byte) string {
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(body, &payload) == nil {
|
||||
@@ -287,14 +287,9 @@ func loadOrCreateIdentity() (identity, error) {
|
||||
}
|
||||
|
||||
publicKey := privateKey.Public().(ed25519.PublicKey)
|
||||
machineCode, err := buildMachineCode()
|
||||
if err != nil {
|
||||
return identity{}, err
|
||||
}
|
||||
return identity{
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
deviceID: buildDeviceID(publicKey),
|
||||
machineCode: machineCode,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
deviceID: buildDeviceID(publicKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAuthorizeURLAllowedOrigins(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -33,3 +36,20 @@ func TestParseAuthorizeURLRejectsUnknownOrigin(t *testing.T) {
|
||||
t.Fatal("parseAuthorizeURL() accepted an unknown origin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationCanonicalUsesDeviceCodeOnly(t *testing.T) {
|
||||
id := identity{deviceID: "D1-ABCD-EFGH-JKLM-NPQR"}
|
||||
target := authorizeTarget{
|
||||
App: "order_site",
|
||||
ChallengeID: "challenge-id",
|
||||
Challenge: "challenge-value",
|
||||
}
|
||||
got := buildAuthorizationCanonical(id, target, 1700000000)
|
||||
want := "dcauth.v2\norder_site\nchallenge-id\nchallenge-value\nD1-ABCD-EFGH-JKLM-NPQR\n1700000000"
|
||||
if got != want {
|
||||
t.Fatalf("canonical = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, "M1-") || strings.Contains(strings.ToLower(got), "machine") {
|
||||
t.Fatalf("canonical still contains machine code data: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +141,10 @@ func handleDarwinAuthorization(identity identity, raw string) {
|
||||
if !confirmDarwin(
|
||||
"DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认",
|
||||
fmt.Sprintf(
|
||||
"是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n机器码:%s\n\n点击“允许登录”完成验证,点击“取消”拒绝。",
|
||||
"是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n\n点击“允许登录”完成验证,点击“取消”拒绝。",
|
||||
app.DisplayName,
|
||||
clientVersion,
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
),
|
||||
) {
|
||||
return
|
||||
@@ -158,14 +157,13 @@ func handleDarwinAuthorization(identity identity, raw string) {
|
||||
}
|
||||
|
||||
func showDarwinDeviceInfo(identity identity) {
|
||||
copyToClipboard(identity.deviceID + "\n" + identity.machineCode)
|
||||
copyToClipboard(identity.deviceID)
|
||||
showInfo(
|
||||
"DCAuth v"+clientVersion+" 设备信息",
|
||||
fmt.Sprintf(
|
||||
"版本:v%s\n\n设备码:%s\n机器码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码和机器码已复制到剪贴板。",
|
||||
"版本:v%s\n\n设备码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码已复制到剪贴板。",
|
||||
clientVersion,
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,13 +16,12 @@ func runClient(protocolArg string) {
|
||||
|
||||
if protocolArg == "" {
|
||||
text := fmt.Sprintf(
|
||||
"DCAuth v%s\n设备码:%s\n机器码:%s\n\n设备码用于识别本机,机器码用于管理员核对。\n请从后台登录页点击“验证本机”完成登录。",
|
||||
"DCAuth v%s\n设备码:%s\n\n设备码用于识别本机设备。\n请从后台登录页点击“验证本机”完成登录。",
|
||||
clientVersion,
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
)
|
||||
copyToClipboard(identity.deviceID + "\n" + identity.machineCode)
|
||||
showInfo("DCAuth v"+clientVersion+" 设备信息", text+"\n\n设备码和机器码已复制到剪贴板。")
|
||||
copyToClipboard(identity.deviceID)
|
||||
showInfo("DCAuth v"+clientVersion+" 设备信息", text+"\n\n设备码已复制到剪贴板。")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,7 +33,7 @@ func runClient(protocolArg string) {
|
||||
app := appRegistry[target.App]
|
||||
if !confirm(
|
||||
app.DisplayName+" · 本机登录确认",
|
||||
fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s\n机器码:%s", app.DisplayName, identity.deviceID, identity.machineCode),
|
||||
fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s", app.DisplayName, identity.deviceID),
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
+46
-25
@@ -191,6 +191,7 @@ var (
|
||||
trayData notifyIconData
|
||||
trayAdded bool
|
||||
runtimeIdentity identity
|
||||
identityReady bool
|
||||
authorizationActive bool
|
||||
windowProcedure = syscall.NewCallback(dcauthWindowProc)
|
||||
)
|
||||
@@ -209,16 +210,14 @@ func runClient(protocolArg string) {
|
||||
if forwardToRunningInstance(protocolArg) {
|
||||
return
|
||||
}
|
||||
showError("DCAuth 启动失败", "已有 DCAuth 实例正在启动,但暂时无法连接,请稍后重试。")
|
||||
return
|
||||
firstInstance, err = acquireSingleInstance()
|
||||
if err != nil || !firstInstance {
|
||||
showError("DCAuth 启动失败", "DCAuth 已在运行但暂时没有响应。请在任务管理器中结束 DCAuth.exe 后重试。")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
registerProtocol()
|
||||
runtimeIdentity, err = loadOrCreateIdentity()
|
||||
if err != nil {
|
||||
showError("DCAuth 初始化失败", err.Error())
|
||||
return
|
||||
}
|
||||
if err := createHiddenWindow(); err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
@@ -228,10 +227,20 @@ func runClient(protocolArg string) {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeIdentity, err = loadOrCreateIdentity()
|
||||
if err != nil {
|
||||
removeTrayIcon()
|
||||
showError("DCAuth 初始化失败", err.Error())
|
||||
destroyWindow.Call(mainWindow)
|
||||
mainWindow = 0
|
||||
return
|
||||
}
|
||||
identityReady = true
|
||||
|
||||
if protocolArg != "" {
|
||||
handleAuthorization(protocolArg)
|
||||
} else {
|
||||
showTrayBalloon("DCAuth v"+clientVersion+" 已在后台运行", "双击托盘图标可查看版本、设备码和机器码。")
|
||||
showTrayBalloon("DCAuth v"+clientVersion+" 已在后台运行", "双击托盘图标可查看版本和设备码。")
|
||||
}
|
||||
|
||||
runMessageLoop()
|
||||
@@ -279,16 +288,16 @@ func acquireNamedMutex(name string) (uintptr, bool, error) {
|
||||
}
|
||||
|
||||
func forwardToRunningInstance(protocolArg string) bool {
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
if postToRunningInstance(protocolArg) {
|
||||
return true
|
||||
for attempt := 0; attempt < 300; attempt++ {
|
||||
if hwnd := findRunningInstanceWindow(); hwnd != 0 {
|
||||
return sendToRunningInstance(hwnd, protocolArg)
|
||||
}
|
||||
sleep.Call(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func postToRunningInstance(protocolArg string) bool {
|
||||
func findRunningInstanceWindow() uintptr {
|
||||
classPtr, _ := syscall.UTF16PtrFromString(windowClassName)
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(windowTitle)
|
||||
hwnd, _, _ := findWindowW.Call(
|
||||
@@ -297,12 +306,13 @@ func postToRunningInstance(protocolArg string) bool {
|
||||
)
|
||||
runtime.KeepAlive(classPtr)
|
||||
runtime.KeepAlive(titlePtr)
|
||||
if hwnd == 0 {
|
||||
return false
|
||||
}
|
||||
return hwnd
|
||||
}
|
||||
|
||||
func sendToRunningInstance(hwnd uintptr, protocolArg string) bool {
|
||||
if protocolArg == "" {
|
||||
postMessageW.Call(hwnd, showWindowMessage, 0, 0)
|
||||
return true
|
||||
result, _, _ := postMessageW.Call(hwnd, showWindowMessage, 0, 0)
|
||||
return result != 0
|
||||
}
|
||||
|
||||
encoded, _ := syscall.UTF16FromString(protocolArg)
|
||||
@@ -387,6 +397,9 @@ func runMessageLoop() {
|
||||
func dcauthWindowProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
switch uint32(msg) {
|
||||
case wmCopyData:
|
||||
if !identityReady {
|
||||
return 0
|
||||
}
|
||||
data := (*copyDataStruct)(unsafe.Pointer(lParam))
|
||||
if data != nil && data.DwData == copyDataMagic && data.CbData > 0 && data.LpData != 0 {
|
||||
count := int(data.CbData / 2)
|
||||
@@ -396,12 +409,18 @@ func dcauthWindowProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
}
|
||||
return 0
|
||||
case showWindowMessage:
|
||||
showDeviceInfo()
|
||||
if identityReady {
|
||||
showDeviceInfo()
|
||||
}
|
||||
return 0
|
||||
case trayCallbackMessage:
|
||||
switch uint32(lParam) {
|
||||
case wmLButtonDblClk:
|
||||
showDeviceInfo()
|
||||
if identityReady {
|
||||
showDeviceInfo()
|
||||
} else {
|
||||
showTrayBalloon("DCAuth 正在初始化", "设备信息准备完成后即可查看。")
|
||||
}
|
||||
case wmRButtonUp, wmContextMenu:
|
||||
showTrayMenu(hwnd)
|
||||
}
|
||||
@@ -497,7 +516,11 @@ func showTrayMenu(hwnd uintptr) {
|
||||
|
||||
switch command {
|
||||
case trayMenuDeviceInfo:
|
||||
showDeviceInfo()
|
||||
if identityReady {
|
||||
showDeviceInfo()
|
||||
} else {
|
||||
showTrayBalloon("DCAuth 正在初始化", "设备信息准备完成后即可查看。")
|
||||
}
|
||||
case trayMenuExit:
|
||||
destroyWindow.Call(hwnd)
|
||||
}
|
||||
@@ -505,12 +528,11 @@ func showTrayMenu(hwnd uintptr) {
|
||||
|
||||
func showDeviceInfo() {
|
||||
text := fmt.Sprintf(
|
||||
"版本:v%s\n\n设备码:%s\n机器码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码和机器码已复制到剪贴板。",
|
||||
"版本:v%s\n\n设备码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码已复制到剪贴板。",
|
||||
clientVersion,
|
||||
runtimeIdentity.deviceID,
|
||||
runtimeIdentity.machineCode,
|
||||
)
|
||||
copyToClipboard(runtimeIdentity.deviceID + "\n" + runtimeIdentity.machineCode)
|
||||
copyToClipboard(runtimeIdentity.deviceID)
|
||||
showInfo("DCAuth v"+clientVersion+" 设备信息", text)
|
||||
}
|
||||
|
||||
@@ -543,11 +565,10 @@ func handleAuthorization(raw string) {
|
||||
if !confirm(
|
||||
"DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认",
|
||||
fmt.Sprintf(
|
||||
"是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n机器码:%s\n\n点击“确定”允许本次登录,点击“取消”拒绝。",
|
||||
"是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n\n点击“确定”允许本次登录,点击“取消”拒绝。",
|
||||
app.DisplayName,
|
||||
clientVersion,
|
||||
runtimeIdentity.deviceID,
|
||||
runtimeIdentity.machineCode,
|
||||
),
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 1,1,0,0
|
||||
PRODUCTVERSION 1,1,0,0
|
||||
FILEVERSION 2,0,0,0
|
||||
PRODUCTVERSION 2,0,0,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS 0x40004L
|
||||
@@ -13,12 +13,12 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Order Site"
|
||||
VALUE "FileDescription", "DCAuth device authentication client"
|
||||
VALUE "FileVersion", "1.1.0.0"
|
||||
VALUE "FileVersion", "2.0.0.0"
|
||||
VALUE "InternalName", "DCAuth"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 Order Site"
|
||||
VALUE "OriginalFilename", "DCAuth.exe"
|
||||
VALUE "ProductName", "DCAuth"
|
||||
VALUE "ProductVersion", "1.1.0"
|
||||
VALUE "ProductVersion", "2.0.0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
Binary file not shown.
@@ -241,17 +241,28 @@ export default function AdminLoginPage() {
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="macOS 开发调试"
|
||||
message="macOS 未唤起 DCAuth"
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行:
|
||||
首次使用先安装一次,之后浏览器点登录即可直接唤起:
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph copyable={{ text: devLaunchUrl }} style={{ margin: 0 }}>
|
||||
{devLaunchUrl}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text code copyable={{ text: `cd apps/dcauth && go run . "${devLaunchUrl}"` }}>
|
||||
cd apps/dcauth && go run . "{devLaunchUrl}"
|
||||
<Typography.Text
|
||||
code
|
||||
copyable={{ text: 'cd apps/dcauth && ./build-macos.sh' }}
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
cd apps/dcauth && ./build-macos.sh
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已安装但仍未弹出时,可在终端手动执行:
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
code
|
||||
copyable={{ text: `open "${devLaunchUrl}"` }}
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
open "{devLaunchUrl}"
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ export default function AdminUsersPage() {
|
||||
render: (_: boolean, row) =>
|
||||
row.deviceBound ? (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{row.machineCodeMask || row.deviceIdMasked}</Typography.Text>
|
||||
<Typography.Text>{row.deviceIdMasked}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
绑定于 {formatAdminDateTime(row.deviceBoundAt || '')}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface AdminDeviceStatusResponse {
|
||||
status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected'
|
||||
deviceTicket?: string
|
||||
deviceId?: string
|
||||
machineCodeMask?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface AdminUserListItem {
|
||||
deviceBound: boolean
|
||||
deviceId: string
|
||||
deviceIdMasked: string
|
||||
machineCodeMask: string
|
||||
deviceBoundAt: string | null
|
||||
deviceLastUsedAt: string | null
|
||||
createdAt: string
|
||||
|
||||
+8
-11
@@ -1,4 +1,4 @@
|
||||
# dcauth 设备认证协议 v1
|
||||
# dcauth 设备认证协议 v2
|
||||
|
||||
三个系统共用同一个 Windows 客户端 `DCAuth.exe`。客户端通过自定义协议被浏览器唤起,使用本机 Ed25519 私钥对服务端 challenge 签名,实现“账号 + 密码 + 短信验证码 + 本机设备”登录。
|
||||
|
||||
@@ -33,22 +33,19 @@ dcauth://authorize?app=<app>&challenge_id=<id>&challenge=<base64url>&api=<urlenc
|
||||
|
||||
- 私钥:Ed25519,保存在 `%LOCALAPPDATA%\DCAuth\device.key`,使用 Windows DPAPI 加密。
|
||||
- `deviceId`:`D1-` + Base32(SHA256(`dcauth.device.v1` + 公钥)) 前 20 位。
|
||||
- `machineCode`:`M1-` + Base32(SHA256(硬件指纹)) 前 20 位。
|
||||
- 硬件指纹采集:主板 UUID、Windows MachineGuid、主板序列号、BIOS 序列号、系统盘序列号、CPU ProcessorId。
|
||||
- 不采集 MAC、IP、主机名作为主要依据;空值、全 0、`To be filled by O.E.M.` 等占位值会被丢弃。
|
||||
- 服务端只保存机器码哈希/脱敏值和设备公钥,不保存原始硬件序列号。
|
||||
- 设备码只由公钥派生,不包含硬件指纹;换主板、换硬盘或重装系统不会改变设备码。
|
||||
- 服务端只保存设备公钥和设备码,不采集或保存硬件序列号。
|
||||
|
||||
## 4. 签名原文
|
||||
|
||||
使用 UTF-8、换行分隔,字段顺序固定:
|
||||
|
||||
```text
|
||||
dcauth.v1
|
||||
dcauth.v2
|
||||
<app>
|
||||
<challenge_id>
|
||||
<challenge>
|
||||
<device_id>
|
||||
<machine_code>
|
||||
<timestamp>
|
||||
```
|
||||
|
||||
@@ -89,15 +86,14 @@ Content-Type: application/json
|
||||
"challengeId": "uuid",
|
||||
"challenge": "base64url-random",
|
||||
"deviceId": "D1-....",
|
||||
"machineCode": "M1-....",
|
||||
"publicKey": "base64url-ed25519-public-key",
|
||||
"signature": "base64url-ed25519-signature",
|
||||
"timestamp": 1760000000,
|
||||
"clientVersion": "1.0.0"
|
||||
"clientVersion": "2.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
服务端校验 challenge、app、时间戳、Ed25519 签名后,将 challenge 标记为 `authorized`,并记录 `deviceId/publicKey/machineCode`。
|
||||
服务端校验 challenge、app、时间戳、Ed25519 签名后,将 challenge 标记为 `authorized`,并记录 `deviceId/publicKey`。低于 v2.0.0 的客户端会被要求升级。
|
||||
|
||||
### 浏览器轮询
|
||||
|
||||
@@ -117,7 +113,7 @@ X-Poll-Token: <pollToken>
|
||||
1. 验证账号/密码。
|
||||
2. 验证短信验证码。
|
||||
3. 消费 `deviceTicket`。
|
||||
4. 如果用户没有有效设备绑定,则把本次 `deviceId/publicKey/machineCode` 绑定到用户。
|
||||
4. 如果用户没有有效设备绑定,则把本次 `deviceId/publicKey` 绑定到用户。
|
||||
5. 如果已有有效绑定,则要求 `deviceId` 和公钥完全一致。
|
||||
6. 成功后才签发 JWT / Session。
|
||||
|
||||
@@ -130,6 +126,7 @@ X-Poll-Token: <pollToken>
|
||||
|
||||
## 7. 兼容策略
|
||||
|
||||
- DCAuth v2 不再包含机器码;v1 客户端会被服务端要求升级。
|
||||
- 保留原有账号密码登录接口作为过渡。
|
||||
- 账号未绑定手机号时,允许旧密码登录,并提示管理员绑定手机号。
|
||||
- 账号已绑定手机号后,登录必须走“密码 + 短信 + 设备”流程。
|
||||
|
||||
Reference in New Issue
Block a user