From ef36453694a08e3f5d64fac03d41c9c17a497397 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 21 Oct 2026 20:46:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(dcauth):=20=E7=A7=BB=E9=99=A4=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E7=A0=81=EF=BC=8C=E6=94=B9=E7=94=A8=E7=BA=AF=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E7=A0=81=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426 - 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示 - 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件 - Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态 - 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起 --- apps/backend/scripts/check-sql-guard.ts | 7 ++ .../102_admin_phone_device_auth.sql | 9 +- .../src/repositories/admin-device-repo.ts | 47 ++------- .../src/repositories/admin-login-log-repo.ts | 6 +- .../src/repositories/admin-user-repo.ts | 2 - .../src/services/admin/admin-auth-service.ts | 7 +- .../admin/admin-device-auth-service.test.ts | 31 ++++++ .../admin/admin-device-auth-service.ts | 67 ++++++------- .../services/admin/admin-login-log-service.ts | 3 - .../services/admin/admin-sms-service.test.ts | 14 +++ .../src/services/admin/admin-sms-service.ts | 12 +++ apps/dcauth/README.md | 41 ++++++-- apps/dcauth/base32.go | 40 -------- apps/dcauth/build-macos.sh | 92 ++++++++++++++++++ apps/dcauth/hardware_other.go | 19 ---- apps/dcauth/hardware_windows.go | 75 -------------- apps/dcauth/main.go | 43 ++++---- apps/dcauth/main_test.go | 22 ++++- apps/dcauth/ui_darwin.go | 8 +- apps/dcauth/ui_other.go | 9 +- apps/dcauth/ui_windows.go | 71 +++++++++----- apps/dcauth/versioninfo.rc | 8 +- apps/dcauth/versioninfo_windows_amd64.syso | Bin 924 -> 924 bytes .../src/pages/admin/AdminLoginPage.tsx | 25 +++-- .../src/pages/admin/AdminUsersPage.tsx | 2 +- apps/frontend/src/types/admin/auth.ts | 1 - apps/frontend/src/types/admin/users.ts | 1 - docs/设备认证-dcauth协议.md | 19 ++-- 28 files changed, 355 insertions(+), 326 deletions(-) create mode 100644 apps/backend/src/services/admin/admin-device-auth-service.test.ts create mode 100644 apps/backend/src/services/admin/admin-sms-service.test.ts create mode 100755 apps/dcauth/build-macos.sh delete mode 100644 apps/dcauth/hardware_other.go delete mode 100644 apps/dcauth/hardware_windows.go diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index cba7b262..5c7b79f2 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -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(', ')}`) } diff --git a/apps/backend/src/db/migrations/102_admin_phone_device_auth.sql b/apps/backend/src/db/migrations/102_admin_phone_device_auth.sql index b56cb23a..df6d5c5f 100644 --- a/apps/backend/src/db/migrations/102_admin_phone_device_auth.sql +++ b/apps/backend/src/db/migrations/102_admin_phone_device_auth.sql @@ -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'; diff --git a/apps/backend/src/repositories/admin-device-repo.ts b/apps/backend/src/repositories/admin-device-repo.ts index b2fa2211..4fb2a47b 100644 --- a/apps/backend/src/repositories/admin-device-repo.ts +++ b/apps/backend/src/repositories/admin-device-repo.ts @@ -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 { @@ -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 { const result = await query( ` 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 } diff --git a/apps/backend/src/repositories/admin-login-log-repo.ts b/apps/backend/src/repositories/admin-login-log-repo.ts index d6f5a716..fa67b4b9 100644 --- a/apps/backend/src/repositories/admin-login-log-repo.ts +++ b/apps/backend/src/repositories/admin-login-log-repo.ts @@ -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, ], ) diff --git a/apps/backend/src/repositories/admin-user-repo.ts b/apps/backend/src/repositories/admin-user-repo.ts index 50b007f0..46909856 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -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 diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index 0bd4e49e..680637c4 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -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 { 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, diff --git a/apps/backend/src/services/admin/admin-device-auth-service.test.ts b/apps/backend/src/services/admin/admin-device-auth-service.test.ts new file mode 100644 index 00000000..34f0a439 --- /dev/null +++ b/apps/backend/src/services/admin/admin-device-auth-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/admin/admin-device-auth-service.ts b/apps/backend/src/services/admin/admin-device-auth-service.ts index e7c68afb..2c301bfa 100644 --- a/apps/backend/src/services/admin/admin-device-auth-service.ts +++ b/apps/backend/src/services/admin/admin-device-auth-service.ts @@ -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): 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): 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): 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): 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 { diff --git a/apps/backend/src/services/admin/admin-login-log-service.ts b/apps/backend/src/services/admin/admin-login-log-service.ts index d60812f2..fa31ff4e 100644 --- a/apps/backend/src/services/admin/admin-login-log-service.ts +++ b/apps/backend/src/services/admin/admin-login-log-service.ts @@ -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 }, diff --git a/apps/backend/src/services/admin/admin-sms-service.test.ts b/apps/backend/src/services/admin/admin-sms-service.test.ts new file mode 100644 index 00000000..8603b623 --- /dev/null +++ b/apps/backend/src/services/admin/admin-sms-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/admin/admin-sms-service.ts b/apps/backend/src/services/admin/admin-sms-service.ts index 03db289f..26313190 100644 --- a/apps/backend/src/services/admin/admin-sms-service.ts +++ b/apps/backend/src/services/admin/admin-sms-service.ts @@ -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() diff --git a/apps/dcauth/README.md b/apps/dcauth/README.md index 46d2ded8..dbb4978d 100644 --- a/apps/dcauth/README.md +++ b/apps/dcauth/README.md @@ -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 作为唯一客户端。 diff --git a/apps/dcauth/base32.go b/apps/dcauth/base32.go index 92a2172c..b4b75202 100644 --- a/apps/dcauth/base32.go +++ b/apps/dcauth/base32.go @@ -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 -} diff --git a/apps/dcauth/build-macos.sh b/apps/dcauth/build-macos.sh new file mode 100755 index 00000000..a0f19c64 --- /dev/null +++ b/apps/dcauth/build-macos.sh @@ -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" diff --git a/apps/dcauth/hardware_other.go b/apps/dcauth/hardware_other.go deleted file mode 100644 index d6c2da1a..00000000 --- a/apps/dcauth/hardware_other.go +++ /dev/null @@ -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) -} diff --git a/apps/dcauth/hardware_windows.go b/apps/dcauth/hardware_windows.go deleted file mode 100644 index 9b05d5d4..00000000 --- a/apps/dcauth/hardware_windows.go +++ /dev/null @@ -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 "" -} diff --git a/apps/dcauth/main.go b/apps/dcauth/main.go index 6d0677c4..db9b3af2 100644 --- a/apps/dcauth/main.go +++ b/apps/dcauth/main.go @@ -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 } diff --git a/apps/dcauth/main_test.go b/apps/dcauth/main_test.go index 01495800..732c6e36 100644 --- a/apps/dcauth/main_test.go +++ b/apps/dcauth/main_test.go @@ -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) + } +} diff --git a/apps/dcauth/ui_darwin.go b/apps/dcauth/ui_darwin.go index a970e4eb..29064020 100644 --- a/apps/dcauth/ui_darwin.go +++ b/apps/dcauth/ui_darwin.go @@ -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, ), ) } diff --git a/apps/dcauth/ui_other.go b/apps/dcauth/ui_other.go index 39b3882d..46e50d0f 100644 --- a/apps/dcauth/ui_other.go +++ b/apps/dcauth/ui_other.go @@ -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 } diff --git a/apps/dcauth/ui_windows.go b/apps/dcauth/ui_windows.go index 9d80dfff..ca0ac103 100644 --- a/apps/dcauth/ui_windows.go +++ b/apps/dcauth/ui_windows.go @@ -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 diff --git a/apps/dcauth/versioninfo.rc b/apps/dcauth/versioninfo.rc index 74755237..8420a08c 100644 --- a/apps/dcauth/versioninfo.rc +++ b/apps/dcauth/versioninfo.rc @@ -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" diff --git a/apps/dcauth/versioninfo_windows_amd64.syso b/apps/dcauth/versioninfo_windows_amd64.syso index 2278b81d3575413a0e1ee5d6cbfd7fd5f682531f..19aaaef5fab3cfc50cec77cc10b48001ea4686ca 100644 GIT binary patch delta 43 pcmbQkK8Jn65pD(sCI$##+IU`-k=2MnkHKK`Pey$xbFwaTJ^;Tg2#Ejy delta 43 qcmbQkK8Jn65pG5XMg|Z7(i_jKGO`*n=rI^>{>i8hWlq**&IbU$C - 浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行: + 首次使用先安装一次,之后浏览器点登录即可直接唤起: - - {devLaunchUrl} - - - cd apps/dcauth && go run . "{devLaunchUrl}" + + cd apps/dcauth && ./build-macos.sh + + + 已安装但仍未弹出时,可在终端手动执行: + + + open "{devLaunchUrl}" } diff --git a/apps/frontend/src/pages/admin/AdminUsersPage.tsx b/apps/frontend/src/pages/admin/AdminUsersPage.tsx index 81d8dbd2..0aa82295 100644 --- a/apps/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminUsersPage.tsx @@ -455,7 +455,7 @@ export default function AdminUsersPage() { render: (_: boolean, row) => row.deviceBound ? ( - {row.machineCodeMask || row.deviceIdMasked} + {row.deviceIdMasked} 绑定于 {formatAdminDateTime(row.deviceBoundAt || '')} diff --git a/apps/frontend/src/types/admin/auth.ts b/apps/frontend/src/types/admin/auth.ts index e4cad143..0bda82f9 100644 --- a/apps/frontend/src/types/admin/auth.ts +++ b/apps/frontend/src/types/admin/auth.ts @@ -35,7 +35,6 @@ export interface AdminDeviceStatusResponse { status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected' deviceTicket?: string deviceId?: string - machineCodeMask?: string message?: string } diff --git a/apps/frontend/src/types/admin/users.ts b/apps/frontend/src/types/admin/users.ts index 8589987f..427d0cf4 100644 --- a/apps/frontend/src/types/admin/users.ts +++ b/apps/frontend/src/types/admin/users.ts @@ -13,7 +13,6 @@ export interface AdminUserListItem { deviceBound: boolean deviceId: string deviceIdMasked: string - machineCodeMask: string deviceBoundAt: string | null deviceLastUsedAt: string | null createdAt: string diff --git a/docs/设备认证-dcauth协议.md b/docs/设备认证-dcauth协议.md index c90957f9..514f88b0 100644 --- a/docs/设备认证-dcauth协议.md +++ b/docs/设备认证-dcauth协议.md @@ -1,4 +1,4 @@ -# dcauth 设备认证协议 v1 +# dcauth 设备认证协议 v2 三个系统共用同一个 Windows 客户端 `DCAuth.exe`。客户端通过自定义协议被浏览器唤起,使用本机 Ed25519 私钥对服务端 challenge 签名,实现“账号 + 密码 + 短信验证码 + 本机设备”登录。 @@ -33,22 +33,19 @@ dcauth://authorize?app=&challenge_id=&challenge=&api= - ``` @@ -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: 1. 验证账号/密码。 2. 验证短信验证码。 3. 消费 `deviceTicket`。 -4. 如果用户没有有效设备绑定,则把本次 `deviceId/publicKey/machineCode` 绑定到用户。 +4. 如果用户没有有效设备绑定,则把本次 `deviceId/publicKey` 绑定到用户。 5. 如果已有有效绑定,则要求 `deviceId` 和公钥完全一致。 6. 成功后才签发 JWT / Session。 @@ -130,6 +126,7 @@ X-Poll-Token: ## 7. 兼容策略 +- DCAuth v2 不再包含机器码;v1 客户端会被服务端要求升级。 - 保留原有账号密码登录接口作为过渡。 - 账号未绑定手机号时,允许旧密码登录,并提示管理员绑定手机号。 - 账号已绑定手机号后,登录必须走“密码 + 短信 + 设备”流程。