feat(dcauth): 新增后台手机号与设备认证登录
登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、 登录日志、设备绑定与重置接口。
This commit is contained in:
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 作为唯一客户端。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))...)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module dcauth
|
||||
|
||||
go 1.23
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func registerProtocol() {}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 的兼容范围)。不允许空格和控制字符。
|
||||
Reference in New Issue
Block a user