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

- DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426
- 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示
- 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件
- Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态
- 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起
This commit is contained in:
yml2213
2026-10-21 20:46:25 +08:00
parent cb916d7750
commit ef36453694
28 changed files with 355 additions and 326 deletions
+7
View File
@@ -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
View File
@@ -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 作为唯一客户端。
-40
View File
@@ -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
}
+92
View File
@@ -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"
-19
View File
@@ -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)
}
-75
View File
@@ -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
View File
@@ -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
}
+21 -1
View File
@@ -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)
}
}
+3 -5
View File
@@ -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,
),
)
}
+4 -5
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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>
-1
View File
@@ -35,7 +35,6 @@ export interface AdminDeviceStatusResponse {
status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected'
deviceTicket?: string
deviceId?: string
machineCodeMask?: string
message?: string
}
-1
View File
@@ -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
View File
@@ -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 客户端会被服务端要求升级。
- 保留原有账号密码登录接口作为过渡。
- 账号未绑定手机号时,允许旧密码登录,并提示管理员绑定手机号。
- 账号已绑定手机号后,登录必须走“密码 + 短信 + 设备”流程。