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/app.ts b/apps/backend/src/app.ts index 82f2a5b5..b775fc1d 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,6 +7,7 @@ import affiliateDashRouter from './routes/affiliate-dash.js' import collectRouter from './routes/collect.js' import filesRouter from './routes/files.js' import claimsRouter from './routes/claims.js' +import dcAuthRouter from './routes/dcauth.js' import douyinMashidaRouter from './routes/douyin-mashida.js' import kuaishouIndustryRouter from './routes/kuaishou-industry.js' import open91Router from './routes/open-91.js' @@ -115,6 +116,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp app.use('/api/v1/pdd/extension', pddExtensionRouter) app.use('/api/v1/files', filesRouter) app.use('/api/v1/worker', workerRouter) + app.use('/api/dcauth', dcAuthRouter) app.use('/api/v1/admin', adminRouter) app.use('/s', shortLinksRouter) 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 new file mode 100644 index 00000000..df6d5c5f --- /dev/null +++ b/apps/backend/src/db/migrations/102_admin_phone_device_auth.sql @@ -0,0 +1,109 @@ +-- 102_admin_phone_device_auth.sql —— 后台账号手机号、短信验证码与设备认证。 +-- +-- 登录链路:账号/密码 + 短信验证码 + DCAuth 设备签名。 +-- 旧账号未绑定手机号时仍可使用原密码登录;绑定手机号后强制走新链路。 + +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT ''; +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS phone_bound_at TIMESTAMPTZ; +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS phone_bound_by BIGINT; +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS test_account BOOLEAN NOT NULL DEFAULT FALSE; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_admin_users_phone_bound_by' + ) THEN + ALTER TABLE admin_users + ADD CONSTRAINT fk_admin_users_phone_bound_by + FOREIGN KEY (phone_bound_by) REFERENCES admin_users(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_phone + ON admin_users(phone) + WHERE phone <> ''; + +CREATE TABLE IF NOT EXISTS admin_sms_codes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + phone TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'login', + code_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + failed_attempt_count INTEGER NOT NULL DEFAULT 0, + request_ip TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_admin_sms_codes_user_created + ON admin_sms_codes(user_id, purpose, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_admin_sms_codes_phone_created + ON admin_sms_codes(phone, created_at DESC); + +CREATE TABLE IF NOT EXISTS admin_user_devices ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + public_key TEXT NOT NULL, + client_version TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + bound_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_user_devices_active_user + ON admin_user_devices(user_id) + WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_admin_user_devices_device_id + ON admin_user_devices(device_id); + +CREATE TABLE IF NOT EXISTS admin_device_challenges ( + id BIGSERIAL PRIMARY KEY, + challenge_id TEXT NOT NULL UNIQUE, + app TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'login', + challenge TEXT NOT NULL, + poll_token_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + device_id TEXT NOT NULL DEFAULT '', + public_key TEXT NOT NULL DEFAULT '', + client_version TEXT NOT NULL DEFAULT '', + device_ticket_hash TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + authorized_at TIMESTAMPTZ, + ticket_issued_at TIMESTAMPTZ, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_admin_device_challenges_expires + ON admin_device_challenges(expires_at); +CREATE INDEX IF NOT EXISTS idx_admin_device_challenges_user + ON admin_device_challenges(user_id, created_at DESC); + +ALTER TABLE admin_login_logs + ADD COLUMN IF NOT EXISTS login_method TEXT NOT NULL DEFAULT 'password'; + +ALTER TABLE admin_sessions + ADD COLUMN IF NOT EXISTS device_id TEXT NOT NULL DEFAULT ''; + +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_device_challenges IS 'DCAuth 设备认证 challenge 与一次性 ticket'; +COMMENT ON COLUMN admin_login_logs.login_method IS 'password / sms_device'; +COMMENT ON COLUMN admin_sessions.device_id IS '登录会话绑定的设备 ID'; diff --git a/apps/backend/src/middleware/cors.ts b/apps/backend/src/middleware/cors.ts index 7c8867c7..9c817a20 100644 --- a/apps/backend/src/middleware/cors.ts +++ b/apps/backend/src/middleware/cors.ts @@ -19,7 +19,10 @@ export function createCorsMiddleware( } } - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Shop-Token') + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-Shop-Token, X-Poll-Token', + ) res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') res.setHeader('Access-Control-Max-Age', '86400') diff --git a/apps/backend/src/repositories/admin-device-repo.ts b/apps/backend/src/repositories/admin-device-repo.ts new file mode 100644 index 00000000..4fb2a47b --- /dev/null +++ b/apps/backend/src/repositories/admin-device-repo.ts @@ -0,0 +1,223 @@ +import { query } from '../db/client.js' + +export type AdminDeviceChallengeRow = { + id: number + challenge_id: string + app: string + purpose: string + challenge: string + poll_token_hash: string + status: string + user_id: number | null + device_id: string + public_key: string + client_version: string + device_ticket_hash: string + expires_at: string + authorized_at: string | null + ticket_issued_at: string | null + consumed_at: string | null + created_at: string + updated_at: string +} + +export type AdminUserDeviceRow = { + id: number + user_id: number + device_id: string + public_key: string + client_version: string + status: string + bound_at: string + last_used_at: string | null + revoked_at: string | null + revoked_by: number | null + created_at: string + updated_at: string +} + +export async function createAdminDeviceChallenge(input: { + challengeId: string + app: string + purpose: string + challenge: string + pollTokenHash: string + userId: number | null + expiresAt: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO admin_device_challenges ( + challenge_id, app, purpose, challenge, poll_token_hash, status, user_id, + expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $8) + RETURNING * + `, + [ + input.challengeId, + input.app, + input.purpose, + input.challenge, + input.pollTokenHash, + input.userId, + input.expiresAt, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function getAdminDeviceChallenge( + challengeId: string, +): Promise { + const result = await query( + `SELECT * FROM admin_device_challenges WHERE challenge_id = $1 LIMIT 1`, + [challengeId], + ) + return result.rows[0] || null +} + +export async function authorizeAdminDeviceChallenge(input: { + challengeId: string + app: string + deviceId: string + publicKey: string + clientVersion: string + now: string +}): Promise { + const result = await query( + ` + UPDATE admin_device_challenges + SET + status = 'authorized', + device_id = $3, + public_key = $4, + client_version = $5, + authorized_at = $6, + updated_at = $6 + WHERE challenge_id = $1 + AND app = $2 + AND status = 'pending' + AND expires_at > $6 + RETURNING * + `, + [input.challengeId, input.app, input.deviceId, input.publicKey, input.clientVersion, input.now], + ) + return result.rows[0] || null +} + +export async function issueAdminDeviceTicket(input: { + challengeId: string + ticketHash: string + now: string +}): Promise { + const result = await query( + ` + UPDATE admin_device_challenges + SET + status = 'ticket_issued', + device_ticket_hash = $2, + ticket_issued_at = $3, + updated_at = $3 + WHERE challenge_id = $1 + AND status = 'authorized' + AND expires_at > $3 + `, + [input.challengeId, input.ticketHash, input.now], + ) + return result.rowCount === 1 +} + +export async function consumeAdminDeviceTicket(input: { + challengeId: string + pollTokenHash: string + ticketHash: string + now: string +}): Promise { + const result = await query( + ` + UPDATE admin_device_challenges + SET status = 'consumed', consumed_at = $4, updated_at = $4 + WHERE challenge_id = $1 + AND poll_token_hash = $2 + AND device_ticket_hash = $3 + AND status = 'ticket_issued' + AND expires_at > $4 + RETURNING * + `, + [input.challengeId, input.pollTokenHash, input.ticketHash, input.now], + ) + return result.rows[0] || null +} + +export async function getActiveAdminUserDevice( + userId: number | string, +): Promise { + const result = await query( + ` + SELECT * + FROM admin_user_devices + WHERE user_id = $1 AND status = 'active' + ORDER BY id DESC + LIMIT 1 + `, + [Number(userId)], + ) + return result.rows[0] || null +} + +export async function bindAdminUserDevice(input: { + userId: number + deviceId: string + publicKey: string + clientVersion: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO admin_user_devices ( + 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, + 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.clientVersion, input.now], + ) + return result.rows[0] || null +} + +export async function touchAdminUserDevice(deviceId: string, now: string): Promise { + await query( + ` + UPDATE admin_user_devices + SET last_used_at = $2, updated_at = $2 + WHERE device_id = $1 AND status = 'active' + `, + [deviceId, now], + ) +} + +export async function resetAdminUserDevice( + userId: number | string, + revokedBy: number | null, + now: string, +): Promise { + const result = await query( + ` + UPDATE admin_user_devices + SET status = 'revoked', revoked_at = $2, revoked_by = $3, updated_at = $2 + WHERE user_id = $1 AND status = 'active' + `, + [Number(userId), now, revokedBy], + ) + return Number(result.rowCount || 0) > 0 +} diff --git a/apps/backend/src/repositories/admin-login-log-repo.ts b/apps/backend/src/repositories/admin-login-log-repo.ts index 7c536c30..fa67b4b9 100644 --- a/apps/backend/src/repositories/admin-login-log-repo.ts +++ b/apps/backend/src/repositories/admin-login-log-repo.ts @@ -12,6 +12,7 @@ export type AdminLoginLogRow = { user_agent: string success: boolean failure_reason: string + login_method: string created_at: string } @@ -24,6 +25,7 @@ type AdminLoginLogCreateInput = { userAgent?: string success?: boolean failureReason?: string + loginMethod?: string createdAt: string } @@ -49,8 +51,9 @@ export async function createAdminLoginLog( user_agent, success, failure_reason, + login_method, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * `, [ @@ -62,6 +65,7 @@ export async function createAdminLoginLog( String(input.userAgent || '').trim(), input.success !== false, String(input.failureReason || '').trim(), + String(input.loginMethod || 'password').trim() || 'password', input.createdAt, ], ) diff --git a/apps/backend/src/repositories/admin-session-repo.ts b/apps/backend/src/repositories/admin-session-repo.ts index 47594b61..245eade7 100644 --- a/apps/backend/src/repositories/admin-session-repo.ts +++ b/apps/backend/src/repositories/admin-session-repo.ts @@ -4,6 +4,7 @@ export type AdminSessionRow = { id: number session_id: string user_id: number + device_id: string status: string issued_at: string last_seen_at: string @@ -16,6 +17,7 @@ export type AdminSessionRow = { export async function createAdminSession(input: { sessionId: string userId: number + deviceId?: string issuedAt: string expiresAt: string now: string @@ -23,12 +25,19 @@ export async function createAdminSession(input: { const result = await query( ` INSERT INTO admin_sessions ( - session_id, user_id, status, issued_at, last_seen_at, + session_id, user_id, device_id, status, issued_at, last_seen_at, expires_at, revoked_at, created_at, updated_at - ) VALUES ($1, $2, 'active', $3, $3, $4, NULL, $5, $5) + ) VALUES ($1, $2, $3, 'active', $4, $4, $5, NULL, $6, $6) RETURNING * `, - [input.sessionId, input.userId, input.issuedAt, input.expiresAt, input.now], + [ + input.sessionId, + input.userId, + String(input.deviceId || '').trim(), + input.issuedAt, + input.expiresAt, + input.now, + ], ) return result.rows[0] || null } diff --git a/apps/backend/src/repositories/admin-sms-code-repo.ts b/apps/backend/src/repositories/admin-sms-code-repo.ts new file mode 100644 index 00000000..a4917c00 --- /dev/null +++ b/apps/backend/src/repositories/admin-sms-code-repo.ts @@ -0,0 +1,117 @@ +import { query } from '../db/client.js' + +export type AdminSmsCodeRow = { + id: number + user_id: number + phone: string + purpose: string + code_hash: string + expires_at: string + consumed_at: string | null + failed_attempt_count: number + request_ip: string + created_at: string +} + +export async function createAdminSmsCode(input: { + userId: number + phone: string + purpose: string + codeHash: string + expiresAt: string + requestIp: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO admin_sms_codes ( + user_id, phone, purpose, code_hash, expires_at, consumed_at, + failed_attempt_count, request_ip, created_at + ) VALUES ($1, $2, $3, $4, $5, NULL, 0, $6, $7) + RETURNING * + `, + [ + input.userId, + input.phone, + input.purpose, + input.codeHash, + input.expiresAt, + input.requestIp, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function getLatestAdminSmsCode( + userId: number, + purpose: string, +): Promise { + const result = await query( + ` + SELECT * + FROM admin_sms_codes + WHERE user_id = $1 AND purpose = $2 + ORDER BY id DESC + LIMIT 1 + `, + [userId, purpose], + ) + return result.rows[0] || null +} + +export async function consumeAdminSmsCode( + codeId: number, + now: string, +): Promise { + const result = await query( + ` + UPDATE admin_sms_codes + SET consumed_at = $1 + WHERE id = $2 AND consumed_at IS NULL + RETURNING * + `, + [now, codeId], + ) + return result.rows[0] || null +} + +export async function recordAdminSmsCodeFailure( + codeId: number, + now: string, + maxAttempts: number, +): Promise { + const result = await query( + ` + UPDATE admin_sms_codes + SET + failed_attempt_count = COALESCE(failed_attempt_count, 0) + 1, + consumed_at = CASE + WHEN COALESCE(failed_attempt_count, 0) + 1 >= $3 THEN $2 + ELSE consumed_at + END + WHERE id = $1 AND consumed_at IS NULL + RETURNING * + `, + [codeId, now, Math.max(1, Math.floor(maxAttempts))], + ) + return result.rows[0] || null +} + +export async function countAdminSmsCodesOnDay( + userId: number, + dayStartIso: string, + dayEndIso: string, +): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM admin_sms_codes + WHERE user_id = $1 + AND created_at >= $2 + AND created_at < $3 + `, + [userId, dayStartIso, dayEndIso], + ) + return Number(result.rows[0]?.total || 0) +} diff --git a/apps/backend/src/repositories/admin-user-repo.ts b/apps/backend/src/repositories/admin-user-repo.ts index e274d9fe..46909856 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -7,9 +7,16 @@ type AdminUserRow = { password_hash: string role: string status: string + phone: string + phone_bound_at: string | null + phone_bound_by: number | null + test_account: boolean session_version: number created_at: string updated_at: string + device_id?: string | null + device_bound_at?: string | null + device_last_used_at?: string | null } type AdminUserCreateInput = { @@ -18,6 +25,10 @@ type AdminUserCreateInput = { passwordHash: string role: string status: string + phone?: string + phoneBoundAt?: string | null + phoneBoundBy?: number | null + testAccount?: boolean createdAt: string updatedAt: string } @@ -30,6 +41,10 @@ type AdminUserPatch = Partial< | 'password_hash' | 'role' | 'status' + | 'phone' + | 'phone_bound_at' + | 'phone_bound_by' + | 'test_account' | 'session_version' | 'updated_at' > @@ -49,8 +64,14 @@ type AdminUserListResult = { } const ADMIN_USER_SELECT = ` - SELECT au.* + SELECT + au.*, + d.device_id AS device_id, + d.bound_at AS device_bound_at, + d.last_used_at AS device_last_used_at FROM admin_users au + LEFT JOIN admin_user_devices d + ON d.user_id = au.id AND d.status = 'active' ` export async function getAdminUserById(userId: number | string): Promise { @@ -69,6 +90,13 @@ export async function getAdminUserByUsername(username: string): Promise { + const result = await query(`${ADMIN_USER_SELECT} WHERE au.phone = $1 LIMIT 1`, [ + String(phone || '').trim(), + ]) + return result.rows[0] || null +} + export async function createAdminUser(input: AdminUserCreateInput): Promise { const result = await query<{ [column: string]: unknown; id: number }>( ` @@ -78,10 +106,14 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise + sendAdminLoginSmsCode(req.body?.username, req.body?.password, { + ip: resolveClientIp(req), + userAgent: resolveUserAgent(req), + location: resolveClientLocation(req), + }), + { + successMessage: '验证码已发送', + errorMessage: '验证码发送失败', + scope: '[admin/auth/login/sms-code]', + }, + ), +) + +router.post( + '/auth/login/prepare', + adminLoginIpRateLimit, + adminLoginAccountRateLimit, + createJsonHandler( + (req) => + prepareAdminLogin(req.body?.username, req.body?.password, req.body?.smsCode, { + ip: resolveClientIp(req), + userAgent: resolveUserAgent(req), + location: resolveClientLocation(req), + apiBase: resolveApiBase(req, req.body?.apiBase), + }), + { + successMessage: '请在本机 DCAuth 中确认登录', + errorMessage: '准备设备认证失败', + scope: '[admin/auth/login/prepare]', + }, + ), +) + +router.post( + '/auth/login/complete', + adminLoginIpRateLimit, + createJsonHandler( + (req) => + completeAdminLogin(req.body, { + ip: resolveClientIp(req), + userAgent: resolveUserAgent(req), + location: resolveClientLocation(req), + }), + { + successMessage: '登录成功', + errorMessage: '完成设备登录失败', + scope: '[admin/auth/login/complete]', + }, + ), +) + router.get( '/auth/session', createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { @@ -66,3 +127,47 @@ router.post( ) export default router + +function resolveApiBase( + req: { headers: Record; protocol?: string }, + value: unknown, +) { + const forwardedProto = String(req.headers['x-forwarded-proto'] || '') + .split(',')[0] + ?.trim() + const forwardedHost = String(req.headers['x-forwarded-host'] || '') + .split(',')[0] + ?.trim() + const origin = String(value || req.headers.origin || '').trim() + const raw = + origin || + `${forwardedProto || req.protocol || 'http'}://${forwardedHost || String(req.headers.host || '')}` + let parsed: URL + try { + parsed = new URL(raw) + } catch { + throw createHttpError('登录来源地址无效', { + statusCode: 400, + errorCode: 'admin_api_base_invalid', + }) + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw createHttpError('登录来源地址必须使用 http 或 https', { + statusCode: 400, + errorCode: 'admin_api_base_invalid', + }) + } + if ( + parsed.pathname !== '/' || + parsed.search || + parsed.hash || + parsed.username || + parsed.password + ) { + throw createHttpError('登录来源地址必须是不带路径的 origin', { + statusCode: 400, + errorCode: 'admin_api_base_invalid', + }) + } + return `${parsed.protocol}//${parsed.host}` +} diff --git a/apps/backend/src/routes/admin/users.ts b/apps/backend/src/routes/admin/users.ts index d5c1b7be..ae577030 100644 --- a/apps/backend/src/routes/admin/users.ts +++ b/apps/backend/src/routes/admin/users.ts @@ -4,7 +4,10 @@ import { createManagedAdminUser, getAdminUserList, resetManagedAdminUserPassword, + resetManagedAdminUserDevice, updateManagedAdminUserDisplayName, + updateManagedAdminUserPhone, + updateManagedAdminUserTestAccount, updateManagedAdminUserRole, updateManagedAdminUserStatus, } from '../../services/admin/admin-auth-service.js' @@ -160,4 +163,73 @@ router.post( ), ) +router.post( + '/users/:userId/phone', + createJsonHandler( + (req) => + updateManagedAdminUserPhone( + String(req.params.userId || ''), + req.body, + getRequiredAdminSession(req), + ), + { + successMessage: '手机号已绑定', + errorMessage: '绑定手机号失败', + scope: '[admin/users/:userId/phone]', + audit: (_req, data) => { + const result = data as AdminUserMutationResult + return { + action: 'admin_user_phone_updated', + targetType: 'admin_user', + targetId: String(result.user.userId), + data: { username: result.user.username }, + } + }, + }, + ), +) + +router.post( + '/users/:userId/device/reset', + createJsonHandler( + (req) => + resetManagedAdminUserDevice(String(req.params.userId || ''), getRequiredAdminSession(req)), + { + successMessage: '设备绑定已重置', + errorMessage: '重置设备绑定失败', + scope: '[admin/users/:userId/device/reset]', + audit: (_req, data) => { + const result = data as AdminUserMutationResult + return { + action: 'admin_user_device_reset', + targetType: 'admin_user', + targetId: String(result.user.userId), + data: { username: result.user.username }, + } + }, + }, + ), +) + +router.post( + '/users/:userId/test-account', + createJsonHandler( + (req) => updateManagedAdminUserTestAccount(String(req.params.userId || ''), req.body), + { + successMessage: '测试账号设置已更新', + errorMessage: '更新测试账号设置失败', + scope: '[admin/users/:userId/test-account]', + audit: (_req, data) => { + const result = data as AdminUserMutationResult + return { + action: 'admin_user_test_account_updated', + targetType: 'admin_user', + targetId: String(result.user.userId), + data: { username: result.user.username }, + } + }, + }, + ), +) + export default router diff --git a/apps/backend/src/routes/dcauth.ts b/apps/backend/src/routes/dcauth.ts new file mode 100644 index 00000000..3773690e --- /dev/null +++ b/apps/backend/src/routes/dcauth.ts @@ -0,0 +1,38 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js' +import { + authorizeAdminDevice, + getAdminDeviceChallengeStatus, +} from '../services/admin/admin-device-auth-service.js' +import { buildSuccessPayload, sendRouteError } from '../utils/http.js' + +const router = Router() + +const authorizeRateLimit = createRateLimitMiddleware({ + scope: 'admin:device:authorize', + windowMs: 60_000, + max: 30, + key: getBodyFieldRateLimitKey('challengeId'), +}) + +router.post('/authorize', authorizeRateLimit, async (req, res) => { + try { + await authorizeAdminDevice((req.body || {}) as Record) + res.json(buildSuccessPayload({ authorized: true }, '设备已确认')) + } catch (error) { + sendRouteError(res, error, '设备确认失败', '[dcauth/authorize]') + } +}) + +router.get('/challenge/:challengeId/status', async (req, res) => { + try { + const pollToken = String(req.headers['x-poll-token'] || req.query.pollToken || '').trim() + const data = await getAdminDeviceChallengeStatus(req.params.challengeId, pollToken) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取设备认证状态失败', '[dcauth/status]') + } +}) + +export default router diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index b7001207..680637c4 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -6,10 +6,12 @@ import { countActiveAdminUsers, createAdminUser, getAdminUserById, + getAdminUserByPhone, getAdminUserByUsername, listAdminUsers, updateAdminUser, } from '../../repositories/admin-user-repo.js' +import { resetAdminUserDevice as resetAdminUserDeviceRecord } from '../../repositories/admin-device-repo.js' import { createAdminSession as createAdminSessionRecord, getActiveAdminSession, @@ -21,6 +23,12 @@ import { createHttpError } from '../../utils/http.js' import { normalizePage, normalizePageSize } from './admin-query-utils.js' import { recordAdminLoginLog } from './admin-login-log-service.js' import { listAdminPermissionKeysForRole } from './admin-rbac-service.js' +import { + consumeAdminDeviceLogin, + createAdminLoginDeviceChallenge, + ensureAdminDeviceBinding, +} from './admin-device-auth-service.js' +import { maskPhone, normalizeAdminPhone, verifyAdminLoginSmsCode } from './admin-sms-service.js' type AdminUserRow = NonNullable>> @@ -32,6 +40,7 @@ export type AdminSession = { role: AdminRole expiresAt: string sessionVersion: number + deviceId: string permissions: string[] } @@ -123,10 +132,18 @@ export async function loginAdmin( }) } + if (String(user.phone || '').trim() && user.test_account !== true) { + throw createHttpError('该账号已启用安全登录,请使用账号密码 + 短信验证码 + 本机验证', { + statusCode: 403, + errorCode: 'admin_device_auth_required', + }) + } + const session = createAdminSession(user) const createdSession = await createAdminSessionRecord({ sessionId: String(session.sessionId || ''), userId: Number(user.id), + deviceId: '', issuedAt: String(session.issuedAt), expiresAt: String(session.expiresAt), now: nowIso(), @@ -142,11 +159,153 @@ export async function loginAdmin( username: String(user.username || normalizedUsername), role: normalizeAdminRole(user.role), success: true, + loginMethod: user.test_account === true ? 'password_test' : 'password', ...pickLoginMeta(meta), }) return session } +export async function prepareAdminLogin( + usernameValue: unknown, + passwordValue: unknown, + smsCodeValue: unknown, + meta: { + ip?: string + userAgent?: string + location?: string + apiBase: string + }, +): Promise { + ensureAdminAuthConfigured() + const normalizedUsername = normalizeUsername(usernameValue) + const normalizedPassword = String(passwordValue || '').trim() + if (!normalizedUsername || !normalizedPassword) { + throw createHttpError('缺少后台账号或密码', { + statusCode: 400, + errorCode: 'admin_credentials_required', + }) + } + + const user = await getAdminUserByUsername(normalizedUsername) + if ( + !user || + user.status !== 'active' || + !verifyAdminPassword(normalizedPassword, user.password_hash) + ) { + await recordAdminLoginLog({ + userId: user ? Number(user.id) : null, + username: normalizedUsername, + role: user ? normalizeAdminRole(user.role) : '', + success: false, + failureReason: 'invalid_credentials', + ...pickLoginMeta(meta), + }) + throw createHttpError('账号或密码错误', { + statusCode: 401, + errorCode: 'admin_login_failed', + }) + } + if (!String(user.phone || '').trim()) { + throw createHttpError('该账号尚未绑定手机号,请联系管理员', { + statusCode: 409, + errorCode: 'admin_phone_unbound', + }) + } + + await verifyAdminLoginSmsCode(Number(user.id), smsCodeValue) + const challenge = await createAdminLoginDeviceChallenge(Number(user.id), meta.apiBase) + return { + ...challenge, + phoneMasked: maskPhone(user.phone), + app: 'order_site', + } +} + +export async function completeAdminLogin( + payload: JsonObject = {}, + meta: { + ip?: string + userAgent?: string + location?: string + } = {}, +): Promise { + ensureAdminAuthConfigured() + const challengeId = String(payload.challengeId || '').trim() + const pollToken = String(payload.pollToken || '').trim() + const deviceTicket = String(payload.deviceTicket || '').trim() + if (!challengeId || !pollToken || !deviceTicket) { + throw createHttpError('缺少设备认证票据', { + statusCode: 400, + errorCode: 'admin_device_ticket_required', + }) + } + + const challenge = await getAdminDeviceChallengeForLogin(challengeId) + const user = await getAdminUserById(Number(challenge.user_id)) + if (!user || user.status !== 'active') { + throw createHttpError('后台账号已不可用,请重新登录', { + statusCode: 401, + errorCode: 'admin_auth_user_invalid', + }) + } + + const consumed = await consumeAdminDeviceLogin(Number(user.id), { + challengeId, + pollToken, + deviceTicket, + }) + await ensureAdminDeviceBinding(Number(user.id), consumed) + + return createAdminLoginSession(user, consumed.device_id, meta) +} + +async function createAdminLoginSession( + user: AdminUserRow, + deviceId: string, + meta: { + ip?: string + userAgent?: string + location?: string + }, +): Promise { + const session = createAdminSession(user, deviceId) + const createdSession = await createAdminSessionRecord({ + sessionId: String(session.sessionId || ''), + userId: Number(user.id), + deviceId, + issuedAt: String(session.issuedAt), + expiresAt: String(session.expiresAt), + now: nowIso(), + }) + if (!createdSession) { + throw createHttpError('后台登录会话创建失败,请重试', { + statusCode: 503, + errorCode: 'admin_session_create_failed', + }) + } + await recordAdminLoginLog({ + userId: Number(user.id), + username: String(user.username || ''), + role: normalizeAdminRole(user.role), + success: true, + loginMethod: 'sms_device', + ...pickLoginMeta(meta), + }) + return session +} + +async function getAdminDeviceChallengeForLogin(challengeId: string) { + const { getAdminDeviceChallenge } = await import('../../repositories/admin-device-repo.js') + const challenge = await getAdminDeviceChallenge(challengeId) + if (!challenge || !challenge.user_id) { + throw createHttpError('设备认证会话不存在,请重新登录', { + statusCode: 404, + errorCode: 'admin_device_challenge_not_found', + }) + } + return challenge +} + export async function verifyAdminSessionToken(token: unknown): Promise { ensureAdminAuthConfigured() @@ -236,6 +395,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise { + const user = await getRequiredAdminUser(userId) + const phone = normalizeAdminPhone(payload.phone) + if (!phone) { + throw createHttpError('请输入手机号', { + statusCode: 400, + errorCode: 'admin_phone_required', + }) + } + if (phone === String(user.phone || '').trim()) { + return { user: mapAdminUser(user) } + } + const existing = await getAdminUserByPhone(phone) + if (existing && Number(existing.id) !== Number(user.id)) { + throw createHttpError('该手机号已绑定其他后台账号', { + statusCode: 409, + errorCode: 'admin_phone_exists', + }) + } + + const now = nowIso() + const updated = await updateAdminUser(user.id, { + phone, + phone_bound_at: now, + phone_bound_by: session.userId, + session_version: nextAdminSessionVersion(user), + updated_at: now, + }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } + return { user: mapAdminUser(updated) } +} + +export async function updateManagedAdminUserTestAccount( + userId: number | string, + payload: JsonObject = {}, +): Promise { + const user = await getRequiredAdminUser(userId) + const testAccount = payload.testAccount === true + const updated = await updateAdminUser(user.id, { + test_account: testAccount, + session_version: nextAdminSessionVersion(user), + updated_at: nowIso(), + }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } + return { + user: mapAdminUser(updated), + testAccount, + } +} + +export async function resetManagedAdminUserDevice( + userId: number | string, + session: AdminSession, +): Promise { + const user = await getRequiredAdminUser(userId) + const reset = await resetAdminUserDeviceRecord(user.id, session.userId, nowIso()) + const updated = await updateAdminUser(user.id, { + session_version: nextAdminSessionVersion(user), + updated_at: nowIso(), + }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } + return { + user: mapAdminUser(updated), + reset, + } +} + export async function resetManagedAdminUserPassword( userId: number | string, payload: JsonObject = {}, @@ -478,7 +736,7 @@ export function ensureAdminAuthConfigured(): void { }) } -function createAdminSession(user: AdminUserRow): JsonObject { +function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject { const issuedAt = nowIso() const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12)) const payload = { @@ -486,6 +744,7 @@ function createAdminSession(user: AdminUserRow): JsonObject { uid: Number(user.id), usr: String(user.username || ''), role: normalizeAdminRole(user.role), + dev: String(deviceId || ''), ver: normalizeAdminSessionVersion(user.session_version), iat: issuedAt, exp: expiresAt, @@ -513,7 +772,7 @@ export function hashAdminPassword(password: string): string { return `scrypt$${salt}$${derived}` } -function verifyAdminPassword(password: string, storedHash: string): boolean { +export function verifyAdminPassword(password: string, storedHash: string): boolean { const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') if (algorithm !== 'scrypt' || !salt || !expectedHash) { return false @@ -586,6 +845,7 @@ function normalizeStatusQuery(status: unknown): string { function normalizeUsername(username: unknown): string { return String(username || '') .trim() + .normalize('NFKC') .toLowerCase() } @@ -615,8 +875,8 @@ function normalizePassword(password: unknown): string { } function validateUsername(username: string): void { - if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) { - throw createHttpError('后台账号格式无效,需为 3-32 位字母数字或 ._-', { + if (!/^[\p{L}\p{N}._-]{2,32}$/u.test(username)) { + throw createHttpError('后台账号格式无效,需为 2-32 位中文、字母、数字或 ._-', { statusCode: 400, errorCode: 'admin_user_username_invalid', }) @@ -688,11 +948,28 @@ function mapAdminUser(user: AdminUserRow): JsonObject { displayName: String(user.display_name || user.username || ''), role: normalizeAdminRole(user.role), status: normalizeAdminUserStatus(user.status), + phone: String(user.phone || ''), + phoneMasked: maskPhone(user.phone), + phoneBoundAt: user.phone_bound_at || null, + testAccount: user.test_account === true, + deviceBound: Boolean(user.device_id), + deviceId: String(user.device_id || ''), + deviceIdMasked: maskDeviceId(user.device_id), + deviceBoundAt: user.device_bound_at || null, + deviceLastUsedAt: user.device_last_used_at || null, createdAt: user.created_at, updatedAt: user.updated_at, } } +function maskDeviceId(value: unknown): string { + const deviceId = String(value || '') + .trim() + .toUpperCase() + if (deviceId.length <= 8) return deviceId + return `${deviceId.slice(0, 6)}-****-${deviceId.slice(-4)}` +} + function pickLoginMeta( meta: { ip?: string 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 new file mode 100644 index 00000000..2c301bfa --- /dev/null +++ b/apps/backend/src/services/admin/admin-device-auth-service.ts @@ -0,0 +1,348 @@ +import crypto from 'node:crypto' + +import { + authorizeAdminDeviceChallenge, + bindAdminUserDevice, + consumeAdminDeviceTicket, + createAdminDeviceChallenge, + getActiveAdminUserDevice, + getAdminDeviceChallenge, + issueAdminDeviceTicket, + touchAdminUserDevice, + type AdminDeviceChallengeRow, + type AdminUserDeviceRow, +} from '../../repositories/admin-device-repo.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' + +export const ADMIN_DEVICE_APP = 'order_site' +const CHALLENGE_TTL_SECONDS = 120 +const MAX_CLOCK_SKEW_SECONDS = 300 +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +export type AdminDeviceChallengeResult = { + challengeId: string + challenge: string + expiresAt: string + pollToken: string + launchUrl: string +} + +export async function createAdminLoginDeviceChallenge( + userId: number, + apiBase: string, +): Promise { + const challengeId = crypto.randomUUID() + const challenge = crypto.randomBytes(32).toString('base64url') + const pollToken = crypto.randomBytes(32).toString('base64url') + const now = nowIso() + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_SECONDS * 1000).toISOString() + + const created = await createAdminDeviceChallenge({ + challengeId, + app: ADMIN_DEVICE_APP, + purpose: 'login', + challenge, + pollTokenHash: hashToken(pollToken), + userId, + expiresAt, + now, + }) + if (!created) { + throw createHttpError('创建设备认证会话失败,请重试', { + statusCode: 500, + errorCode: 'admin_device_challenge_create_failed', + }) + } + + return { + challengeId, + challenge, + expiresAt, + pollToken, + launchUrl: buildLaunchUrl(apiBase, challengeId, challenge), + } +} + +export async function authorizeAdminDevice(payload: Record): Promise { + const app = String(payload.app || '').trim() + if (app !== ADMIN_DEVICE_APP) { + throw createHttpError('设备认证应用不匹配', { + statusCode: 400, + errorCode: 'admin_device_app_mismatch', + }) + } + + const challengeId = String(payload.challengeId || '').trim() + const challengeValue = String(payload.challenge || '').trim() + const deviceId = String(payload.deviceId || '') + .trim() + .toUpperCase() + const publicKey = String(payload.publicKey || '').trim() + const signature = String(payload.signature || '').trim() + const timestamp = Number(payload.timestamp || 0) + const clientVersion = String(payload.clientVersion || '') + .trim() + .slice(0, 32) + + 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 ( + !Number.isInteger(timestamp) || + Math.abs(Date.now() / 1000 - timestamp) > MAX_CLOCK_SKEW_SECONDS + ) { + throw createHttpError('设备时间与服务器偏差过大,请校准系统时间', { + statusCode: 400, + errorCode: 'admin_device_clock_invalid', + }) + } + + const challenge = await getAdminDeviceChallenge(challengeId) + if (!challenge || challenge.app !== ADMIN_DEVICE_APP || challenge.purpose !== 'login') { + throw createHttpError('设备认证会话不存在', { + statusCode: 404, + errorCode: 'admin_device_challenge_not_found', + }) + } + if (!safeEqual(challenge.challenge, challengeValue)) { + throw createHttpError('设备认证 challenge 不匹配', { + statusCode: 400, + errorCode: 'admin_device_challenge_mismatch', + }) + } + if (challenge.status !== 'pending' || new Date(challenge.expires_at).getTime() <= Date.now()) { + throw createHttpError('设备认证会话已失效,请重新登录', { + statusCode: 409, + errorCode: 'admin_device_challenge_expired', + }) + } + + const publicKeyBytes = decodeBase64Url(publicKey) + const signatureBytes = decodeBase64Url(signature) + if (publicKeyBytes.length !== 32 || signatureBytes.length !== 64) { + throw createHttpError('设备公钥或签名格式不正确', { + statusCode: 400, + errorCode: 'admin_device_signature_invalid', + }) + } + + const canonical = buildAdminDeviceCanonical({ + app, + challengeId, + challenge: challengeValue, + deviceId, + timestamp, + }) + const keyObject = crypto.createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]), + format: 'der', + type: 'spki', + }) + const verified = crypto.verify(null, Buffer.from(canonical, 'utf8'), keyObject, signatureBytes) + if (!verified) { + throw createHttpError('设备签名校验失败', { + statusCode: 401, + errorCode: 'admin_device_signature_invalid', + }) + } + + const authorized = await authorizeAdminDeviceChallenge({ + challengeId, + app, + deviceId, + publicKey, + clientVersion, + now: nowIso(), + }) + if (!authorized) { + throw createHttpError('设备认证会话已被使用,请重新登录', { + statusCode: 409, + errorCode: 'admin_device_challenge_used', + }) + } +} + +export async function getAdminDeviceChallengeStatus( + challengeIdValue: unknown, + pollTokenValue: unknown, +): Promise> { + const challengeId = String(challengeIdValue || '').trim() + const pollToken = String(pollTokenValue || '').trim() + if (!challengeId || !pollToken) { + throw createHttpError('缺少设备认证参数', { + statusCode: 400, + errorCode: 'admin_device_poll_invalid', + }) + } + + const challenge = await getAdminDeviceChallenge(challengeId) + if (!challenge) { + return { status: 'rejected', message: '设备认证会话不存在' } + } + if (!safeEqual(challenge.poll_token_hash, hashToken(pollToken))) { + throw createHttpError('设备认证轮询密钥无效', { + statusCode: 403, + errorCode: 'admin_device_poll_forbidden', + }) + } + if (new Date(challenge.expires_at).getTime() <= Date.now() && challenge.status !== 'consumed') { + return { status: 'expired', message: '设备认证会话已过期' } + } + if (challenge.status === 'authorized') { + const ticket = crypto.randomBytes(32).toString('base64url') + const issued = await issueAdminDeviceTicket({ + challengeId, + ticketHash: hashToken(ticket), + now: nowIso(), + }) + if (issued) { + return { + status: 'authorized', + deviceTicket: ticket, + deviceId: challenge.device_id, + } + } + const latest = await getAdminDeviceChallenge(challengeId) + return { status: latest?.status || 'pending' } + } + return { + status: challenge.status, + deviceId: challenge.device_id || '', + } +} + +export async function consumeAdminDeviceLogin( + userId: number, + payload: Record, +): Promise { + const challengeId = String(payload.challengeId || '').trim() + const pollToken = String(payload.pollToken || '').trim() + const deviceTicket = String(payload.deviceTicket || '').trim() + if (!challengeId || !pollToken || !deviceTicket) { + throw createHttpError('缺少设备认证票据', { + statusCode: 400, + errorCode: 'admin_device_ticket_required', + }) + } + + const challenge = await consumeAdminDeviceTicket({ + challengeId, + pollTokenHash: hashToken(pollToken), + ticketHash: hashToken(deviceTicket), + now: nowIso(), + }) + if (!challenge || Number(challenge.user_id) !== Number(userId)) { + throw createHttpError('设备认证票据无效或已使用,请重新登录', { + statusCode: 401, + errorCode: 'admin_device_ticket_invalid', + }) + } + return challenge +} + +export async function ensureAdminDeviceBinding( + userId: number, + challenge: AdminDeviceChallengeRow, +): Promise { + const current = await getActiveAdminUserDevice(userId) + if (current) { + if ( + !safeEqual(current.device_id, challenge.device_id) || + !safeEqual(current.public_key, challenge.public_key) + ) { + throw createHttpError('当前设备与账号绑定设备不一致,请联系管理员重置设备', { + statusCode: 401, + errorCode: 'admin_device_mismatch', + }) + } + await touchAdminUserDevice(current.device_id, nowIso()) + return current + } + + const bound = await bindAdminUserDevice({ + userId, + deviceId: challenge.device_id, + publicKey: challenge.public_key, + clientVersion: challenge.client_version, + now: nowIso(), + }) + if (!bound) { + throw createHttpError('绑定本机设备失败,请重试', { + statusCode: 500, + errorCode: 'admin_device_bind_failed', + }) + } + return bound +} + +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 isSupportedAdminDeviceClientVersion(clientVersion: string): boolean { + const major = Number(String(clientVersion || '').split('.')[0]) + return Number.isInteger(major) && major >= 2 +} + +function hashToken(value: string): string { + return crypto + .createHash('sha256') + .update(String(value || '')) + .digest('hex') +} + +function decodeBase64Url(value: string): Buffer { + if (!/^[A-Za-z0-9_-]+$/.test(value)) { + throw createHttpError('设备签名编码无效', { + statusCode: 400, + errorCode: 'admin_device_signature_invalid', + }) + } + return Buffer.from(value, 'base64url') +} + +function safeEqual(input: unknown, expected: unknown): boolean { + const left = Buffer.from(String(input || ''), 'utf8') + const right = Buffer.from(String(expected || ''), 'utf8') + if (left.length !== right.length) return false + return crypto.timingSafeEqual(left, right) +} + +function buildLaunchUrl(apiBase: string, challengeId: string, challenge: string): string { + const query = new URLSearchParams({ + app: ADMIN_DEVICE_APP, + challenge_id: challengeId, + challenge, + api: apiBase, + }) + return `dcauth://authorize?${query.toString()}` +} 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 5f16c198..fa31ff4e 100644 --- a/apps/backend/src/services/admin/admin-login-log-service.ts +++ b/apps/backend/src/services/admin/admin-login-log-service.ts @@ -15,6 +15,7 @@ type RecordAdminLoginInput = { ip?: string location?: string userAgent?: string + loginMethod?: string } export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) { @@ -28,6 +29,7 @@ export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) { userAgent: String(input.userAgent || '').trim(), success: input.success !== false, failureReason: String(input.failureReason || '').trim(), + loginMethod: String(input.loginMethod || 'password').trim() || 'password', createdAt: nowIso(), }) } catch (error) { @@ -74,6 +76,7 @@ export async function getAdminLoginLogs(query: JsonObject = {}) { userAgent: String(item.user_agent || '').trim(), success: item.success !== false, failureReason: String(item.failure_reason || '').trim(), + loginMethod: String(item.login_method || 'password').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 new file mode 100644 index 00000000..26313190 --- /dev/null +++ b/apps/backend/src/services/admin/admin-sms-service.ts @@ -0,0 +1,228 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { getAdminUserByUsername } from '../../repositories/admin-user-repo.js' +import { + consumeAdminSmsCode, + countAdminSmsCodesOnDay, + createAdminSmsCode, + getLatestAdminSmsCode, + recordAdminSmsCodeFailure, +} from '../../repositories/admin-sms-code-repo.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { getSmsProvider } from '../sms/index.js' +import { verifyAdminPassword } from './admin-auth-service.js' +import { recordAdminLoginLog } from './admin-login-log-service.js' + +const MAX_VERIFY_ATTEMPTS = 5 +const SMS_PURPOSE = 'login' + +export type AdminSmsMeta = { + ip?: string + userAgent?: string + location?: string +} + +export async function sendAdminLoginSmsCode( + usernameValue: unknown, + passwordValue: unknown, + meta: AdminSmsMeta = {}, +): Promise> { + const username = normalizeAdminUsername(usernameValue) + const password = String(passwordValue || '').trim() + if (!username || !password) { + throw createHttpError('缺少账号或密码', { + statusCode: 400, + errorCode: 'admin_credentials_required', + }) + } + + const user = await getAdminUserByUsername(username) + if ( + !user || + user.status !== 'active' || + !verifyAdminPassword(password, String(user.password_hash || '')) + ) { + await recordAdminLoginLog({ + userId: user ? Number(user.id) : null, + username, + role: user ? String(user.role || '') : '', + success: false, + failureReason: 'invalid_credentials', + ...pickMeta(meta), + }) + throw createHttpError('账号或密码错误', { + statusCode: 401, + errorCode: 'admin_login_failed', + }) + } + + const phone = normalizeAdminPhone(user.phone) + if (!phone) { + throw createHttpError('该账号尚未绑定手机号,请联系管理员', { + statusCode: 409, + errorCode: 'admin_phone_unbound', + }) + } + + 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)) + const latest = await getLatestAdminSmsCode(Number(user.id), SMS_PURPOSE) + if ( + latest && + !latest.consumed_at && + new Date(latest.created_at).getTime() > Date.now() - cooldownMs + ) { + throw createHttpError('验证码发送过于频繁,请稍后再试', { + statusCode: 429, + errorCode: 'admin_sms_cooldown', + }) + } + + const dayRange = resolveChinaDayRange() + if ( + shouldEnforceAdminSmsDailyLimit(provider) && + (await countAdminSmsCodesOnDay(Number(user.id), dayRange.start, dayRange.end)) >= dailyLimit + ) { + throw createHttpError('今日验证码发送次数已达上限,请明天再试', { + statusCode: 429, + errorCode: 'admin_sms_daily_limit', + }) + } + + const code = generateSmsCode() + await getSmsProvider().sendCode(phone, code) + await createAdminSmsCode({ + userId: Number(user.id), + phone, + purpose: SMS_PURPOSE, + codeHash: hashSmsCode(code), + expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), + requestIp: String(meta.ip || '').trim(), + now: nowIso(), + }) + + return { + sent: true, + phoneMasked: maskPhone(phone), + expiresInSeconds: ttlSeconds, + provider: String(config.provider || 'mock'), + } +} + +export async function verifyAdminLoginSmsCode(userId: number, codeValue: unknown): Promise { + const code = String(codeValue || '').trim() + if (!code) { + throw createHttpError('请输入短信验证码', { + statusCode: 400, + errorCode: 'admin_sms_code_required', + }) + } + + const latest = await getLatestAdminSmsCode(userId, SMS_PURPOSE) + const valid = Boolean( + latest && + !latest.consumed_at && + new Date(latest.expires_at).getTime() > Date.now() && + latest.code_hash === hashSmsCode(code), + ) + if (!valid) { + if (latest && !latest.consumed_at) { + const failed = await recordAdminSmsCodeFailure(latest.id, nowIso(), MAX_VERIFY_ATTEMPTS) + if (failed?.consumed_at) { + throw createHttpError('验证码错误次数过多,请重新获取验证码', { + statusCode: 400, + errorCode: 'admin_sms_code_locked', + }) + } + } + throw createHttpError('短信验证码错误或已过期', { + statusCode: 400, + errorCode: 'admin_sms_code_invalid', + }) + } + + if (!(await consumeAdminSmsCode(latest!.id, nowIso()))) { + throw createHttpError('验证码已失效,请重新获取', { + statusCode: 400, + errorCode: 'admin_sms_code_invalid', + }) + } +} + +export function normalizeAdminPhone(value: unknown): string { + const phone = String(value || '').trim() + if (!phone) return '' + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw createHttpError('手机号格式不正确', { + statusCode: 400, + errorCode: 'admin_phone_invalid', + }) + } + return phone +} + +export function maskPhone(phoneValue: unknown): string { + const phone = String(phoneValue || '').trim() + if (!/^1[3-9]\d{9}$/.test(phone)) return phone + return `${phone.slice(0, 3)}****${phone.slice(-4)}` +} + +export function hashSmsCode(code: string): string { + return crypto + .createHash('sha256') + .update(String(code || '')) + .digest('hex') +} + +function generateSmsCode(): string { + let code = '' + for (let index = 0; index < 6; index += 1) { + code += String(crypto.randomInt(10)) + } + return code +} + +export function shouldEnforceAdminSmsDailyLimit(providerValue: unknown): boolean { + return ( + String(providerValue || 'mock') + .trim() + .toLowerCase() !== 'mock' + ) +} + +function normalizeAdminUsername(value: unknown): string { + return String(value || '') + .trim() + .normalize('NFKC') + .toLowerCase() +} + +function resolveChinaDayRange(): { start: string; end: string } { + const now = new Date() + const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000) + const year = chinaNow.getUTCFullYear() + const month = chinaNow.getUTCMonth() + const day = chinaNow.getUTCDate() + const start = new Date(Date.UTC(year, month, day) - 8 * 60 * 60 * 1000) + const end = new Date(Date.UTC(year, month, day + 1) - 8 * 60 * 60 * 1000) + return { start: start.toISOString(), end: end.toISOString() } +} + +function pickMeta(meta: AdminSmsMeta) { + const result: AdminSmsMeta = {} + if (typeof meta.ip === 'string' && meta.ip.trim()) result.ip = meta.ip.trim() + if (typeof meta.userAgent === 'string' && meta.userAgent.trim()) { + result.userAgent = meta.userAgent.trim() + } + if (typeof meta.location === 'string' && meta.location.trim()) { + result.location = meta.location.trim() + } + return result +} diff --git a/apps/dcauth/README.md b/apps/dcauth/README.md new file mode 100644 index 00000000..bbd4235e --- /dev/null +++ b/apps/dcauth/README.md @@ -0,0 +1,151 @@ +# DCAuth 客户端 + +三个系统共用的设备认证客户端,通过 `dcauth://` 协议被浏览器唤起。 + +## 运行方式 + +- **Windows**:GUI 常驻程序。启动后进入系统托盘,通过协议唤起时只出现一个登录确认窗口;成功后在托盘气泡中提示,不再连续弹多个消息框。 +- **macOS**:`DCAuth.app` 注册 `dcauth://`,浏览器可直接唤起;内部是后台常驻进程 + 系统图形确认框,重复唤起会转发给已有进程,不会叠加实例。 + +## 构建 + +Windows x64: + +```bash +cd apps/dcauth +x86_64-w64-mingw32-windres --codepage=65001 versioninfo.rc \ + -O coff -o versioninfo_windows_amd64.syso +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.1.exe +``` + +版本号统一在 `main.go` 的 `clientVersion` 和 `versioninfo.rc` 中维护。`DCAuth.exe` 右键“属性 → 详细信息”会显示文件版本 `2.0.1.0` 和产品版本 `2.0.1`,托盘菜单、设备信息及确认窗口也会显示当前版本。 + +Windows ARM64: + +```bash +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 \ + go build -trimpath -ldflags "-H windowsgui -s -w" \ + -o dist/DCAuth-arm64.exe . +``` + +macOS 通用二进制: + +```bash +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ + go build -trimpath -ldflags "-s -w" -o /tmp/DCAuth-macos-arm64 . + +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w" -o /tmp/DCAuth-macos-amd64 . + +lipo -create -output dist/DCAuth-macos \ + /tmp/DCAuth-macos-arm64 /tmp/DCAuth-macos-amd64 + +chmod +x dist/DCAuth-macos +codesign --force --sign - dist/DCAuth-macos +``` + +## Windows 首次运行 + +直接运行一次 `DCAuth.exe`: + +- 注册 `dcauth://` 协议到当前 Windows 用户。 +- 生成 Ed25519 设备密钥,使用 Windows DPAPI 加密保存在 `%LOCALAPPDATA%\DCAuth\device.key`。 +- 进入系统托盘后台运行,不弹设备信息窗口。 + +托盘操作: + +- 双击图标:查看设备码并复制到剪贴板。 +- 右键菜单:查看设备码、退出 DCAuth。 +- 浏览器登录:只弹出一个确认窗口;点击“确定”后显示成功气泡,点击“取消”则拒绝。 +- 再次唤起协议:第二个进程会把请求转交给常驻进程,然后立即退出。 + +## 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://...` 链接。复制后在终端执行: + +```bash +cd apps/dcauth +./dist/DCAuth-macos "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=http%3A%2F%2Flocalhost" +``` + +首次执行会启动后台进程并显示图形确认框;命令本身会立即返回。后续协议请求会转发给这个后台进程。 + +停止后台进程: + +```bash +./dist/DCAuth-macos --exit +``` + +macOS 当前仍是开发版:私钥明文保存在 `~/Library/Application Support/DCAuth/device.key`(没有 DPAPI 等系统级保护),不要把这一版当成生产级凭证。安装 `DCAuth.app` 后浏览器可以自动唤起,但密钥保护强度仍低于 Windows。 + +如果只是测后台业务,不想每次都走短信和设备验证,可以在“后台用户”里把测试账号设为 **技术测试**。该账号只使用账号密码登录;正式账号仍必须走短信 + DCAuth。 + +## 浏览器联动 + +登录页创建 challenge 后跳转: + +```text +dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=https%3A%2F%2Fks.khhao.com +``` + +客户端校验 app 和 api 白名单后,弹出一次确认框,签名并提交到: + +```text +POST /api/dcauth/authorize +``` + +浏览器轮询 challenge 状态,拿到一次性 ticket 后完成登录。 + +## 支持的应用 + +| app | 生产入口 | +|---|---| +| `order_site` | `https://ks.khhao.com`(历史入口 `https://order.khhao.com` 仍放行) | +| `live_hub` | `https://live.khhao.com` | +| `affiliate_dash` | `https://skin.khhao.com` | + +本地开发入口已内置在 `main.go` 的 `appRegistry` 中,包括 `http://localhost`、`http://localhost/`、`http://127.0.0.1`、`http://192.168.10.6` 及对应 Vite 端口。 + +## 验证 + +```bash +go test ./... +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth.exe . +``` + +## 安全说明 + +- 私钥不离开本机,Windows 使用 DPAPI 保护。 +- 服务端只保存设备公钥和设备码,不采集或保存硬件序列号。 +- 设备码只由公钥派生,不包含硬件指纹;更换硬件不会改变设备码。 +- 正式分发前建议使用代码签名证书,避免 SmartScreen 和杀毒软件误报。 +- 如果要做到 token 也不能复制,需要后续让 exe 对每次 API 请求签名,或把 exe 作为唯一客户端。 diff --git a/apps/dcauth/base32.go b/apps/dcauth/base32.go new file mode 100644 index 00000000..b4b75202 --- /dev/null +++ b/apps/dcauth/base32.go @@ -0,0 +1,49 @@ +package main + +import ( + "crypto/sha256" + "strings" +) + +const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +func buildDeviceID(publicKey []byte) string { + material := append([]byte("dcauth.device.v1"), publicKey...) + sum := sha256.Sum256(material) + return formatCode("D1", sum[:]) +} + +func formatCode(prefix string, data []byte) string { + encoded := base32Crockford(data) + if len(encoded) > 20 { + encoded = encoded[:20] + } + groups := make([]string, 0, 5) + for len(encoded) > 0 { + size := 4 + if len(encoded) < size { + size = len(encoded) + } + groups = append(groups, encoded[:size]) + encoded = encoded[size:] + } + return prefix + "-" + strings.Join(groups, "-") +} + +func base32Crockford(data []byte) string { + var builder strings.Builder + var buffer uint32 + var bits uint + for _, value := range data { + buffer = (buffer << 8) | uint32(value) + bits += 8 + for bits >= 5 { + bits -= 5 + builder.WriteByte(crockfordAlphabet[(buffer>>bits)&31]) + } + } + if bits > 0 { + builder.WriteByte(crockfordAlphabet[(buffer<<(5-bits))&31]) + } + return builder.String() +} 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/dpapi_other.go b/apps/dcauth/dpapi_other.go new file mode 100644 index 00000000..64df4690 --- /dev/null +++ b/apps/dcauth/dpapi_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package main + +func protectData(data []byte) ([]byte, error) { + return append([]byte(nil), data...), nil +} + +func unprotectData(data []byte) ([]byte, error) { + return append([]byte(nil), data...), nil +} diff --git a/apps/dcauth/dpapi_windows.go b/apps/dcauth/dpapi_windows.go new file mode 100644 index 00000000..6b72c801 --- /dev/null +++ b/apps/dcauth/dpapi_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package main + +import ( + "fmt" + "syscall" + "unsafe" +) + +type dataBlob struct { + cbData uint32 + pbData *byte +} + +var ( + crypt32DLL = syscall.NewLazyDLL("crypt32.dll") + kernel32DLL = syscall.NewLazyDLL("kernel32.dll") + cryptProtectData = crypt32DLL.NewProc("CryptProtectData") + unprotect = crypt32DLL.NewProc("CryptUnprotectData") + localFree = kernel32DLL.NewProc("LocalFree") +) + +func protectData(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty data") + } + input := dataBlob{cbData: uint32(len(data)), pbData: &data[0]} + var output dataBlob + result, _, err := cryptProtectData.Call( + uintptr(unsafe.Pointer(&input)), + 0, + 0, + 0, + 0, + 0, + uintptr(unsafe.Pointer(&output)), + ) + if result == 0 { + return nil, fmt.Errorf("CryptProtectData failed: %w", err) + } + defer localFree.Call(uintptr(unsafe.Pointer(output.pbData))) + return cloneBlob(output), nil +} + +func unprotectData(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty data") + } + input := dataBlob{cbData: uint32(len(data)), pbData: &data[0]} + var output dataBlob + result, _, err := unprotect.Call( + uintptr(unsafe.Pointer(&input)), + 0, + 0, + 0, + 0, + 0, + uintptr(unsafe.Pointer(&output)), + ) + if result == 0 { + return nil, fmt.Errorf("CryptUnprotectData failed: %w", err) + } + defer localFree.Call(uintptr(unsafe.Pointer(output.pbData))) + return cloneBlob(output), nil +} + +func cloneBlob(blob dataBlob) []byte { + if blob.pbData == nil || blob.cbData == 0 { + return nil + } + return append([]byte(nil), unsafe.Slice(blob.pbData, int(blob.cbData))...) +} diff --git a/apps/dcauth/go.mod b/apps/dcauth/go.mod new file mode 100644 index 00000000..bae5d7d3 --- /dev/null +++ b/apps/dcauth/go.mod @@ -0,0 +1,3 @@ +module dcauth + +go 1.23 diff --git a/apps/dcauth/main.go b/apps/dcauth/main.go new file mode 100644 index 00000000..8e150548 --- /dev/null +++ b/apps/dcauth/main.go @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const clientVersion = "2.0.1" + +type appConfig struct { + DisplayName string + AllowedAPIs []string +} + +var appRegistry = map[string]appConfig{ + "order_site": { + DisplayName: "订单后台", + AllowedAPIs: []string{ + "https://ks.khhao.com", + "https://order.khhao.com", + "http://localhost", + "http://127.0.0.1", + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://192.168.10.6", + "http://192.168.10.6:5173", + }, + }, + "live_hub": { + DisplayName: "直播运营后台", + AllowedAPIs: []string{ + "https://live.khhao.com", + "http://localhost:5174", + "http://127.0.0.1:5174", + "http://localhost:8800", + "http://127.0.0.1:8800", + }, + }, + "affiliate_dash": { + DisplayName: "皮肤分销后台", + AllowedAPIs: []string{ + "https://skin.khhao.com", + "http://localhost:15173", + "http://127.0.0.1:15173", + }, + }, +} + +type identity struct { + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey + deviceID string +} + +type authorizeRequest struct { + App string `json:"app"` + ChallengeID string `json:"challengeId"` + Challenge string `json:"challenge"` + DeviceID string `json:"deviceId"` + PublicKey string `json:"publicKey"` + Signature string `json:"signature"` + Timestamp int64 `json:"timestamp"` + ClientVersion string `json:"clientVersion"` +} + +func main() { + runClient(findProtocolArg(os.Args[1:])) +} + +type authorizeTarget struct { + App string + ChallengeID string + Challenge string + APIBase string +} + +func findProtocolArg(args []string) string { + for _, arg := range args { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(arg)), "dcauth://") { + return strings.TrimSpace(arg) + } + } + return "" +} + +func parseAuthorizeURL(raw string) (authorizeTarget, error) { + parsed, err := url.Parse(raw) + if err != nil { + return authorizeTarget{}, fmt.Errorf("无法解析协议地址") + } + if !strings.EqualFold(parsed.Scheme, "dcauth") { + return authorizeTarget{}, fmt.Errorf("协议 scheme 不是 dcauth") + } + if !strings.EqualFold(parsed.Host, "authorize") && !strings.EqualFold(strings.Trim(parsed.Path, "/"), "authorize") { + return authorizeTarget{}, fmt.Errorf("不支持的协议动作") + } + + query := parsed.Query() + appID := strings.TrimSpace(query.Get("app")) + challengeID := strings.TrimSpace(query.Get("challenge_id")) + challenge := strings.TrimSpace(query.Get("challenge")) + apiBase := strings.TrimRight(strings.TrimSpace(query.Get("api")), "/") + + if appID == "" || challengeID == "" || challenge == "" || apiBase == "" { + return authorizeTarget{}, fmt.Errorf("缺少 app、challenge_id、challenge 或 api 参数") + } + if len(challengeID) > 128 || len(challenge) > 512 { + return authorizeTarget{}, fmt.Errorf("challenge 参数过长") + } + + config, ok := appRegistry[appID] + if !ok { + return authorizeTarget{}, fmt.Errorf("未知应用:%s", appID) + } + origin, err := normalizeOrigin(apiBase) + if err != nil { + return authorizeTarget{}, err + } + if !containsOrigin(config.AllowedAPIs, origin) { + return authorizeTarget{}, fmt.Errorf("应用 %s 不允许访问 %s", appID, origin) + } + + return authorizeTarget{ + App: appID, + ChallengeID: challengeID, + Challenge: challenge, + APIBase: origin, + }, nil +} + +func normalizeOrigin(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("api 地址无效") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return "", fmt.Errorf("api 地址必须使用 http 或 https") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("api 地址不允许携带用户信息、查询串或片段") + } + path := strings.TrimRight(parsed.EscapedPath(), "/") + if path != "" { + return "", fmt.Errorf("api 必须是不带路径的 origin") + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil +} + +func containsOrigin(allowed []string, origin string) bool { + for _, item := range allowed { + if strings.EqualFold(strings.TrimRight(item, "/"), origin) { + return true + } + } + return false +} + +func submitAuthorization(id identity, target authorizeTarget) error { + timestamp := time.Now().Unix() + canonical := buildAuthorizationCanonical(id, target, timestamp) + signature := ed25519.Sign(id.privateKey, []byte(canonical)) + + payload := authorizeRequest{ + App: target.App, + ChallengeID: target.ChallengeID, + Challenge: target.Challenge, + DeviceID: id.deviceID, + PublicKey: base64.RawURLEncoding.EncodeToString(id.publicKey), + Signature: base64.RawURLEncoding.EncodeToString(signature), + Timestamp: timestamp, + ClientVersion: clientVersion, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("生成请求失败") + } + + request, err := http.NewRequest(http.MethodPost, target.APIBase+"/api/dcauth/authorize", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("生成请求失败") + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "DCAuth/"+clientVersion) + + client := &http.Client{Timeout: 15 * time.Second} + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("无法连接服务器:%w", err) + } + defer response.Body.Close() + responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024)) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("服务器拒绝本机验证(HTTP %d):%s", response.StatusCode, summarizeResponse(responseBody)) + } + 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 { + for _, key := range []string{"msg", "message", "detail", "error"} { + if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + } + text := strings.TrimSpace(string(body)) + if len(text) > 200 { + text = text[:200] + "..." + } + if text == "" { + return "无响应内容" + } + return text +} + +func deviceKeyPath() (string, error) { + base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if base == "" { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("无法定位用户配置目录") + } + base = configDir + } + dir := filepath.Join(base, "DCAuth") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("无法创建设备密钥目录:%w", err) + } + return filepath.Join(dir, "device.key"), nil +} + +func loadOrCreateIdentity() (identity, error) { + keyPath, err := deviceKeyPath() + if err != nil { + return identity{}, err + } + + var privateKey ed25519.PrivateKey + encrypted, readErr := os.ReadFile(keyPath) + if readErr == nil { + raw, err := unprotectData(encrypted) + if err != nil { + return identity{}, fmt.Errorf("设备密钥无法解密,请确认当前 Windows 用户未变化") + } + if len(raw) != ed25519.PrivateKeySize { + return identity{}, fmt.Errorf("设备密钥文件损坏,请删除 %s 后重试", keyPath) + } + privateKey = ed25519.PrivateKey(raw) + } else if os.IsNotExist(readErr) { + _, generated, err := ed25519.GenerateKey(nil) + if err != nil { + return identity{}, fmt.Errorf("生成设备密钥失败") + } + privateKey = generated + protected, err := protectData([]byte(privateKey)) + if err != nil { + return identity{}, fmt.Errorf("保护设备密钥失败:%w", err) + } + if err := os.WriteFile(keyPath, protected, 0o600); err != nil { + return identity{}, fmt.Errorf("保存设备密钥失败:%w", err) + } + } else { + return identity{}, fmt.Errorf("读取设备密钥失败:%w", readErr) + } + + publicKey := privateKey.Public().(ed25519.PublicKey) + return identity{ + privateKey: privateKey, + publicKey: publicKey, + deviceID: buildDeviceID(publicKey), + }, nil +} diff --git a/apps/dcauth/main_test.go b/apps/dcauth/main_test.go new file mode 100644 index 00000000..36289450 --- /dev/null +++ b/apps/dcauth/main_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseAuthorizeURLAllowedOrigins(t *testing.T) { + tests := []struct { + name string + api string + expected string + }{ + {name: "localhost", api: "http%3A%2F%2Flocalhost", expected: "http://localhost"}, + {name: "localhost trailing slash", api: "http%3A%2F%2Flocalhost%2F", expected: "http://localhost"}, + {name: "lan address", api: "http%3A%2F%2F192.168.10.6", expected: "http://192.168.10.6"}, + {name: "production ks.khhao.com", api: "https%3A%2F%2Fks.khhao.com", expected: "https://ks.khhao.com"}, + {name: "production uppercase host", api: "https%3A%2F%2FKS.KHHAO.COM", expected: "https://ks.khhao.com"}, + {name: "legacy production order.khhao.com", api: "https%3A%2F%2Forder.khhao.com", expected: "https://order.khhao.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := "dcauth://authorize?app=order_site&challenge_id=challenge-id&challenge=challenge-value&api=" + tt.api + target, err := parseAuthorizeURL(raw) + if err != nil { + t.Fatalf("parseAuthorizeURL() error = %v", err) + } + if target.APIBase != tt.expected { + t.Fatalf("APIBase = %q, want %q", target.APIBase, tt.expected) + } + }) + } +} + +func TestParseAuthorizeURLRejectsUnknownOrigin(t *testing.T) { + tests := []struct { + name string + api string + }{ + {name: "unknown host", api: "http%3A%2F%2Fexample.com"}, + {name: "lookalike suffix", api: "https%3A%2F%2Fks.khhao.com.evil.com"}, + {name: "lookalike prefix", api: "https%3A%2F%2Fevil-ks.khhao.com"}, + {name: "unexpected port", api: "https%3A%2F%2Fks.khhao.com%3A8443"}, + {name: "plain http production host", api: "http%3A%2F%2Fks.khhao.com"}, + {name: "userinfo trick", api: "https%3A%2F%2Fks.khhao.com%40evil.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := "dcauth://authorize?app=order_site&challenge_id=challenge-id&challenge=challenge-value&api=" + tt.api + if _, err := parseAuthorizeURL(raw); err == nil { + t.Fatalf("parseAuthorizeURL() accepted %q", tt.api) + } + }) + } +} + +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/process_windows.go b/apps/dcauth/process_windows.go new file mode 100644 index 00000000..7352d180 --- /dev/null +++ b/apps/dcauth/process_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +const createNoWindow = 0x08000000 + +func hideCommandWindow(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNoWindow, + } +} diff --git a/apps/dcauth/registry_other.go b/apps/dcauth/registry_other.go new file mode 100644 index 00000000..143a67d1 --- /dev/null +++ b/apps/dcauth/registry_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func registerProtocol() {} diff --git a/apps/dcauth/registry_windows.go b/apps/dcauth/registry_windows.go new file mode 100644 index 00000000..2421c7eb --- /dev/null +++ b/apps/dcauth/registry_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package main + +import ( + "os" + "os/exec" + "path/filepath" +) + +func registerProtocol() { + executable, err := os.Executable() + if err != nil { + return + } + executable, err = filepath.Abs(executable) + if err != nil { + return + } + command := `"` + executable + `" "%1"` + + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth`, + "/ve", "/d", "URL:DCAuth Protocol", "/f", + ) + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth`, + "/v", "URL Protocol", "/d", "", "/f", + ) + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth\DefaultIcon`, + "/ve", "/d", executable+",0", "/f", + ) + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth\shell\open\command`, + "/ve", "/d", command, "/f", + ) +} + +func runHiddenCommand(name string, args ...string) { + command := exec.Command(name, args...) + hideCommandWindow(command) + _ = command.Run() +} diff --git a/apps/dcauth/ui_darwin.go b/apps/dcauth/ui_darwin.go new file mode 100644 index 00000000..29064020 --- /dev/null +++ b/apps/dcauth/ui_darwin.go @@ -0,0 +1,328 @@ +//go:build darwin + +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +const ( + daemonShow = "__show__" + daemonExit = "__exit__" +) + +func runClient(protocolArg string) { + socketPath, err := darwinSocketPath() + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + + if hasCommandLineArg("--exit") { + if forwardToDarwinDaemon(socketPath, daemonExit) { + return + } + showError("DCAuth 未运行", "当前没有正在运行的 DCAuth 后台进程。") + return + } + + message := protocolArg + if message == "" { + message = daemonShow + } + if forwardToDarwinDaemon(socketPath, message) { + return + } + if os.Getenv("DCAUTH_BACKGROUND") == "1" { + runDarwinDaemon(protocolArg) + return + } + if err := startDarwinDaemon(protocolArg); err != nil { + showError("DCAuth 启动失败", err.Error()) + } +} + +func runDarwinDaemon(protocolArg string) { + lockPath, socketPath, err := darwinDaemonPaths() + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + lockFile, acquired, err := acquireDaemonLock(lockPath) + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + if !acquired { + message := protocolArg + if message == "" { + message = daemonShow + } + if !waitForDarwinDaemon(socketPath, message, 10*time.Second) { + showError("DCAuth 启动失败", "已有后台进程正在启动,但暂时无法连接。") + } + return + } + defer lockFile.Close() + + identity, err := loadOrCreateIdentity() + if err != nil { + showError("DCAuth 初始化失败", err.Error()) + return + } + _ = os.Remove(socketPath) + listener, err := net.Listen("unix", socketPath) + if err != nil { + showError("DCAuth 启动失败", fmt.Sprintf("无法创建后台通信通道:%v", err)) + return + } + defer listener.Close() + defer os.Remove(socketPath) + _ = os.Chmod(socketPath, 0o600) + + requests := make(chan string, 16) + go acceptDarwinRequests(listener, requests) + + if protocolArg != "" { + handleDarwinAuthorization(identity, protocolArg) + } else { + notifyDarwin("DCAuth v"+clientVersion, "DCAuth 已在后台运行。首次打开会更新协议绑定。") + } + + for request := range requests { + switch request { + case daemonExit: + return + case daemonShow: + showDarwinDeviceInfo(identity) + default: + handleDarwinAuthorization(identity, request) + } + } +} + +func acceptDarwinRequests(listener net.Listener, requests chan<- string) { + for { + connection, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 1024), 1024*1024) + for scanner.Scan() { + request := strings.TrimSpace(scanner.Text()) + if request != "" { + requests <- request + } + } + }(connection) + } +} + +func handleDarwinAuthorization(identity identity, raw string) { + target, err := parseAuthorizeURL(raw) + if err != nil { + showError("DCAuth 协议无效", err.Error()) + return + } + app := appRegistry[target.App] + if !confirmDarwin( + "DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认", + fmt.Sprintf( + "是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n\n点击“允许登录”完成验证,点击“取消”拒绝。", + app.DisplayName, + clientVersion, + identity.deviceID, + ), + ) { + return + } + if err := submitAuthorization(identity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + notifyDarwin("DCAuth v"+clientVersion+" 验证成功", "已确认本机设备,请返回浏览器继续登录。") +} + +func showDarwinDeviceInfo(identity identity) { + copyToClipboard(identity.deviceID) + showInfo( + "DCAuth v"+clientVersion+" 设备信息", + fmt.Sprintf( + "版本:v%s\n\n设备码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码已复制到剪贴板。", + clientVersion, + identity.deviceID, + ), + ) +} + +func startDarwinDaemon(protocolArg string) error { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("无法定位 DCAuth 程序:%w", err) + } + args := make([]string, 0, 1) + if protocolArg != "" { + args = append(args, protocolArg) + } + command := exec.Command(executable, args...) + command.Env = append(os.Environ(), "DCAUTH_BACKGROUND=1") + command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + logPath, err := darwinLogPath() + if err == nil { + if logFile, openErr := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); openErr == nil { + defer logFile.Close() + command.Stdout = logFile + command.Stderr = logFile + } + } + if err := command.Start(); err != nil { + return fmt.Errorf("无法启动后台进程:%w", err) + } + return command.Process.Release() +} + +func acquireDaemonLock(path string) (*os.File, bool, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, false, fmt.Errorf("无法创建后台锁文件:%w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, false, nil + } + return nil, false, fmt.Errorf("无法锁定后台进程:%w", err) + } + return file, true, nil +} + +func forwardToDarwinDaemon(socketPath, message string) bool { + connection, err := net.DialTimeout("unix", socketPath, 250*time.Millisecond) + if err != nil { + return false + } + defer connection.Close() + _ = connection.SetWriteDeadline(time.Now().Add(time.Second)) + _, err = io.WriteString(connection, message+"\n") + return err == nil +} + +func waitForDarwinDaemon(socketPath, message string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if forwardToDarwinDaemon(socketPath, message) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return false +} + +func darwinDaemonPaths() (string, string, error) { + directory, err := darwinDataDir() + if err != nil { + return "", "", err + } + return filepath.Join(directory, "daemon.lock"), filepath.Join(directory, "dcauth.sock"), nil +} + +func darwinSocketPath() (string, error) { + _, socketPath, err := darwinDaemonPaths() + return socketPath, err +} + +func darwinLogPath() (string, error) { + directory, err := darwinDataDir() + if err != nil { + return "", err + } + return filepath.Join(directory, "dcauth.log"), nil +} + +func darwinDataDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("无法定位用户配置目录:%w", err) + } + directory := filepath.Join(base, "DCAuth") + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", fmt.Errorf("无法创建 DCAuth 配置目录:%w", err) + } + return directory, nil +} + +func hasCommandLineArg(target string) bool { + for _, arg := range os.Args[1:] { + if strings.EqualFold(strings.TrimSpace(arg), target) { + return true + } + } + return false +} + +func showInfo(title, text string) { + _, _ = runAppleScript( + "display dialog " + appleScriptString(text) + + " with title " + appleScriptString(title) + + " buttons {\"好\"} default button \"好\" with icon note", + ) +} + +func showError(title, text string) { + _, _ = runAppleScript( + "display alert " + appleScriptString(title) + + " message " + appleScriptString(text) + + " as critical buttons {\"好\"} default button \"好\"", + ) +} + +func confirmDarwin(title, text string) bool { + output, err := runAppleScript( + "display dialog " + appleScriptString(text) + + " with title " + appleScriptString(title) + + " buttons {\"取消\", \"允许登录\"} default button \"允许登录\" cancel button \"取消\" with icon caution", + ) + return err == nil && strings.Contains(output, "允许登录") +} + +func notifyDarwin(title, text string) { + _, _ = runAppleScript( + "display notification " + appleScriptString(text) + + " with title " + appleScriptString(title), + ) +} + +func runAppleScript(statement string) (string, error) { + output, err := exec.Command("osascript", "-e", statement).CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func appleScriptString(text string) string { + replacer := strings.NewReplacer( + "\\", "\\\\", + "\"", "\\\"", + "\r", "", + "\n", "\\n", + ) + return `"` + replacer.Replace(text) + `"` +} + +func copyToClipboard(text string) { + command := exec.Command("pbcopy") + command.Stdin = strings.NewReader(text) + _ = command.Run() +} diff --git a/apps/dcauth/ui_darwin_test.go b/apps/dcauth/ui_darwin_test.go new file mode 100644 index 00000000..d93436bd --- /dev/null +++ b/apps/dcauth/ui_darwin_test.go @@ -0,0 +1,13 @@ +//go:build darwin + +package main + +import "testing" + +func TestAppleScriptStringEscapesDialogText(t *testing.T) { + got := appleScriptString("第一行\n第二行 \"确认\" \\ DCAuth") + want := `"第一行\n第二行 \"确认\" \\ DCAuth"` + if got != want { + t.Fatalf("appleScriptString() = %q, want %q", got, want) + } +} diff --git a/apps/dcauth/ui_other.go b/apps/dcauth/ui_other.go new file mode 100644 index 00000000..46e50d0f --- /dev/null +++ b/apps/dcauth/ui_other.go @@ -0,0 +1,64 @@ +//go:build !windows && !darwin + +package main + +import ( + "fmt" + "os" +) + +func runClient(protocolArg string) { + identity, err := loadOrCreateIdentity() + if err != nil { + showError("DCAuth 初始化失败", err.Error()) + return + } + + if protocolArg == "" { + text := fmt.Sprintf( + "DCAuth v%s\n设备码:%s\n\n设备码用于识别本机设备。\n请从后台登录页点击“验证本机”完成登录。", + clientVersion, + identity.deviceID, + ) + copyToClipboard(identity.deviceID) + showInfo("DCAuth v"+clientVersion+" 设备信息", text+"\n\n设备码已复制到剪贴板。") + return + } + + target, err := parseAuthorizeURL(protocolArg) + if err != nil { + showError("DCAuth 协议无效", err.Error()) + return + } + app := appRegistry[target.App] + if !confirm( + app.DisplayName+" · 本机登录确认", + fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s", app.DisplayName, identity.deviceID), + ) { + return + } + if err := submitAuthorization(identity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + showInfo("本机验证成功", "已确认本机设备,请回到浏览器继续登录。") +} + +func showInfo(title, text string) { + fmt.Printf("[%s]\n%s\n", title, text) +} + +func showError(title, text string) { + fmt.Fprintf(os.Stderr, "[%s]\n%s\n", title, text) +} + +func confirm(title, text string) bool { + fmt.Printf("[%s]\n%s\n[y/N] ", title, text) + var answer string + _, _ = fmt.Scanln(&answer) + return answer == "y" || answer == "Y" +} + +func copyToClipboard(text string) { + _ = text +} diff --git a/apps/dcauth/ui_windows.go b/apps/dcauth/ui_windows.go new file mode 100644 index 00000000..ca0ac103 --- /dev/null +++ b/apps/dcauth/ui_windows.go @@ -0,0 +1,646 @@ +//go:build windows + +package main + +import ( + "fmt" + "runtime" + "syscall" + "unicode/utf16" + "unsafe" +) + +const ( + mbOK = 0x00000000 + mbOKCancel = 0x00000001 + mbIconError = 0x00000010 + mbIconQuestion = 0x00000020 + mbIconInformation = 0x00000040 + mbSetForeground = 0x00010000 + mbTopMost = 0x00040000 + idOK = 1 +) + +const ( + cfUnicodeText = 13 + gmemMoveable = 0x0002 +) + +const ( + wmNull = 0x0000 + wmDestroy = 0x0002 + wmClose = 0x0010 + wmCopyData = 0x004A + wmCommand = 0x0111 + wmLButtonUp = 0x0202 + wmLButtonDblClk = 0x0203 + wmRButtonUp = 0x0205 + wmContextMenu = 0x007B + wmApp = 0x8000 +) + +const ( + trayCallbackMessage = wmApp + 1 + showWindowMessage = wmApp + 2 + copyDataMagic = 0x44434131 +) + +const ( + trayMenuDeviceInfo = 1001 + trayMenuExit = 1002 +) + +const ( + nimAdd = 0x00000000 + nimModify = 0x00000001 + nimDelete = 0x00000002 +) + +const ( + nifMessage = 0x00000001 + nifIcon = 0x00000002 + nifTip = 0x00000004 + nifInfo = 0x00000010 +) + +const ( + niifInfo = 0x00000001 +) + +const ( + mfString = 0x00000000 + mfGrayed = 0x00000001 + mfSeparator = 0x00000800 + tpmRight = 0x00000002 + tpmReturn = 0x00000100 +) + +const ( + errorAlreadyExists = syscall.Errno(183) + errorClassAlreadyReg = syscall.Errno(1410) +) + +const ( + windowClassName = "DCAuthHiddenWindow" + windowTitle = "DCAuth" + mutexName = `Local\DCAuth.OrderSite` + authorizationMutexName = `Local\DCAuth.OrderSite.Authorization` +) + +type point struct { + X int32 + Y int32 +} + +type message struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point + Private uint32 +} + +type wndClassEx struct { + CbSize uint32 + Style uint32 + LpfnWndProc uintptr + CbClsExtra int32 + CbWndExtra int32 + HInstance uintptr + HIcon uintptr + HCursor uintptr + HbrBackground uintptr + LpszMenuName *uint16 + LpszClassName *uint16 + HIconSm uintptr +} + +type copyDataStruct struct { + DwData uintptr + CbData uint32 + LpData uintptr +} + +type guid struct { + Data1 uint32 + Data2 uint16 + Data3 uint16 + Data4 [8]byte +} + +type notifyIconData struct { + CbSize uint32 + HWnd uintptr + UID uint32 + UFlags uint32 + UCallbackMessage uint32 + HIcon uintptr + SzTip [128]uint16 + DwState uint32 + DwStateMask uint32 + SzInfo [256]uint16 + UVersion uint32 + SzInfoTitle [64]uint16 + DwInfoFlags uint32 + GuidItem guid + HBalloonIcon uintptr +} + +var ( + user32DLL = syscall.NewLazyDLL("user32.dll") + shell32DLL = syscall.NewLazyDLL("shell32.dll") + + messageBoxW = user32DLL.NewProc("MessageBoxW") + openClip = user32DLL.NewProc("OpenClipboard") + closeClip = user32DLL.NewProc("CloseClipboard") + emptyClip = user32DLL.NewProc("EmptyClipboard") + setClipData = user32DLL.NewProc("SetClipboardData") + registerClassExW = user32DLL.NewProc("RegisterClassExW") + createWindowExW = user32DLL.NewProc("CreateWindowExW") + defWindowProcW = user32DLL.NewProc("DefWindowProcW") + getMessageW = user32DLL.NewProc("GetMessageW") + translateMessage = user32DLL.NewProc("TranslateMessage") + dispatchMessageW = user32DLL.NewProc("DispatchMessageW") + postQuitMessage = user32DLL.NewProc("PostQuitMessage") + destroyWindow = user32DLL.NewProc("DestroyWindow") + findWindowW = user32DLL.NewProc("FindWindowW") + sendMessageW = user32DLL.NewProc("SendMessageW") + postMessageW = user32DLL.NewProc("PostMessageW") + createPopupMenu = user32DLL.NewProc("CreatePopupMenu") + appendMenuW = user32DLL.NewProc("AppendMenuW") + trackPopupMenu = user32DLL.NewProc("TrackPopupMenu") + destroyMenu = user32DLL.NewProc("DestroyMenu") + setForegroundWindow = user32DLL.NewProc("SetForegroundWindow") + getCursorPos = user32DLL.NewProc("GetCursorPos") + loadIconW = user32DLL.NewProc("LoadIconW") + shellNotifyIconW = shell32DLL.NewProc("Shell_NotifyIconW") + + globalAlloc = kernel32DLL.NewProc("GlobalAlloc") + globalLock = kernel32DLL.NewProc("GlobalLock") + globalUnlock = kernel32DLL.NewProc("GlobalUnlock") + createMutexW = kernel32DLL.NewProc("CreateMutexW") + getModuleW = kernel32DLL.NewProc("GetModuleHandleW") + sleep = kernel32DLL.NewProc("Sleep") +) + +var ( + mainWindow uintptr + instanceMutex uintptr + trayData notifyIconData + trayAdded bool + runtimeIdentity identity + identityReady bool + authorizationActive bool + windowProcedure = syscall.NewCallback(dcauthWindowProc) +) + +func runClient(protocolArg string) { + runtime.LockOSThread() + firstInstance, err := acquireSingleInstance() + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + if !firstInstance { + if authorizationInProgress() { + return + } + if forwardToRunningInstance(protocolArg) { + return + } + firstInstance, err = acquireSingleInstance() + if err != nil || !firstInstance { + showError("DCAuth 启动失败", "DCAuth 已在运行但暂时没有响应。请在任务管理器中结束 DCAuth.exe 后重试。") + return + } + } + + registerProtocol() + if err := createHiddenWindow(); err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + if err := addTrayIcon(); err != nil { + showError("DCAuth 启动失败", err.Error()) + 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+" 已在后台运行", "双击托盘图标可查看版本和设备码。") + } + + runMessageLoop() + removeTrayIcon() +} + +func acquireSingleInstance() (bool, error) { + namePtr, _ := syscall.UTF16PtrFromString(mutexName) + handle, _, callErr := createMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr))) + runtime.KeepAlive(namePtr) + if handle == 0 { + return false, fmt.Errorf("无法创建单实例锁:%v", callErr) + } + if errno, ok := callErr.(syscall.Errno); ok && errno == errorAlreadyExists { + _ = syscall.CloseHandle(syscall.Handle(handle)) + return false, nil + } + instanceMutex = handle + return true, nil +} + +func authorizationInProgress() bool { + handle, acquired, err := acquireNamedMutex(authorizationMutexName) + if err != nil || !acquired { + if handle != 0 { + _ = syscall.CloseHandle(syscall.Handle(handle)) + } + return err == nil && !acquired + } + _ = syscall.CloseHandle(syscall.Handle(handle)) + return false +} + +func acquireNamedMutex(name string) (uintptr, bool, error) { + namePtr, _ := syscall.UTF16PtrFromString(name) + handle, _, callErr := createMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr))) + runtime.KeepAlive(namePtr) + if handle == 0 { + return 0, false, fmt.Errorf("无法创建互斥锁:%v", callErr) + } + if errno, ok := callErr.(syscall.Errno); ok && errno == errorAlreadyExists { + return handle, false, nil + } + return handle, true, nil +} + +func forwardToRunningInstance(protocolArg string) bool { + for attempt := 0; attempt < 300; attempt++ { + if hwnd := findRunningInstanceWindow(); hwnd != 0 { + return sendToRunningInstance(hwnd, protocolArg) + } + sleep.Call(100) + } + return false +} + +func findRunningInstanceWindow() uintptr { + classPtr, _ := syscall.UTF16PtrFromString(windowClassName) + titlePtr, _ := syscall.UTF16PtrFromString(windowTitle) + hwnd, _, _ := findWindowW.Call( + uintptr(unsafe.Pointer(classPtr)), + uintptr(unsafe.Pointer(titlePtr)), + ) + runtime.KeepAlive(classPtr) + runtime.KeepAlive(titlePtr) + return hwnd +} + +func sendToRunningInstance(hwnd uintptr, protocolArg string) bool { + if protocolArg == "" { + result, _, _ := postMessageW.Call(hwnd, showWindowMessage, 0, 0) + return result != 0 + } + + encoded, _ := syscall.UTF16FromString(protocolArg) + data := copyDataStruct{ + DwData: copyDataMagic, + CbData: uint32(len(encoded) * 2), + LpData: uintptr(unsafe.Pointer(&encoded[0])), + } + result, _, _ := sendMessageW.Call( + hwnd, + wmCopyData, + 0, + uintptr(unsafe.Pointer(&data)), + ) + runtime.KeepAlive(encoded) + return result != 0 +} + +func createHiddenWindow() error { + instance, _, _ := getModuleW.Call(0) + if instance == 0 { + return fmt.Errorf("无法读取程序实例句柄") + } + icon, _, _ := loadIconW.Call(0, 32512) + + classNamePtr, _ := syscall.UTF16PtrFromString(windowClassName) + class := wndClassEx{ + CbSize: uint32(unsafe.Sizeof(wndClassEx{})), + LpfnWndProc: windowProcedure, + HInstance: instance, + HIcon: icon, + LpszClassName: classNamePtr, + HIconSm: icon, + } + result, _, callErr := registerClassExW.Call(uintptr(unsafe.Pointer(&class))) + runtime.KeepAlive(classNamePtr) + if result == 0 { + if errno, ok := callErr.(syscall.Errno); !ok || errno != errorClassAlreadyReg { + return fmt.Errorf("无法注册窗口类:%v", callErr) + } + } + + titlePtr, _ := syscall.UTF16PtrFromString(windowTitle) + const wsExToolWindow = 0x00000080 + hwnd, _, callErr := createWindowExW.Call( + wsExToolWindow, + uintptr(unsafe.Pointer(classNamePtr)), + uintptr(unsafe.Pointer(titlePtr)), + 0, + 0, 0, 0, 0, + 0, + 0, + instance, + 0, + ) + if hwnd == 0 { + return fmt.Errorf("无法创建 DCAuth 后台窗口:%v", callErr) + } + runtime.KeepAlive(classNamePtr) + runtime.KeepAlive(titlePtr) + mainWindow = hwnd + return nil +} + +func runMessageLoop() { + var msg message + for { + result, _, _ := getMessageW.Call( + uintptr(unsafe.Pointer(&msg)), + 0, + 0, + 0, + ) + if int32(result) <= 0 { + return + } + translateMessage.Call(uintptr(unsafe.Pointer(&msg))) + dispatchMessageW.Call(uintptr(unsafe.Pointer(&msg))) + } +} + +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) + raw := syscall.UTF16ToString(unsafe.Slice((*uint16)(unsafe.Pointer(data.LpData)), count)) + handleAuthorization(raw) + return 1 + } + return 0 + case showWindowMessage: + if identityReady { + showDeviceInfo() + } + return 0 + case trayCallbackMessage: + switch uint32(lParam) { + case wmLButtonDblClk: + if identityReady { + showDeviceInfo() + } else { + showTrayBalloon("DCAuth 正在初始化", "设备信息准备完成后即可查看。") + } + case wmRButtonUp, wmContextMenu: + showTrayMenu(hwnd) + } + return 0 + case wmClose: + destroyWindow.Call(hwnd) + return 0 + case wmDestroy: + removeTrayIcon() + postQuitMessage.Call(0) + return 0 + } + result, _, _ := defWindowProcW.Call(hwnd, msg, wParam, lParam) + return result +} + +func addTrayIcon() error { + icon, _, _ := loadIconW.Call(0, 32512) + trayData = notifyIconData{ + HWnd: mainWindow, + UID: 1, + UFlags: nifMessage | nifIcon | nifTip, + UCallbackMessage: trayCallbackMessage, + HIcon: icon, + } + trayData.CbSize = uint32(unsafe.Sizeof(trayData)) + copyUTF16(trayData.SzTip[:], "DCAuth v"+clientVersion+" - 本机设备认证(后台运行)") + result, _, _ := shellNotifyIconW.Call( + nimAdd, + uintptr(unsafe.Pointer(&trayData)), + ) + if result == 0 { + return fmt.Errorf("无法创建系统托盘图标") + } + trayAdded = true + return nil +} + +func removeTrayIcon() { + if !trayAdded { + return + } + shellNotifyIconW.Call(nimDelete, uintptr(unsafe.Pointer(&trayData))) + trayAdded = false +} + +func showTrayBalloon(title, text string) { + if !trayAdded { + return + } + info := trayData + info.UFlags |= nifInfo + info.UVersion = 10000 + info.DwInfoFlags = niifInfo + copyUTF16(info.SzInfoTitle[:], title) + copyUTF16(info.SzInfo[:], text) + shellNotifyIconW.Call(nimModify, uintptr(unsafe.Pointer(&info))) +} + +func showTrayMenu(hwnd uintptr) { + menu, _, _ := createPopupMenu.Call() + if menu == 0 { + return + } + defer destroyMenu.Call(menu) + + statusText := mustUTF16("DCAuth v" + clientVersion + " · 后台运行中") + deviceText := mustUTF16("查看设备码") + exitText := mustUTF16("退出 DCAuth") + appendMenuW.Call(menu, mfString|mfGrayed, 0, uintptr(unsafe.Pointer(statusText))) + appendMenuW.Call(menu, mfString, trayMenuDeviceInfo, uintptr(unsafe.Pointer(deviceText))) + appendMenuW.Call(menu, mfSeparator, 0, 0) + appendMenuW.Call(menu, mfString, trayMenuExit, uintptr(unsafe.Pointer(exitText))) + runtime.KeepAlive(statusText) + runtime.KeepAlive(deviceText) + runtime.KeepAlive(exitText) + + var cursor point + if ok, _, _ := getCursorPos.Call(uintptr(unsafe.Pointer(&cursor))); ok == 0 { + return + } + setForegroundWindow.Call(hwnd) + command, _, _ := trackPopupMenu.Call( + menu, + tpmRight|tpmReturn, + uintptr(cursor.X), + uintptr(cursor.Y), + 0, + hwnd, + 0, + ) + postMessageW.Call(hwnd, wmNull, 0, 0) + + switch command { + case trayMenuDeviceInfo: + if identityReady { + showDeviceInfo() + } else { + showTrayBalloon("DCAuth 正在初始化", "设备信息准备完成后即可查看。") + } + case trayMenuExit: + destroyWindow.Call(hwnd) + } +} + +func showDeviceInfo() { + text := fmt.Sprintf( + "版本:v%s\n\n设备码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码已复制到剪贴板。", + clientVersion, + runtimeIdentity.deviceID, + ) + copyToClipboard(runtimeIdentity.deviceID) + showInfo("DCAuth v"+clientVersion+" 设备信息", text) +} + +func handleAuthorization(raw string) { + if authorizationActive { + showTrayBalloon("DCAuth v"+clientVersion, "已有一次登录确认正在进行,请先完成当前窗口。") + return + } + authorizationActive = true + defer func() { authorizationActive = false }() + + authHandle, acquired, err := acquireNamedMutex(authorizationMutexName) + if err != nil { + showError("DCAuth 验证失败", err.Error()) + return + } + if !acquired { + _ = syscall.CloseHandle(syscall.Handle(authHandle)) + showTrayBalloon("DCAuth v"+clientVersion, "已有一次登录确认正在进行,请先完成当前窗口。") + return + } + defer syscall.CloseHandle(syscall.Handle(authHandle)) + + target, err := parseAuthorizeURL(raw) + if err != nil { + showError("DCAuth 协议无效", err.Error()) + return + } + app := appRegistry[target.App] + if !confirm( + "DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认", + fmt.Sprintf( + "是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n\n点击“确定”允许本次登录,点击“取消”拒绝。", + app.DisplayName, + clientVersion, + runtimeIdentity.deviceID, + ), + ) { + return + } + if err := submitAuthorization(runtimeIdentity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + showTrayBalloon("DCAuth v"+clientVersion+" 验证成功", "已确认本机设备,请返回浏览器继续登录。") +} + +func showInfo(title, text string) { + showMessage(title, text, mbOK|mbIconInformation) +} + +func showError(title, text string) { + showMessage(title, text, mbOK|mbIconError) +} + +func confirm(title, text string) bool { + return showMessage(title, text, mbOKCancel|mbIconQuestion) == idOK +} + +func showMessage(title, text string, flags uint) int { + titlePtr, _ := syscall.UTF16PtrFromString(title) + textPtr, _ := syscall.UTF16PtrFromString(text) + result, _, _ := messageBoxW.Call( + mainWindow, + uintptr(unsafe.Pointer(textPtr)), + uintptr(unsafe.Pointer(titlePtr)), + uintptr(flags|mbSetForeground|mbTopMost), + ) + runtime.KeepAlive(titlePtr) + runtime.KeepAlive(textPtr) + return int(result) +} + +func copyUTF16(destination []uint16, text string) { + encoded := utf16.Encode([]rune(text)) + if len(encoded) >= len(destination) { + encoded = encoded[:len(destination)-1] + } + copy(destination, encoded) + destination[len(encoded)] = 0 +} + +func mustUTF16(text string) *uint16 { + value, _ := syscall.UTF16PtrFromString(text) + return value +} + +func copyToClipboard(text string) { + opened, _, _ := openClip.Call(mainWindow) + if opened == 0 { + return + } + defer closeClip.Call() + emptyClip.Call(mainWindow) + + encoded := utf16.Encode([]rune(text)) + encoded = append(encoded, 0) + size := uintptr(len(encoded) * 2) + handle, _, _ := globalAlloc.Call(gmemMoveable, size) + if handle == 0 { + return + } + pointer, _, _ := globalLock.Call(handle) + if pointer == 0 { + return + } + target := unsafe.Slice((*uint16)(unsafe.Pointer(pointer)), len(encoded)) + copy(target, encoded) + globalUnlock.Call(handle) + setClipData.Call(cfUnicodeText, handle) +} diff --git a/apps/dcauth/versioninfo.rc b/apps/dcauth/versioninfo.rc new file mode 100644 index 00000000..b321bb7e --- /dev/null +++ b/apps/dcauth/versioninfo.rc @@ -0,0 +1,28 @@ +1 VERSIONINFO +FILEVERSION 2,0,1,0 +PRODUCTVERSION 2,0,1,0 +FILEFLAGSMASK 0x3fL +FILEFLAGS 0x0L +FILEOS 0x40004L +FILETYPE 0x1L +FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Order Site" + VALUE "FileDescription", "DCAuth device authentication client" + VALUE "FileVersion", "2.0.1.0" + VALUE "InternalName", "DCAuth" + VALUE "LegalCopyright", "Copyright (C) 2026 Order Site" + VALUE "OriginalFilename", "DCAuth.exe" + VALUE "ProductName", "DCAuth" + VALUE "ProductVersion", "2.0.1" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END diff --git a/apps/dcauth/versioninfo_windows_amd64.syso b/apps/dcauth/versioninfo_windows_amd64.syso new file mode 100644 index 00000000..3ca78af1 Binary files /dev/null and b/apps/dcauth/versioninfo_windows_amd64.syso differ diff --git a/apps/frontend/index.html b/apps/frontend/index.html index a730e0cb..94870c37 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -3,7 +3,15 @@ - order-site react + + + + + + 运营后台管理中心
diff --git a/apps/frontend/public/apple-touch-icon.png b/apps/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..f38780f0 Binary files /dev/null and b/apps/frontend/public/apple-touch-icon.png differ diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico new file mode 100644 index 00000000..a97b6d9e Binary files /dev/null and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg new file mode 100644 index 00000000..b3c32d35 --- /dev/null +++ b/apps/frontend/public/favicon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/public/icon-192.png b/apps/frontend/public/icon-192.png new file mode 100644 index 00000000..a6409c0d Binary files /dev/null and b/apps/frontend/public/icon-192.png differ diff --git a/apps/frontend/public/icon-512.png b/apps/frontend/public/icon-512.png new file mode 100644 index 00000000..fa8fcc53 Binary files /dev/null and b/apps/frontend/public/icon-512.png differ diff --git a/apps/frontend/src/pages/admin/AdminLoginPage.tsx b/apps/frontend/src/pages/admin/AdminLoginPage.tsx index 02bcf346..f7a7d654 100644 --- a/apps/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/apps/frontend/src/pages/admin/AdminLoginPage.tsx @@ -5,18 +5,29 @@ import { SettingOutlined, UserOutlined, } from '@ant-design/icons' -import { App, Button, Form, Input } from 'antd' -import { useState } from 'react' +import { Alert, App, Button, Divider, Form, Input, Space, Typography } from 'antd' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' -import { loginAdmin } from '@/services/admin' +import { + completeAdminLogin, + fetchAdminDeviceStatus, + loginAdmin, + prepareAdminLogin, + sendAdminLoginSmsCode, +} from '@/services/admin' +import type { AdminDevicePrepareResponse } from '@/types/admin' import { setAdminSession } from '@/utils/admin-auth' type LoginForm = { username: string password: string + smsCode: string } +const isMacPlatform = + typeof navigator !== 'undefined' && /Macintosh|Mac OS X|iPhone|iPad/i.test(navigator.userAgent) + const brandFeatures = [ { icon: , @@ -25,8 +36,8 @@ const brandFeatures = [ }, { icon: , - title: '权限管控', - desc: '按角色开放操作,操作可审计', + title: '本机认证', + desc: '账号密码 + 短信验证码 + DCAuth 设备签名', }, { icon: , @@ -35,14 +46,140 @@ const brandFeatures = [ }, ] +/** 与站点图标同源的品牌标记:橙色渐变圆角方块 + 白色仪表盘四宫格。 */ +function BrandMark({ size = 42 }: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + ) +} + export default function AdminLoginPage() { const navigate = useNavigate() const { message } = App.useApp() + const [form] = Form.useForm() const [loading, setLoading] = useState(false) + const [sendingCode, setSendingCode] = useState(false) + const [countdown, setCountdown] = useState(0) + const [phoneMasked, setPhoneMasked] = useState('') + const [waiting, setWaiting] = useState(false) + const [devLaunchUrl, setDevLaunchUrl] = useState('') + const timerRef = useRef(null) + const submittingRef = useRef(false) + + useEffect(() => { + return () => { + if (timerRef.current) window.clearInterval(timerRef.current) + } + }, []) + + function startCountdown(seconds: number) { + setCountdown(seconds) + if (timerRef.current) window.clearInterval(timerRef.current) + timerRef.current = window.setInterval(() => { + setCountdown((current) => { + if (current <= 1) { + if (timerRef.current) window.clearInterval(timerRef.current) + return 0 + } + return current - 1 + }) + }, 1000) + } + + async function sendCode() { + try { + const values = await form.validateFields(['username', 'password']) + setSendingCode(true) + const response = await sendAdminLoginSmsCode({ + username: values.username.trim(), + password: values.password.trim(), + }) + setPhoneMasked(response.data.phoneMasked) + startCountdown(60) + message.success(`验证码已发送至 ${response.data.phoneMasked}`) + } catch (error) { + message.error(error instanceof Error ? error.message : '验证码发送失败') + } finally { + setSendingCode(false) + } + } async function submitLogin(values: LoginForm) { + if (submittingRef.current) return + submittingRef.current = true setLoading(true) + setWaiting(false) + try { + const prepared = await prepareAdminLogin({ + username: values.username.trim(), + password: values.password.trim(), + smsCode: values.smsCode.trim(), + apiBase: window.location.origin, + }) + setPhoneMasked(prepared.data.phoneMasked) + setWaiting(true) + setDevLaunchUrl(import.meta.env.DEV && isMacPlatform ? prepared.data.launchUrl : '') + message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认') + launchProtocol(prepared.data.launchUrl) + const completed = await waitForDevice(prepared.data) + setAdminSession(completed.token, completed.expiresAt, completed.user) + message.success('登录成功') + void navigate('/admin/dashboard', { replace: true }) + } catch (error) { + const code = (error as { errorCode?: string })?.errorCode + if (code === 'admin_phone_unbound') { + message.warning('该账号未绑定手机号,请联系管理员绑定后再使用安全登录') + } else { + message.error(error instanceof Error ? error.message : '登录失败') + } + } finally { + submittingRef.current = false + setLoading(false) + setWaiting(false) + setDevLaunchUrl('') + } + } + + async function submitLegacyLogin() { + if (submittingRef.current) return + let values: Pick + try { + values = await form.validateFields(['username', 'password']) + } catch { + return + } + submittingRef.current = true + setLoading(true) try { const response = await loginAdmin({ username: values.username.trim(), @@ -52,27 +189,49 @@ export default function AdminLoginPage() { message.success('登录成功') void navigate('/admin/dashboard', { replace: true }) } catch (error) { - message.error(error instanceof Error ? error.message : '后台登录失败') + message.error(error instanceof Error ? error.message : '账号密码登录失败') } finally { + submittingRef.current = false setLoading(false) } } + async function waitForDevice(prepared: AdminDevicePrepareResponse) { + for (let attempt = 0; attempt < 120; attempt += 1) { + await sleep(1000) + const status = await fetchAdminDeviceStatus(prepared.challengeId, prepared.pollToken) + if (status.data.status === 'authorized' && status.data.deviceTicket) { + const completed = await completeAdminLogin({ + challengeId: prepared.challengeId, + pollToken: prepared.pollToken, + deviceTicket: status.data.deviceTicket, + }) + return completed.data + } + if (status.data.status === 'expired' || status.data.status === 'rejected') { + throw new Error(status.data.message || '本机验证已过期,请重新登录') + } + if (attempt === 4) { + message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth.exe') + } + } + throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起') + } + return (
- {/* 品牌信息区:移动端隐藏 */} - {/* 登录表单区 */}
- {/* 移动端精简品牌栏 */}
- + Order Site Admin

运营后台登录

-

使用账号密码进入后台,并按角色开放不同操作权限

+

账号密码 + 短信验证码 + 本机 DCAuth 设备验证

- layout="vertical" requiredMark={false} onFinish={submitLogin}> + {waiting ? ( + <> + + {import.meta.env.DEV && devLaunchUrl ? ( + + + 首次使用先安装一次,之后浏览器点登录即可直接唤起: + + + cd apps/dcauth && ./build-macos.sh + + + 已安装但仍未弹出时,可在终端手动执行: + + + open "{devLaunchUrl}" + + + } + style={{ marginBottom: 16 }} + /> + ) : null} + + ) : null} + + + form={form} + layout="vertical" + requiredMark={false} + onFinish={submitLogin} + > - } placeholder="输入账号" autoComplete="username" /> + } + placeholder="中文 / 英文 / 数字账号" + autoComplete="username" + /> + + } + placeholder="6 位短信验证码" + maxLength={6} + autoComplete="one-time-code" + addonAfter={ + + } + /> + + + + + 技术测试账号 / 未绑定手机号 + + + +
仅限授权运营人员访问
@@ -138,3 +383,16 @@ export default function AdminLoginPage() {
) } + +function launchProtocol(url: string) { + const anchor = document.createElement('a') + anchor.href = url + anchor.style.display = 'none' + document.body.appendChild(anchor) + anchor.click() + window.setTimeout(() => anchor.remove(), 1000) +} + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)) +} diff --git a/apps/frontend/src/pages/admin/AdminUsersPage.tsx b/apps/frontend/src/pages/admin/AdminUsersPage.tsx index 067e590b..b56db2be 100644 --- a/apps/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminUsersPage.tsx @@ -11,6 +11,7 @@ import { Result, Select, Space, + Switch, Table, Tag, Typography, @@ -27,7 +28,10 @@ import { fetchAdminRolePermissions, fetchAdminUsers, resetAdminUserPassword, + resetAdminUserDevice, updateAdminUserDisplayName, + updateAdminUserPhone, + updateAdminUserTestAccount, updateAdminUserRole, updateAdminUserStatus, updateAdminRolePermissions, @@ -53,6 +57,8 @@ type CreateUserForm = { username: string password: string role: AdminRole + phone?: string + testAccount?: boolean } type DisplayNameForm = { @@ -129,6 +135,8 @@ export default function AdminUsersPage() { password: values.password.trim(), role: values.role, status: 'active', + phone: values.phone?.trim() || '', + testAccount: values.testAccount === true, }) message.success('后台用户已创建') createForm.resetFields() @@ -293,6 +301,101 @@ export default function AdminUsersPage() { }) } + function openBindPhoneModal(item: AdminUserListItem) { + const form = ModalFormStore.create<{ phone: string }>() + + modal.confirm({ + title: '绑定手机号', + content: ( +
+ + + +
+ ), + okText: '保存', + cancelText: '取消', + centered: true, + onOk: async () => { + const values = await form.validate() + setActionLoadingId(item.userId) + try { + await updateAdminUserPhone(item.userId, { phone: values.phone.trim() }) + message.success('手机号已绑定') + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '绑定手机号失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + + function confirmResetDevice(item: AdminUserListItem) { + if (!item.deviceBound) return + modal.confirm({ + title: '重置设备绑定', + content: `确认解除 ${displayNameOf(item)} 的当前设备吗?解绑后该账号需要重新完成首次设备绑定。`, + okText: '确认解绑', + cancelText: '取消', + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + setActionLoadingId(item.userId) + try { + await resetAdminUserDevice(item.userId) + message.success('设备绑定已重置') + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '重置设备失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + + function confirmToggleTestAccount(item: AdminUserListItem) { + const next = !item.testAccount + modal.confirm({ + title: next ? '设为技术测试账号' : '取消技术测试账号', + content: next + ? `确认让 ${displayNameOf(item)} 跳过短信验证码和 DCAuth 设备验证,仅使用账号密码登录吗?仅建议用于技术测试。` + : `确认恢复 ${displayNameOf(item)} 的完整安全登录流程吗?`, + okText: next ? '设为测试账号' : '恢复正式账号', + cancelText: '取消', + okButtonProps: { danger: next }, + centered: true, + onOk: async () => { + setActionLoadingId(item.userId) + try { + await updateAdminUserTestAccount(item.userId, { testAccount: next }) + message.success(next ? '已设为技术测试账号' : '已恢复正式账号') + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '更新测试账号设置失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + function applyFilters(values: UserFilterForm) { setSearchParams(cleanParams({ ...values, page: 1, pageSize })) } @@ -334,6 +437,29 @@ export default function AdminUsersPage() { minWidth: 140, render: (value: string) => {value}, }, + { + title: '手机号', + dataIndex: 'phone', + width: 150, + render: (value: string) => + value ? {value} : 未绑定, + }, + { + title: '登录设备', + dataIndex: 'deviceBound', + width: 210, + render: (_: boolean, row) => + row.deviceBound ? ( + + {row.deviceIdMasked} + + 绑定于 {formatAdminDateTime(row.deviceBoundAt || '')} + + + ) : ( + 未绑定 + ), + }, { title: '角色', dataIndex: 'role', @@ -343,8 +469,13 @@ export default function AdminUsersPage() { { title: '状态', dataIndex: 'status', - width: 140, - render: (value) => , + width: 170, + render: (value, row) => ( + + + {row.testAccount ? 技术测试 : null} + + ), }, { title: '创建时间', @@ -360,7 +491,7 @@ export default function AdminUsersPage() { }, { title: '操作', - width: 390, + width: 760, render: (_, row) => { const isCurrent = row.userId === currentUserId const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({ @@ -377,6 +508,26 @@ export default function AdminUsersPage() { > 改名 + + + @@ -462,7 +613,7 @@ export default function AdminUsersPage() { form={createForm} layout="inline" - initialValues={{ role: 'operator' }} + initialValues={{ role: 'operator', testAccount: false }} onFinish={submitCreate} className="filter-form users-action-form" > @@ -470,7 +621,7 @@ export default function AdminUsersPage() { - + + + +