feat(dcauth): 新增后台手机号与设备认证登录

登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、
登录日志、设备绑定与重置接口。
This commit is contained in:
yml2213
2026-10-21 20:46:24 +08:00
parent 7542d547bf
commit 040100f290
36 changed files with 3128 additions and 41 deletions
+2
View File
@@ -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)
@@ -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';
+4 -1
View File
@@ -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')
@@ -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<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`
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<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`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<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`
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<boolean> {
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<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`
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<AdminUserDeviceRow | null> {
const result = await query<AdminUserDeviceRow>(
`
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<AdminUserDeviceRow | null> {
const result = await query<AdminUserDeviceRow>(
`
INSERT INTO admin_user_devices (
user_id, device_id, public_key, machine_code_hash, machine_code_mask,
client_version, status, bound_at, last_used_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7, $7, $7)
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<void> {
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<boolean> {
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
}
@@ -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,
],
)
@@ -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<AdminSessionRow>(
`
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
}
@@ -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<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
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<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
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<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
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<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
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<number> {
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)
}
@@ -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<AdminUserRow | null> {
@@ -69,6 +92,13 @@ export async function getAdminUserByUsername(username: string): Promise<AdminUse
return result.rows[0] || null
}
export async function getAdminUserByPhone(phone: string): Promise<AdminUserRow | null> {
const result = await query<AdminUserRow>(`${ADMIN_USER_SELECT} WHERE au.phone = $1 LIMIT 1`, [
String(phone || '').trim(),
])
return result.rows[0] || null
}
export async function createAdminUser(input: AdminUserCreateInput): Promise<AdminUserRow | null> {
const result = await query<{ [column: string]: unknown; id: number }>(
`
@@ -78,10 +108,14 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
password_hash,
role,
status,
phone,
phone_bound_at,
phone_bound_by,
test_account,
session_version,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id
`,
[
@@ -90,6 +124,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
input.passwordHash,
input.role,
input.status,
String(input.phone || '').trim(),
input.phoneBoundAt || null,
input.phoneBoundBy ?? null,
input.testAccount === true,
1,
input.createdAt,
input.updatedAt,
@@ -118,9 +156,13 @@ export async function updateAdminUser(
password_hash = $3,
role = $4,
status = $5,
session_version = $6,
updated_at = $7
WHERE id = $8
phone = $6,
phone_bound_at = $7,
phone_bound_by = $8,
test_account = $9,
session_version = $10,
updated_at = $11
WHERE id = $12
RETURNING id
`,
[
@@ -129,6 +171,10 @@ export async function updateAdminUser(
next.password_hash,
next.role,
next.status,
String(next.phone || '').trim(),
next.phone_bound_at || null,
next.phone_bound_by ?? null,
next.test_account === true,
Number(next.session_version || 1),
next.updated_at,
Number(userId),
@@ -151,7 +197,9 @@ export async function listAdminUsers({
if (keyword) {
params.push(`%${keyword}%`)
filters.push(`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`)
filters.push(
`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length} OR au.phone ILIKE $${params.length})`,
)
}
if (role) {
+105
View File
@@ -5,13 +5,17 @@ import {
getAdminSessionSummary,
loginAdmin,
logoutAdmin,
prepareAdminLogin,
completeAdminLogin,
} from '../../services/admin/admin-auth-service.js'
import { sendAdminLoginSmsCode } from '../../services/admin/admin-sms-service.js'
import {
resolveClientIp,
resolveClientLocation,
resolveUserAgent,
} from '../../services/admin/admin-login-log-service.js'
import { createJsonHandler, extractBearerToken } from './session.js'
import { createHttpError } from '../../utils/http.js'
const router = Router()
@@ -47,6 +51,63 @@ router.post(
),
)
router.post(
'/auth/login/sms-code',
adminLoginIpRateLimit,
adminLoginAccountRateLimit,
createJsonHandler(
(req) =>
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<string, unknown>; 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}`
}
+72
View File
@@ -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
+38
View File
@@ -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<string, unknown>)
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
@@ -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<Awaited<ReturnType<typeof getAdminUserById>>>
@@ -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<JsonObject> {
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<JsonObject> {
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<JsonObject> {
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<AdminSession> {
ensureAdminAuthConfigured()
@@ -236,6 +399,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
role: normalizeAdminRole(user.role),
expiresAt,
sessionVersion: currentSessionVersion,
deviceId: String(activeSession.device_id || ''),
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
}
}
@@ -257,6 +421,7 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
username: session.username,
displayName: session.displayName,
role: session.role,
deviceId: session.deviceId,
permissions: session.permissions,
},
}
@@ -297,6 +462,7 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
const username = normalizeUsername(payload.username)
const displayName = normalizeDisplayName(payload.displayName, username) || username
const password = normalizePassword(payload.password)
const phone = normalizeAdminPhone(payload.phone)
if (!username) {
throw createHttpError('缺少后台账号', {
@@ -321,6 +487,12 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
errorCode: 'admin_user_exists',
})
}
if (phone && (await getAdminUserByPhone(phone))) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const created = await createAdminUser({
@@ -329,6 +501,10 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
passwordHash: hashAdminPassword(password),
role: normalizeAdminRole(payload.role),
status: normalizeAdminUserStatus(payload.status || 'active'),
phone,
phoneBoundAt: phone ? now : null,
phoneBoundBy: null,
testAccount: payload.testAccount === true,
createdAt: now,
updatedAt: now,
})
@@ -430,6 +606,92 @@ export async function updateManagedAdminUserDisplayName(
}
}
export async function updateManagedAdminUserPhone(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
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<JsonObject> {
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<JsonObject> {
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
@@ -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<AdminDeviceChallengeResult> {
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<string, unknown>): Promise<void> {
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<Record<string, unknown>> {
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<string, unknown>,
): Promise<AdminDeviceChallengeRow> {
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<AdminUserDeviceRow> {
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()}`
}
@@ -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 },
@@ -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<Record<string, unknown>> {
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<void> {
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
}
+71
View File
@@ -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 作为唯一客户端。
+89
View File
@@ -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
}
+11
View File
@@ -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
}
+73
View File
@@ -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))...)
}
+3
View File
@@ -0,0 +1,3 @@
module dcauth
go 1.23
+19
View File
@@ -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)
}
+71
View File
@@ -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 ""
}
+338
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package main
func registerProtocol() {}
+26
View File
@@ -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()
}
+27
View File
@@ -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
}
+84
View File
@@ -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)
}
+215 -14
View File
@@ -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: <SafetyCertificateOutlined />,
title: '权限管控',
desc: '按角色开放操作,操作可审计',
title: '本机认证',
desc: '账号密码 + 短信验证码 + DCAuth 设备签名',
},
{
icon: <SettingOutlined />,
@@ -38,11 +46,95 @@ const brandFeatures = [
export default function AdminLoginPage() {
const navigate = useNavigate()
const { message } = App.useApp()
const [form] = Form.useForm<LoginForm>()
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<number | null>(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<LoginForm, 'username' | 'password'>
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 (
<main className="auth-page">
<div className="auth-shell">
{/* 品牌信息区:移动端隐藏 */}
<aside className="auth-brand">
<div className="auth-brand-logo">
<span className="auth-logo-mark">
@@ -72,7 +185,7 @@ export default function AdminLoginPage() {
<div className="auth-brand-body">
<h2>运营后台管理中心</h2>
<p>统一管理订单履约、打手调度与财务结算,按角色开放不同操作权限。</p>
<p>账号密码验证通过后,还需短信验证码和本机 DCAuth 设备签名才能进入后台。</p>
<div className="auth-brand-features">
{brandFeatures.map((item) => (
<div className="auth-brand-feature" key={item.title}>
@@ -91,10 +204,8 @@ export default function AdminLoginPage() {
</div>
</aside>
{/* 登录表单区 */}
<section className="auth-form-side">
<div className="auth-form-card">
{/* 移动端精简品牌栏 */}
<div className="auth-mobile-brand">
<span className="auth-logo-mark">
<DashboardOutlined />
@@ -104,16 +215,58 @@ export default function AdminLoginPage() {
<div className="auth-form-header">
<h1>运营后台登录</h1>
<p>使用账号密码进入后台,并按角色开放不同操作权限</p>
<p>账号密码 + 短信验证码 + 本机 DCAuth 设备验证</p>
</div>
<Form<LoginForm> layout="vertical" requiredMark={false} onFinish={submitLogin}>
{waiting ? (
<>
<Alert
type="info"
showIcon
message="等待本机确认"
description="请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth.exe。"
style={{ marginBottom: 16 }}
/>
{import.meta.env.DEV && devLaunchUrl ? (
<Alert
type="warning"
showIcon
message="macOS 开发调试"
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text type="secondary">
浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行:
</Typography.Text>
<Typography.Paragraph copyable={{ text: devLaunchUrl }} style={{ margin: 0 }}>
{devLaunchUrl}
</Typography.Paragraph>
<Typography.Text code copyable={{ text: `cd apps/dcauth && go run . "${devLaunchUrl}"` }}>
cd apps/dcauth && go run . "{devLaunchUrl}"
</Typography.Text>
</Space>
}
style={{ marginBottom: 16 }}
/>
) : null}
</>
) : null}
<Form<LoginForm>
form={form}
layout="vertical"
requiredMark={false}
onFinish={submitLogin}
>
<Form.Item
name="username"
label="账号"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input prefix={<UserOutlined />} placeholder="输入账号" autoComplete="username" />
<Input
prefix={<UserOutlined />}
placeholder="中文 / 英文 / 数字账号"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
@@ -126,11 +279,46 @@ export default function AdminLoginPage() {
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
name="smsCode"
label={`短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
rules={[{ required: true, message: '请输入短信验证码' }]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位短信验证码"
maxLength={6}
autoComplete="one-time-code"
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sendingCode}
disabled={countdown > 0}
onClick={() => void sendCode()}
style={{ padding: 0, height: 'auto' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading} size="large">
登录后台
安全登录 · 拉起本机验证
</Button>
</Form>
<Divider plain style={{ margin: '18px 0 12px' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
技术测试账号 / 未绑定手机号
</Typography.Text>
</Divider>
<Button type="link" block onClick={() => void submitLegacyLogin()} disabled={loading}>
账号密码登录(测试 / 过渡)
</Button>
<div className="auth-form-footer">仅限授权运营人员访问</div>
</div>
</section>
@@ -138,3 +326,16 @@ export default function AdminLoginPage() {
</main>
)
}
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))
}
+174 -11
View File
@@ -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: (
<Form
layout="vertical"
preserve={false}
initialValues={{ phone: item.phone || '' }}
ref={form.bind}
className="modal-form"
>
<Form.Item
name="phone"
label={`${displayNameOf(item)} 的登录手机号`}
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
extra="绑定后该账号必须使用账号密码 + 短信验证码 + 本机 DCAuth 登录"
>
<Input placeholder="11 位手机号" maxLength={11} />
</Form.Item>
</Form>
),
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) => <Typography.Text type="secondary">{value}</Typography.Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 150,
render: (value: string) =>
value ? (
<Typography.Text>{value}</Typography.Text>
) : (
<Tag color="orange">未绑定</Tag>
),
},
{
title: '登录设备',
dataIndex: 'deviceBound',
width: 210,
render: (_: boolean, row) =>
row.deviceBound ? (
<Space direction="vertical" size={0}>
<Typography.Text>{row.machineCodeMask || row.deviceIdMasked}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
绑定于 {formatAdminDateTime(row.deviceBoundAt || '')}
</Typography.Text>
</Space>
) : (
<Tag>未绑定</Tag>
),
},
{
title: '角色',
dataIndex: 'role',
@@ -343,8 +473,13 @@ export default function AdminUsersPage() {
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (value) => <StatusTag value={value} />,
width: 170,
render: (value, row) => (
<Space wrap>
<StatusTag value={value} />
{row.testAccount ? <Tag color="purple">技术测试</Tag> : null}
</Space>
),
},
{
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() {
>
改名
</Button>
<Button
loading={actionLoadingId === row.userId}
onClick={() => openBindPhoneModal(row)}
>
绑定手机号
</Button>
<Button
disabled={!row.deviceBound}
danger
loading={actionLoadingId === row.userId}
onClick={() => confirmResetDevice(row)}
>
重置设备
</Button>
<Button
loading={actionLoadingId === row.userId}
onClick={() => confirmToggleTestAccount(row)}
>
{row.testAccount ? '取消测试账号' : '设为测试账号'}
</Button>
<Dropdown
disabled={isCurrent}
menu={{
@@ -412,7 +567,7 @@ export default function AdminUsersPage() {
<section className="page-stack">
<PageHeader
title="后台用户"
description="管理员可维护后台账号、姓名、角色和启停状态,避免继续使用单一口令。"
description="管理员可维护后台账号、姓名、手机号、测试账号、角色、设备绑定和启停状态。"
/>
<Card className="users-action-card">
@@ -462,7 +617,7 @@ export default function AdminUsersPage() {
<Form<CreateUserForm>
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() {
<Input placeholder="姓名(可中文)" className="users-input-md" maxLength={32} />
</Form.Item>
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
<Input placeholder="登录账号(英文/数字)" className="users-input-md" />
<Input placeholder="登录账号(中文/英文/数字)" className="users-input-md" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入新密码' }, { min: 8, message: '密码至少 8 位' }]}>
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
</Form.Item>
<Form.Item
name="password"
name="phone"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的 11 位手机号',
},
]}
>
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
<Input placeholder="绑定手机号(可选)" className="users-input-md" maxLength={11} />
</Form.Item>
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select options={USER_ROLE_OPTIONS} className="users-select-sm" />
</Form.Item>
<Form.Item name="testAccount" valuePropName="checked">
<Switch checkedChildren="技术测试" unCheckedChildren="正式账号" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
新增用户
@@ -516,7 +679,7 @@ export default function AdminUsersPage() {
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无用户" /> }}
scroll={{ x: 1220 }}
scroll={{ x: 1680 }}
pagination={buildAdminTablePagination({
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
+35 -1
View File
@@ -1,10 +1,44 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminLoginResponse, AdminSessionSummary } from '@/types/admin'
import type {
AdminDevicePrepareResponse,
AdminDeviceStatusResponse,
AdminLoginResponse,
AdminSessionSummary,
AdminSmsCodeResponse,
} from '@/types/admin'
export function loginAdmin(payload: { username: string; password: string }) {
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login', payload)
}
export function sendAdminLoginSmsCode(payload: { username: string; password: string }) {
return apiPost<AdminSmsCodeResponse>('/api/v1/admin/auth/login/sms-code', payload)
}
export function prepareAdminLogin(payload: {
username: string
password: string
smsCode: string
apiBase: string
}) {
return apiPost<AdminDevicePrepareResponse>('/api/v1/admin/auth/login/prepare', payload)
}
export function fetchAdminDeviceStatus(challengeId: string, pollToken: string) {
return apiGet<AdminDeviceStatusResponse>(
`/api/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
{ pollToken },
)
}
export function completeAdminLogin(payload: {
challengeId: string
pollToken: string
deviceTicket: string
}) {
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login/complete', payload)
}
export function fetchAdminSession() {
return apiGet<AdminSessionSummary>('/api/v1/admin/auth/session')
}
+23
View File
@@ -39,6 +39,8 @@ export function createAdminUser(payload: {
password: string
role: string
status?: string
phone?: string
testAccount?: boolean
}) {
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
}
@@ -64,3 +66,24 @@ export function resetAdminUserPassword(userId: number | string, payload: { passw
payload,
)
}
export function updateAdminUserPhone(userId: number | string, payload: { phone: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/phone`, payload)
}
export function resetAdminUserDevice(userId: number | string) {
return apiPost<{ user: AdminUserListItem; reset: boolean }>(
`/api/v1/admin/users/${userId}/device/reset`,
{},
)
}
export function updateAdminUserTestAccount(
userId: number | string,
payload: { testAccount: boolean },
) {
return apiPost<{ user: AdminUserListItem; testAccount: boolean }>(
`/api/v1/admin/users/${userId}/test-account`,
payload,
)
}
+26
View File
@@ -9,10 +9,36 @@ export interface AdminLoginResponse {
username: string
displayName: string
role: AdminRole
deviceId?: string
permissions?: string[]
}
}
export interface AdminSmsCodeResponse {
sent: boolean
phoneMasked: string
expiresInSeconds: number
provider: string
}
export interface AdminDevicePrepareResponse {
challengeId: string
challenge: string
expiresAt: string
pollToken: string
launchUrl: string
phoneMasked: string
app: string
}
export interface AdminDeviceStatusResponse {
status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected'
deviceTicket?: string
deviceId?: string
machineCodeMask?: string
message?: string
}
export interface AdminSessionSummary {
authenticated: boolean
expiresAt: string
+5
View File
@@ -3,6 +3,11 @@ export type { AdminPagination } from './common'
// Auth types
export type { AdminRole, AdminUserStatus, AdminLoginResponse, AdminSessionSummary } from './auth'
export type {
AdminDevicePrepareResponse,
AdminDeviceStatusResponse,
AdminSmsCodeResponse,
} from './auth'
// Users types
export type { AdminUserListItem } from './users'
+10
View File
@@ -6,6 +6,16 @@ export interface AdminUserListItem {
displayName: string
role: AdminRole
status: AdminUserStatus
phone: string
phoneMasked: string
phoneBoundAt: string | null
testAccount: boolean
deviceBound: boolean
deviceId: string
deviceIdMasked: string
machineCodeMask: string
deviceBoundAt: string | null
deviceLastUsedAt: string | null
createdAt: string
updatedAt: string
}
+142
View File
@@ -0,0 +1,142 @@
# dcauth 设备认证协议 v1
三个系统共用同一个 Windows 客户端 `DCAuth.exe`。客户端通过自定义协议被浏览器唤起,使用本机 Ed25519 私钥对服务端 challenge 签名,实现“账号 + 密码 + 短信验证码 + 本机设备”登录。
## 1. 适用范围
| app | 系统 | 生产入口 |
|---|---|---|
| `order_site` | 订单后台 | `https://order.khhao.com` |
| `live_hub` | 直播运营后台 | `https://live.khhao.com` |
| `affiliate_dash` | 皮肤分销后台 | `https://skin.khhao.com` |
本地开发入口由客户端内置白名单放行,生产环境只允许上表 HTTPS 入口。
## 2. 客户端协议
```text
dcauth://authorize?app=<app>&challenge_id=<id>&challenge=<base64url>&api=<urlencoded-api-origin>
```
客户端收到协议后:
1. 校验 `app` 是否为已知应用。
2. 校验 `api` 的 origin 是否在该应用白名单内。
3. 弹出确认框(显示应用名和“确认本机登录”)。
4. 读取本机设备私钥,生成签名。
5. 向 `${api}/api/dcauth/authorize` 提交签名。
6. 浏览器轮询 challenge 状态,拿到一次性 ticket 后完成登录。
客户端不允许打开任意 URL,也不接受任意 `api`,防止被恶意网页利用。
## 3. 设备身份
- 私钥:Ed25519,保存在 `%LOCALAPPDATA%\DCAuth\device.key`,使用 Windows DPAPI 加密。
- `deviceId`:`D1-` + Base32(SHA256(`dcauth.device.v1` + 公钥)) 前 20 位。
- `machineCode`:`M1-` + Base32(SHA256(硬件指纹)) 前 20 位。
- 硬件指纹采集:主板 UUID、Windows MachineGuid、主板序列号、BIOS 序列号、系统盘序列号、CPU ProcessorId。
- 不采集 MAC、IP、主机名作为主要依据;空值、全 0、`To be filled by O.E.M.` 等占位值会被丢弃。
- 服务端只保存机器码哈希/脱敏值和设备公钥,不保存原始硬件序列号。
## 4. 签名原文
使用 UTF-8、换行分隔,字段顺序固定:
```text
dcauth.v1
<app>
<challenge_id>
<challenge>
<device_id>
<machine_code>
<timestamp>
```
`timestamp` 为 Unix 秒。服务端允许 ±300 秒偏差。
## 5. 后端接口
所有系统统一使用以下路径:
### 创建 challenge
```http
POST /api/dcauth/challenge
Content-Type: application/json
{"app":"order_site","purpose":"login"}
```
响应:
```json
{
"challengeId": "uuid",
"challenge": "base64url-random",
"expiresAt": "2026-10-21T10:00:00Z",
"pollToken": "一次性轮询密钥"
}
```
### 客户端提交签名
```http
POST /api/dcauth/authorize
Content-Type: application/json
{
"app": "order_site",
"challengeId": "uuid",
"challenge": "base64url-random",
"deviceId": "D1-....",
"machineCode": "M1-....",
"publicKey": "base64url-ed25519-public-key",
"signature": "base64url-ed25519-signature",
"timestamp": 1760000000,
"clientVersion": "1.0.0"
}
```
服务端校验 challenge、app、时间戳、Ed25519 签名后,将 challenge 标记为 `authorized`,并记录 `deviceId/publicKey/machineCode`。
### 浏览器轮询
```http
GET /api/dcauth/challenge/{challengeId}/status
X-Poll-Token: <pollToken>
```
状态:`pending` / `authorized` / `ticket_issued` / `consumed` / `expired` / `rejected`。
`authorized` 时返回一次性 `deviceTicket`,浏览器用它在登录完成接口换取系统会话。
### 登录完成
各系统在自己的登录接口中增加 `deviceTicket` 参数。服务端必须:
1. 验证账号/密码。
2. 验证短信验证码。
3. 消费 `deviceTicket`。
4. 如果用户没有有效设备绑定,则把本次 `deviceId/publicKey/machineCode` 绑定到用户。
5. 如果已有有效绑定,则要求 `deviceId` 和公钥完全一致。
6. 成功后才签发 JWT / Session。
## 6. 首次绑定与重置
- 首次登录自动绑定:账号/密码 + 短信验证码 + 设备签名全部通过后,自动写入有效绑定。
- 一个账号只能有一条有效设备绑定。
- 管理员可以重置绑定;重置后旧会话必须失效(JWT/session version 递增)。
- 同一台机器可以绑定多个账号,但一个账号只能绑定一台设备。
## 7. 兼容策略
- 保留原有账号密码登录接口作为过渡。
- 账号未绑定手机号时,允许旧密码登录,并提示管理员绑定手机号。
- 账号已绑定手机号后,登录必须走“密码 + 短信 + 设备”流程。
- 新账号默认要求绑定手机号;管理员可以在用户管理中设置/修改手机号。
- 管理员可以给技术测试账号打开 `test_account`:该账号允许仅账号密码登录,跳过短信和 DCAuth 设备验证,登录日志记录为 `password_test`。
- 最终可以通过环境变量强制所有账号走新流程。
## 8. 用户名规则
三个系统均允许中文、英文、数字、`.`、`_`、`-`,长度 2–32(affiliate_dash 保留原 3–64 的兼容范围)。不允许空格和控制字符。