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..b56cb23a --- /dev/null +++ b/apps/backend/src/db/migrations/102_admin_phone_device_auth.sql @@ -0,0 +1,116 @@ +-- 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, + machine_code_hash TEXT NOT NULL, + machine_code_mask TEXT NOT NULL DEFAULT '', + client_version TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + bound_at TIMESTAMPTZ NOT NULL, + 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 '', + machine_code_hash TEXT NOT NULL DEFAULT '', + machine_code_mask TEXT NOT NULL DEFAULT '', + client_version TEXT NOT NULL DEFAULT '', + device_ticket_hash TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + 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_login_logs + ADD COLUMN IF NOT EXISTS machine_code_mask TEXT NOT NULL DEFAULT ''; + +ALTER TABLE admin_sessions + ADD COLUMN IF NOT EXISTS device_id TEXT NOT NULL DEFAULT ''; + +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_login_logs.machine_code_mask IS '登录设备机器码脱敏值'; +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..b2fa2211 --- /dev/null +++ b/apps/backend/src/repositories/admin-device-repo.ts @@ -0,0 +1,252 @@ +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 + machine_code_hash: string + machine_code_mask: 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 + machine_code_hash: string + machine_code_mask: 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 + machineCodeHash: string + machineCodeMask: string + clientVersion: string + now: string +}): Promise { + const result = await query( + ` + UPDATE admin_device_challenges + SET + status = 'authorized', + device_id = $3, + public_key = $4, + machine_code_hash = $5, + machine_code_mask = $6, + client_version = $7, + authorized_at = $8, + updated_at = $8 + WHERE challenge_id = $1 + AND app = $2 + AND status = 'pending' + AND expires_at > $8 + RETURNING * + `, + [ + input.challengeId, + input.app, + input.deviceId, + input.publicKey, + input.machineCodeHash, + input.machineCodeMask, + 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 + machineCodeHash: string + machineCodeMask: string + clientVersion: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO admin_user_devices ( + user_id, device_id, public_key, machine_code_hash, machine_code_mask, + client_version, status, bound_at, last_used_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7, $7, $7) + ON CONFLICT (user_id) WHERE status = 'active' + DO UPDATE SET + device_id = EXCLUDED.device_id, + public_key = EXCLUDED.public_key, + machine_code_hash = EXCLUDED.machine_code_hash, + machine_code_mask = EXCLUDED.machine_code_mask, + client_version = EXCLUDED.client_version, + bound_at = EXCLUDED.bound_at, + last_used_at = EXCLUDED.last_used_at, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.userId, + input.deviceId, + input.publicKey, + input.machineCodeHash, + input.machineCodeMask, + input.clientVersion, + input.now, + ], + ) + 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..d6f5a716 100644 --- a/apps/backend/src/repositories/admin-login-log-repo.ts +++ b/apps/backend/src/repositories/admin-login-log-repo.ts @@ -12,6 +12,8 @@ export type AdminLoginLogRow = { user_agent: string success: boolean failure_reason: string + login_method: string + machine_code_mask: string created_at: string } @@ -24,6 +26,8 @@ type AdminLoginLogCreateInput = { userAgent?: string success?: boolean failureReason?: string + loginMethod?: string + machineCodeMask?: string createdAt: string } @@ -49,8 +53,10 @@ export async function createAdminLoginLog( user_agent, success, failure_reason, + login_method, + machine_code_mask, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `, [ @@ -62,6 +68,8 @@ export async function createAdminLoginLog( String(input.userAgent || '').trim(), input.success !== false, String(input.failureReason || '').trim(), + String(input.loginMethod || 'password').trim() || 'password', + String(input.machineCodeMask || '').trim(), input.createdAt, ], ) diff --git a/apps/backend/src/repositories/admin-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..50b007f0 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -7,9 +7,17 @@ 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 + machine_code_mask?: string | null + device_bound_at?: string | null + device_last_used_at?: string | null } type AdminUserCreateInput = { @@ -18,6 +26,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 +42,10 @@ type AdminUserPatch = Partial< | 'password_hash' | 'role' | 'status' + | 'phone' + | 'phone_bound_at' + | 'phone_bound_by' + | 'test_account' | 'session_version' | 'updated_at' > @@ -49,8 +65,15 @@ type AdminUserListResult = { } const ADMIN_USER_SELECT = ` - SELECT au.* + SELECT + au.*, + d.device_id AS device_id, + d.machine_code_mask AS machine_code_mask, + d.bound_at AS device_bound_at, + d.last_used_at AS device_last_used_at FROM admin_users au + 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 +92,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 +108,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..0bd4e49e 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,157 @@ 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, { + machineCodeMask: consumed.machine_code_mask, + }) +} + +async function createAdminLoginSession( + user: AdminUserRow, + deviceId: string, + meta: { + ip?: string + userAgent?: string + location?: string + }, + extra: { machineCodeMask?: 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', + machineCodeMask: String(extra.machineCodeMask || ''), + ...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 +399,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 +740,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 +748,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 +776,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 +849,7 @@ function normalizeStatusQuery(status: unknown): string { function normalizeUsername(username: unknown): string { return String(username || '') .trim() + .normalize('NFKC') .toLowerCase() } @@ -615,8 +879,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 +952,29 @@ 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), + machineCodeMask: String(user.machine_code_mask || ''), + 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.ts b/apps/backend/src/services/admin/admin-device-auth-service.ts new file mode 100644 index 00000000..e7c68afb --- /dev/null +++ b/apps/backend/src/services/admin/admin-device-auth-service.ts @@ -0,0 +1,357 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +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 machineCode = String(payload.machineCode || '') + .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 || !machineCode || !publicKey || !signature) { + throw createHttpError('设备认证参数不完整', { + statusCode: 400, + errorCode: 'admin_device_payload_invalid', + }) + } + if (!/^D1-[A-Z0-9-]{8,64}$/.test(deviceId)) { + throw createHttpError('设备码格式不正确', { + statusCode: 400, + errorCode: 'admin_device_id_invalid', + }) + } + if (!/^M1-[A-Z0-9-]{8,64}$/.test(machineCode)) { + throw createHttpError('机器码格式不正确', { + statusCode: 400, + errorCode: 'admin_machine_code_invalid', + }) + } + if ( + !Number.isInteger(timestamp) || + Math.abs(Date.now() / 1000 - timestamp) > MAX_CLOCK_SKEW_SECONDS + ) { + 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 = [ + 'dcauth.v1', + app, + challengeId, + challengeValue, + deviceId, + machineCode, + String(timestamp), + ].join('\n') + 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, + machineCodeHash: hashMachineCode(machineCode), + machineCodeMask: maskMachineCode(machineCode), + 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, + machineCodeMask: challenge.machine_code_mask, + } + } + const latest = await getAdminDeviceChallenge(challengeId) + return { status: latest?.status || 'pending' } + } + return { + status: challenge.status, + deviceId: challenge.device_id || '', + machineCodeMask: challenge.machine_code_mask || '', + } +} + +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, + machineCodeHash: challenge.machine_code_hash, + machineCodeMask: challenge.machine_code_mask, + clientVersion: challenge.client_version, + now: nowIso(), + }) + if (!bound) { + throw createHttpError('绑定本机设备失败,请重试', { + statusCode: 500, + errorCode: 'admin_device_bind_failed', + }) + } + return bound +} + +export function maskMachineCode(machineCode: string): string { + const normalized = String(machineCode || '') + .trim() + .toUpperCase() + if (normalized.length <= 10) return normalized + return `${normalized.slice(0, 7)}-****-****-${normalized.slice(-4)}` +} + +export function hashMachineCode(machineCode: string): string { + return crypto + .createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || 'dcauth')) + .update( + String(machineCode || '') + .trim() + .toUpperCase(), + ) + .digest('hex') +} + +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..d60812f2 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,8 @@ type RecordAdminLoginInput = { ip?: string location?: string userAgent?: string + loginMethod?: string + machineCodeMask?: string } export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) { @@ -28,6 +30,8 @@ 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', + machineCodeMask: String(input.machineCodeMask || '').trim(), createdAt: nowIso(), }) } catch (error) { @@ -74,6 +78,8 @@ 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(), + machineCodeMask: String(item.machine_code_mask || '').trim(), createdAt: item.created_at, })), pagination: { page, pageSize, total }, diff --git a/apps/backend/src/services/admin/admin-sms-service.ts b/apps/backend/src/services/admin/admin-sms-service.ts new file mode 100644 index 00000000..03db289f --- /dev/null +++ b/apps/backend/src/services/admin/admin-sms-service.ts @@ -0,0 +1,216 @@ +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 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 ( + (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 +} + +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..f6fe7765 --- /dev/null +++ b/apps/dcauth/README.md @@ -0,0 +1,71 @@ +# DCAuth Windows 客户端 + +三个系统共用的设备认证客户端,通过 `dcauth://` 协议被浏览器唤起。 + +## 构建 + +```bash +cd apps/dcauth +GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth.exe . +``` + +ARM64: + +```bash +GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth-arm64.exe . +``` + +## 首次运行 + +直接双击 exe 会: + +- 注册 `dcauth://` 协议到当前 Windows 用户。 +- 生成 Ed25519 设备密钥,使用 Windows DPAPI 加密保存在 `%LOCALAPPDATA%\DCAuth\device.key`。 +- 显示设备码和机器码,并复制到剪贴板。 + +## macOS 开发调试 + +Mac 上不需要安装 Windows exe,也不需要注册自定义协议。登录页在开发模式会显示一条 `dcauth://...` 链接,复制后执行: + +```bash +cd apps/dcauth +go run . "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=http%3A%2F%2Flocalhost%3A5173" +``` + +客户端会提示确认,然后直接调用本地 Vite 代理的 `/api/dcauth/authorize`,完成本机签名。 + +如果只是测后台业务,不想每次都走短信和设备验证,可以在“后台用户”里把测试账号设为 **技术测试**。该账号只使用账号密码登录;正式账号仍必须走短信 + DCAuth。 + +## 浏览器联动 + +登录页创建 challenge 后跳转: + +```text +dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=https%3A%2F%2Forder.khhao.com +``` + +客户端校验 app 和 api 白名单后,弹出确认框,签名并提交到: + +```text +POST /api/dcauth/authorize +``` + +浏览器轮询 challenge 状态,拿到一次性 ticket 后完成登录。 + +## 支持的应用 + +| app | 生产入口 | +|---|---| +| `order_site` | `https://order.khhao.com` | +| `live_hub` | `https://live.khhao.com` | +| `affiliate_dash` | `https://skin.khhao.com` | + +本地开发入口已内置在 `main.go` 的 `appRegistry` 中,包括 `http://localhost`、`http://127.0.0.1`、`http://192.168.10.6` 及对应 Vite 端口。 + +## 安全说明 + +- 私钥不离开本机,使用 DPAPI 保护。 +- 服务端只保存公钥和机器码哈希/脱敏值。 +- 机器码采集主板 UUID、MachineGuid、主板/BIOS/系统盘序列号、CPU ID,不上传原始序列号。 +- 正式分发前建议使用代码签名证书,避免 SmartScreen 和杀毒软件误报。 +- 如果要做到 token 也不能复制,需要后续让 exe 对每次 API 请求签名,或把 exe 作为唯一客户端。 diff --git a/apps/dcauth/base32.go b/apps/dcauth/base32.go new file mode 100644 index 00000000..92a2172c --- /dev/null +++ b/apps/dcauth/base32.go @@ -0,0 +1,89 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "sort" + "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 buildMachineCode() (string, error) { + components, err := collectHardwareComponents() + if err != nil { + return "", err + } + keys := make([]string, 0, len(components)) + for key := range components { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := []string{"dcauth.machine.v1"} + for _, key := range keys { + parts = append(parts, key+"="+components[key]) + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\n"))) + return formatCode("M1", sum[:]), nil +} + +func formatCode(prefix string, data []byte) string { + encoded := base32Crockford(data) + if len(encoded) > 20 { + 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() +} + +func normalizeComponent(value string) string { + normalized := strings.ToUpper(strings.TrimSpace(value)) + normalized = strings.Join(strings.Fields(normalized), "") + switch normalized { + case "", "0", "NONE", "N/A", "NA", "UNKNOWN", "DEFAULTSTRING", + "TOBEFILLEDBYO.E.M.", "TOBEFILLEDBYOEM", "SYSTEMSERIALNUMBER", + "00000000-0000-0000-0000-000000000000": + return "" + } + return normalized +} + +func ensureNotEmpty(components map[string]string) error { + if len(components) == 0 { + return fmt.Errorf("无法读取本机硬件信息,请联系管理员") + } + return nil +} diff --git a/apps/dcauth/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/hardware_other.go b/apps/dcauth/hardware_other.go new file mode 100644 index 00000000..d6c2da1a --- /dev/null +++ b/apps/dcauth/hardware_other.go @@ -0,0 +1,19 @@ +//go:build !windows + +package main + +import ( + "os" + "runtime" +) + +func collectHardwareComponents() (map[string]string, error) { + hostname, _ := os.Hostname() + components := map[string]string{ + "platform": runtime.GOOS + "-" + runtime.GOARCH, + } + if normalized := normalizeComponent(hostname); normalized != "" { + components["hostname"] = normalized + } + return components, ensureNotEmpty(components) +} diff --git a/apps/dcauth/hardware_windows.go b/apps/dcauth/hardware_windows.go new file mode 100644 index 00000000..d9698f34 --- /dev/null +++ b/apps/dcauth/hardware_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +package main + +import ( + "encoding/json" + "os/exec" + "strings" +) + +func collectHardwareComponents() (map[string]string, error) { + components := map[string]string{} + + script := ` +$ErrorActionPreference = 'SilentlyContinue' +$cs = Get-CimInstance Win32_ComputerSystemProduct +$bb = Get-CimInstance Win32_BaseBoard +$bios = Get-CimInstance Win32_BIOS +$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 +$disk = Get-CimInstance Win32_DiskDrive | Where-Object { $_.Index -eq 0 } | Select-Object -First 1 +[pscustomobject]@{ + smbiosUuid = [string]$cs.UUID + baseboardSerial = [string]$bb.SerialNumber + biosSerial = [string]$bios.SerialNumber + cpuId = [string]$cpu.ProcessorId + systemDiskSerial = [string]$disk.SerialNumber +} | ConvertTo-Json -Compress +` + output, err := exec.Command( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-Command", script, + ).Output() + if err == nil { + var payload map[string]string + if json.Unmarshal([]byte(strings.TrimSpace(string(output))), &payload) == nil { + for key, value := range payload { + if normalized := normalizeComponent(value); normalized != "" { + components[key] = normalized + } + } + } + } + + if machineGUID := readMachineGUID(); machineGUID != "" { + components["machineGuid"] = machineGUID + } + if err := ensureNotEmpty(components); err != nil { + return nil, err + } + return components, nil +} + +func readMachineGUID() string { + output, err := exec.Command( + "reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid", + ).Output() + if err != nil { + return "" + } + line := strings.TrimSpace(string(output)) + for _, candidate := range strings.Split(line, "\n") { + fields := strings.Fields(strings.TrimSpace(candidate)) + if len(fields) >= 3 && strings.EqualFold(fields[0], "MachineGuid") { + return normalizeComponent(strings.Join(fields[2:], " ")) + } + } + return "" +} diff --git a/apps/dcauth/main.go b/apps/dcauth/main.go new file mode 100644 index 00000000..e982f478 --- /dev/null +++ b/apps/dcauth/main.go @@ -0,0 +1,338 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const clientVersion = "1.0.0" + +type appConfig struct { + DisplayName string + AllowedAPIs []string +} + +var appRegistry = map[string]appConfig{ + "order_site": { + DisplayName: "订单后台", + AllowedAPIs: []string{ + "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 + machineCode string +} + +type authorizeRequest struct { + App string `json:"app"` + ChallengeID string `json:"challengeId"` + Challenge string `json:"challenge"` + DeviceID string `json:"deviceId"` + MachineCode string `json:"machineCode"` + PublicKey string `json:"publicKey"` + Signature string `json:"signature"` + Timestamp int64 `json:"timestamp"` + ClientVersion string `json:"clientVersion"` +} + +func main() { + registerProtocol() + + identity, err := loadOrCreateIdentity() + if err != nil { + showError("DCAuth 初始化失败", err.Error()) + return + } + + protocolArg := findProtocolArg(os.Args[1:]) + if protocolArg == "" { + text := fmt.Sprintf( + "设备码:%s\n机器码:%s\n\n设备码用于识别本机,机器码用于管理员核对。\n请从后台登录页点击“验证本机”完成登录。", + identity.deviceID, + identity.machineCode, + ) + copyToClipboard(identity.deviceID + "\n" + identity.machineCode) + showInfo("DCAuth 设备信息", 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\n机器码:%s", app.DisplayName, identity.deviceID, identity.machineCode), + ) { + return + } + + if err := submitAuthorization(identity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + showInfo("本机验证成功", "已确认本机设备,请回到浏览器继续登录。") +} + +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 := strings.Join([]string{ + "dcauth.v1", + target.App, + target.ChallengeID, + target.Challenge, + id.deviceID, + id.machineCode, + fmt.Sprintf("%d", timestamp), + }, "\n") + signature := ed25519.Sign(id.privateKey, []byte(canonical)) + + payload := authorizeRequest{ + App: target.App, + ChallengeID: target.ChallengeID, + Challenge: target.Challenge, + DeviceID: id.deviceID, + MachineCode: id.machineCode, + PublicKey: base64.RawURLEncoding.EncodeToString(id.publicKey), + Signature: base64.RawURLEncoding.EncodeToString(signature), + Timestamp: timestamp, + 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 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) + machineCode, err := buildMachineCode() + if err != nil { + return identity{}, err + } + return identity{ + privateKey: privateKey, + publicKey: publicKey, + deviceID: buildDeviceID(publicKey), + machineCode: machineCode, + }, nil +} diff --git a/apps/dcauth/main_test.go b/apps/dcauth/main_test.go new file mode 100644 index 00000000..01495800 --- /dev/null +++ b/apps/dcauth/main_test.go @@ -0,0 +1,35 @@ +package main + +import "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"}, + } + + 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) { + raw := "dcauth://authorize?app=order_site&challenge_id=challenge-id&challenge=challenge-value&api=http%3A%2F%2Fexample.com" + if _, err := parseAuthorizeURL(raw); err == nil { + t.Fatal("parseAuthorizeURL() accepted an unknown origin") + } +} 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..e2361921 --- /dev/null +++ b/apps/dcauth/registry_windows.go @@ -0,0 +1,26 @@ +//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"` + + _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth`, "/ve", "/d", "URL:DCAuth Protocol", "/f").Run() + _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth`, "/v", "URL Protocol", "/d", "", "/f").Run() + _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth\DefaultIcon`, "/ve", "/d", executable+",0", "/f").Run() + _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth\shell\open\command`, "/ve", "/d", command, "/f").Run() +} diff --git a/apps/dcauth/ui_other.go b/apps/dcauth/ui_other.go new file mode 100644 index 00000000..af193d24 --- /dev/null +++ b/apps/dcauth/ui_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" +) + +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..bfae5d01 --- /dev/null +++ b/apps/dcauth/ui_windows.go @@ -0,0 +1,84 @@ +//go:build windows + +package main + +import ( + "syscall" + "unicode/utf16" + "unsafe" +) + +const ( + mbOK = 0x00000000 + mbOKCancel = 0x00000001 + mbIconError = 0x00000010 + mbIconQuestion = 0x00000020 + mbIconInformation = 0x00000040 + idOK = 1 +) + +const ( + cfUnicodeText = 13 + gmemMoveable = 0x0002 +) + +var ( + user32DLL = syscall.NewLazyDLL("user32.dll") + messageBoxW = user32DLL.NewProc("MessageBoxW") + openClip = user32DLL.NewProc("OpenClipboard") + closeClip = user32DLL.NewProc("CloseClipboard") + emptyClip = user32DLL.NewProc("EmptyClipboard") + setClipData = user32DLL.NewProc("SetClipboardData") + globalAlloc = kernel32DLL.NewProc("GlobalAlloc") + globalLock = kernel32DLL.NewProc("GlobalLock") + globalUnlock = kernel32DLL.NewProc("GlobalUnlock") +) + +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( + 0, + uintptr(unsafe.Pointer(textPtr)), + uintptr(unsafe.Pointer(titlePtr)), + uintptr(flags), + ) + return int(result) +} + +func copyToClipboard(text string) { + opened, _, _ := openClip.Call(0) + if opened == 0 { + return + } + defer closeClip.Call() + emptyClip.Call() + + 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/frontend/src/pages/admin/AdminLoginPage.tsx b/apps/frontend/src/pages/admin/AdminLoginPage.tsx index 02bcf346..c94349d7 100644 --- a/apps/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/apps/frontend/src/pages/admin/AdminLoginPage.tsx @@ -5,16 +5,24 @@ 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 brandFeatures = [ @@ -25,8 +33,8 @@ const brandFeatures = [ }, { icon: , - title: '权限管控', - desc: '按角色开放操作,操作可审计', + title: '本机认证', + desc: '账号密码 + 短信验证码 + DCAuth 设备签名', }, { icon: , @@ -38,11 +46,95 @@ const brandFeatures = [ 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) + + 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) { 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 ? 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 { + setLoading(false) + setWaiting(false) + setDevLaunchUrl('') + } + } + + async function submitLegacyLogin() { + let values: Pick + try { + values = await form.validateFields(['username', 'password']) + } catch { + return + } + setLoading(true) try { const response = await loginAdmin({ username: values.username.trim(), @@ -52,16 +144,37 @@ 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 { 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 (
- {/* 品牌信息区:移动端隐藏 */} - {/* 登录表单区 */}
- {/* 移动端精简品牌栏 */}
@@ -104,16 +215,58 @@ export default function AdminLoginPage() {

运营后台登录

-

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

+

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

- layout="vertical" requiredMark={false} onFinish={submitLogin}> + {waiting ? ( + <> + + {import.meta.env.DEV && devLaunchUrl ? ( + + + 浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行: + + + {devLaunchUrl} + + + cd apps/dcauth && go run . "{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 +326,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..81d8dbd2 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,33 @@ 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.machineCodeMask || row.deviceIdMasked} + + 绑定于 {formatAdminDateTime(row.deviceBoundAt || '')} + + + ) : ( + 未绑定 + ), + }, { title: '角色', dataIndex: 'role', @@ -343,8 +473,13 @@ export default function AdminUsersPage() { { title: '状态', dataIndex: 'status', - width: 140, - render: (value) => , + width: 170, + render: (value, row) => ( + + + {row.testAccount ? 技术测试 : null} + + ), }, { title: '创建时间', @@ -360,7 +495,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 +512,26 @@ export default function AdminUsersPage() { > 改名 + + + @@ -462,7 +617,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,20 +625,28 @@ export default function AdminUsersPage() { - + + + + - +