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

登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、
登录日志、设备绑定与重置接口。
This commit is contained in:
yml2213
2026-10-21 20:46:24 +08:00
parent 7542d547bf
commit 040100f290
36 changed files with 3128 additions and 41 deletions
+2
View File
@@ -7,6 +7,7 @@ import affiliateDashRouter from './routes/affiliate-dash.js'
import collectRouter from './routes/collect.js'
import filesRouter from './routes/files.js'
import claimsRouter from './routes/claims.js'
import dcAuthRouter from './routes/dcauth.js'
import douyinMashidaRouter from './routes/douyin-mashida.js'
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
import open91Router from './routes/open-91.js'
@@ -115,6 +116,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
app.use('/api/v1/pdd/extension', pddExtensionRouter)
app.use('/api/v1/files', filesRouter)
app.use('/api/v1/worker', workerRouter)
app.use('/api/dcauth', dcAuthRouter)
app.use('/api/v1/admin', adminRouter)
app.use('/s', shortLinksRouter)
@@ -0,0 +1,116 @@
-- 102_admin_phone_device_auth.sql —— 后台账号手机号、短信验证码与设备认证。
--
-- 登录链路:账号/密码 + 短信验证码 + DCAuth 设备签名。
-- 旧账号未绑定手机号时仍可使用原密码登录;绑定手机号后强制走新链路。
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '';
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS phone_bound_at TIMESTAMPTZ;
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS phone_bound_by BIGINT;
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS test_account BOOLEAN NOT NULL DEFAULT FALSE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_admin_users_phone_bound_by'
) THEN
ALTER TABLE admin_users
ADD CONSTRAINT fk_admin_users_phone_bound_by
FOREIGN KEY (phone_bound_by) REFERENCES admin_users(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_phone
ON admin_users(phone)
WHERE phone <> '';
CREATE TABLE IF NOT EXISTS admin_sms_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
phone TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'login',
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
failed_attempt_count INTEGER NOT NULL DEFAULT 0,
request_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_admin_sms_codes_user_created
ON admin_sms_codes(user_id, purpose, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_sms_codes_phone_created
ON admin_sms_codes(phone, created_at DESC);
CREATE TABLE IF NOT EXISTS admin_user_devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
public_key TEXT NOT NULL,
machine_code_hash TEXT NOT NULL,
machine_code_mask TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
bound_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_user_devices_active_user
ON admin_user_devices(user_id)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_admin_user_devices_device_id
ON admin_user_devices(device_id);
CREATE TABLE IF NOT EXISTS admin_device_challenges (
id BIGSERIAL PRIMARY KEY,
challenge_id TEXT NOT NULL UNIQUE,
app TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'login',
challenge TEXT NOT NULL,
poll_token_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
device_id TEXT NOT NULL DEFAULT '',
public_key TEXT NOT NULL DEFAULT '',
machine_code_hash TEXT NOT NULL DEFAULT '',
machine_code_mask TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
device_ticket_hash TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
authorized_at TIMESTAMPTZ,
ticket_issued_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_admin_device_challenges_expires
ON admin_device_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_admin_device_challenges_user
ON admin_device_challenges(user_id, created_at DESC);
ALTER TABLE admin_login_logs
ADD COLUMN IF NOT EXISTS login_method TEXT NOT NULL DEFAULT 'password';
ALTER TABLE admin_login_logs
ADD COLUMN IF NOT EXISTS machine_code_mask TEXT NOT NULL DEFAULT '';
ALTER TABLE admin_sessions
ADD COLUMN IF NOT EXISTS device_id TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN admin_users.phone IS '后台账号绑定手机号,用于登录短信验证码';
COMMENT ON COLUMN admin_users.test_account IS '技术测试账号:允许仅账号密码登录,跳过短信和 DCAuth 设备验证';
COMMENT ON TABLE admin_sms_codes IS '后台登录短信验证码(哈希存储、一次性消费)';
COMMENT ON TABLE admin_user_devices IS '后台账号绑定的 DCAuth 设备(公钥 + 机器码哈希)';
COMMENT ON TABLE admin_device_challenges IS 'DCAuth 设备认证 challenge 与一次性 ticket';
COMMENT ON COLUMN admin_login_logs.login_method IS 'password / sms_device';
COMMENT ON COLUMN admin_login_logs.machine_code_mask IS '登录设备机器码脱敏值';
COMMENT ON COLUMN admin_sessions.device_id IS '登录会话绑定的设备 ID';
+4 -1
View File
@@ -19,7 +19,10 @@ export function createCorsMiddleware(
}
}
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Shop-Token')
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Shop-Token, X-Poll-Token',
)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Max-Age', '86400')
@@ -0,0 +1,252 @@
import { query } from '../db/client.js'
export type AdminDeviceChallengeRow = {
id: number
challenge_id: string
app: string
purpose: string
challenge: string
poll_token_hash: string
status: string
user_id: number | null
device_id: string
public_key: string
machine_code_hash: string
machine_code_mask: string
client_version: string
device_ticket_hash: string
expires_at: string
authorized_at: string | null
ticket_issued_at: string | null
consumed_at: string | null
created_at: string
updated_at: string
}
export type AdminUserDeviceRow = {
id: number
user_id: number
device_id: string
public_key: string
machine_code_hash: string
machine_code_mask: string
client_version: string
status: string
bound_at: string
last_used_at: string | null
revoked_at: string | null
revoked_by: number | null
created_at: string
updated_at: string
}
export async function createAdminDeviceChallenge(input: {
challengeId: string
app: string
purpose: string
challenge: string
pollTokenHash: string
userId: number | null
expiresAt: string
now: string
}): Promise<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`
INSERT INTO admin_device_challenges (
challenge_id, app, purpose, challenge, poll_token_hash, status, user_id,
expires_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $8)
RETURNING *
`,
[
input.challengeId,
input.app,
input.purpose,
input.challenge,
input.pollTokenHash,
input.userId,
input.expiresAt,
input.now,
],
)
return result.rows[0] || null
}
export async function getAdminDeviceChallenge(
challengeId: string,
): Promise<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`SELECT * FROM admin_device_challenges WHERE challenge_id = $1 LIMIT 1`,
[challengeId],
)
return result.rows[0] || null
}
export async function authorizeAdminDeviceChallenge(input: {
challengeId: string
app: string
deviceId: string
publicKey: string
machineCodeHash: string
machineCodeMask: string
clientVersion: string
now: string
}): Promise<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`
UPDATE admin_device_challenges
SET
status = 'authorized',
device_id = $3,
public_key = $4,
machine_code_hash = $5,
machine_code_mask = $6,
client_version = $7,
authorized_at = $8,
updated_at = $8
WHERE challenge_id = $1
AND app = $2
AND status = 'pending'
AND expires_at > $8
RETURNING *
`,
[
input.challengeId,
input.app,
input.deviceId,
input.publicKey,
input.machineCodeHash,
input.machineCodeMask,
input.clientVersion,
input.now,
],
)
return result.rows[0] || null
}
export async function issueAdminDeviceTicket(input: {
challengeId: string
ticketHash: string
now: string
}): Promise<boolean> {
const result = await query(
`
UPDATE admin_device_challenges
SET
status = 'ticket_issued',
device_ticket_hash = $2,
ticket_issued_at = $3,
updated_at = $3
WHERE challenge_id = $1
AND status = 'authorized'
AND expires_at > $3
`,
[input.challengeId, input.ticketHash, input.now],
)
return result.rowCount === 1
}
export async function consumeAdminDeviceTicket(input: {
challengeId: string
pollTokenHash: string
ticketHash: string
now: string
}): Promise<AdminDeviceChallengeRow | null> {
const result = await query<AdminDeviceChallengeRow>(
`
UPDATE admin_device_challenges
SET status = 'consumed', consumed_at = $4, updated_at = $4
WHERE challenge_id = $1
AND poll_token_hash = $2
AND device_ticket_hash = $3
AND status = 'ticket_issued'
AND expires_at > $4
RETURNING *
`,
[input.challengeId, input.pollTokenHash, input.ticketHash, input.now],
)
return result.rows[0] || null
}
export async function getActiveAdminUserDevice(
userId: number | string,
): Promise<AdminUserDeviceRow | null> {
const result = await query<AdminUserDeviceRow>(
`
SELECT *
FROM admin_user_devices
WHERE user_id = $1 AND status = 'active'
ORDER BY id DESC
LIMIT 1
`,
[Number(userId)],
)
return result.rows[0] || null
}
export async function bindAdminUserDevice(input: {
userId: number
deviceId: string
publicKey: string
machineCodeHash: string
machineCodeMask: string
clientVersion: string
now: string
}): Promise<AdminUserDeviceRow | null> {
const result = await query<AdminUserDeviceRow>(
`
INSERT INTO admin_user_devices (
user_id, device_id, public_key, machine_code_hash, machine_code_mask,
client_version, status, bound_at, last_used_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7, $7, $7)
ON CONFLICT (user_id) WHERE status = 'active'
DO UPDATE SET
device_id = EXCLUDED.device_id,
public_key = EXCLUDED.public_key,
machine_code_hash = EXCLUDED.machine_code_hash,
machine_code_mask = EXCLUDED.machine_code_mask,
client_version = EXCLUDED.client_version,
bound_at = EXCLUDED.bound_at,
last_used_at = EXCLUDED.last_used_at,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
input.userId,
input.deviceId,
input.publicKey,
input.machineCodeHash,
input.machineCodeMask,
input.clientVersion,
input.now,
],
)
return result.rows[0] || null
}
export async function touchAdminUserDevice(deviceId: string, now: string): Promise<void> {
await query(
`
UPDATE admin_user_devices
SET last_used_at = $2, updated_at = $2
WHERE device_id = $1 AND status = 'active'
`,
[deviceId, now],
)
}
export async function resetAdminUserDevice(
userId: number | string,
revokedBy: number | null,
now: string,
): Promise<boolean> {
const result = await query(
`
UPDATE admin_user_devices
SET status = 'revoked', revoked_at = $2, revoked_by = $3, updated_at = $2
WHERE user_id = $1 AND status = 'active'
`,
[Number(userId), now, revokedBy],
)
return Number(result.rowCount || 0) > 0
}
@@ -12,6 +12,8 @@ export type AdminLoginLogRow = {
user_agent: string
success: boolean
failure_reason: string
login_method: string
machine_code_mask: string
created_at: string
}
@@ -24,6 +26,8 @@ type AdminLoginLogCreateInput = {
userAgent?: string
success?: boolean
failureReason?: string
loginMethod?: string
machineCodeMask?: string
createdAt: string
}
@@ -49,8 +53,10 @@ export async function createAdminLoginLog(
user_agent,
success,
failure_reason,
login_method,
machine_code_mask,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`,
[
@@ -62,6 +68,8 @@ export async function createAdminLoginLog(
String(input.userAgent || '').trim(),
input.success !== false,
String(input.failureReason || '').trim(),
String(input.loginMethod || 'password').trim() || 'password',
String(input.machineCodeMask || '').trim(),
input.createdAt,
],
)
@@ -4,6 +4,7 @@ export type AdminSessionRow = {
id: number
session_id: string
user_id: number
device_id: string
status: string
issued_at: string
last_seen_at: string
@@ -16,6 +17,7 @@ export type AdminSessionRow = {
export async function createAdminSession(input: {
sessionId: string
userId: number
deviceId?: string
issuedAt: string
expiresAt: string
now: string
@@ -23,12 +25,19 @@ export async function createAdminSession(input: {
const result = await query<AdminSessionRow>(
`
INSERT INTO admin_sessions (
session_id, user_id, status, issued_at, last_seen_at,
session_id, user_id, device_id, status, issued_at, last_seen_at,
expires_at, revoked_at, created_at, updated_at
) VALUES ($1, $2, 'active', $3, $3, $4, NULL, $5, $5)
) VALUES ($1, $2, $3, 'active', $4, $4, $5, NULL, $6, $6)
RETURNING *
`,
[input.sessionId, input.userId, input.issuedAt, input.expiresAt, input.now],
[
input.sessionId,
input.userId,
String(input.deviceId || '').trim(),
input.issuedAt,
input.expiresAt,
input.now,
],
)
return result.rows[0] || null
}
@@ -0,0 +1,117 @@
import { query } from '../db/client.js'
export type AdminSmsCodeRow = {
id: number
user_id: number
phone: string
purpose: string
code_hash: string
expires_at: string
consumed_at: string | null
failed_attempt_count: number
request_ip: string
created_at: string
}
export async function createAdminSmsCode(input: {
userId: number
phone: string
purpose: string
codeHash: string
expiresAt: string
requestIp: string
now: string
}): Promise<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
INSERT INTO admin_sms_codes (
user_id, phone, purpose, code_hash, expires_at, consumed_at,
failed_attempt_count, request_ip, created_at
) VALUES ($1, $2, $3, $4, $5, NULL, 0, $6, $7)
RETURNING *
`,
[
input.userId,
input.phone,
input.purpose,
input.codeHash,
input.expiresAt,
input.requestIp,
input.now,
],
)
return result.rows[0] || null
}
export async function getLatestAdminSmsCode(
userId: number,
purpose: string,
): Promise<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
SELECT *
FROM admin_sms_codes
WHERE user_id = $1 AND purpose = $2
ORDER BY id DESC
LIMIT 1
`,
[userId, purpose],
)
return result.rows[0] || null
}
export async function consumeAdminSmsCode(
codeId: number,
now: string,
): Promise<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
UPDATE admin_sms_codes
SET consumed_at = $1
WHERE id = $2 AND consumed_at IS NULL
RETURNING *
`,
[now, codeId],
)
return result.rows[0] || null
}
export async function recordAdminSmsCodeFailure(
codeId: number,
now: string,
maxAttempts: number,
): Promise<AdminSmsCodeRow | null> {
const result = await query<AdminSmsCodeRow>(
`
UPDATE admin_sms_codes
SET
failed_attempt_count = COALESCE(failed_attempt_count, 0) + 1,
consumed_at = CASE
WHEN COALESCE(failed_attempt_count, 0) + 1 >= $3 THEN $2
ELSE consumed_at
END
WHERE id = $1 AND consumed_at IS NULL
RETURNING *
`,
[codeId, now, Math.max(1, Math.floor(maxAttempts))],
)
return result.rows[0] || null
}
export async function countAdminSmsCodesOnDay(
userId: number,
dayStartIso: string,
dayEndIso: string,
): Promise<number> {
const result = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM admin_sms_codes
WHERE user_id = $1
AND created_at >= $2
AND created_at < $3
`,
[userId, dayStartIso, dayEndIso],
)
return Number(result.rows[0]?.total || 0)
}
@@ -7,9 +7,17 @@ type AdminUserRow = {
password_hash: string
role: string
status: string
phone: string
phone_bound_at: string | null
phone_bound_by: number | null
test_account: boolean
session_version: number
created_at: string
updated_at: string
device_id?: string | null
machine_code_mask?: string | null
device_bound_at?: string | null
device_last_used_at?: string | null
}
type AdminUserCreateInput = {
@@ -18,6 +26,10 @@ type AdminUserCreateInput = {
passwordHash: string
role: string
status: string
phone?: string
phoneBoundAt?: string | null
phoneBoundBy?: number | null
testAccount?: boolean
createdAt: string
updatedAt: string
}
@@ -30,6 +42,10 @@ type AdminUserPatch = Partial<
| 'password_hash'
| 'role'
| 'status'
| 'phone'
| 'phone_bound_at'
| 'phone_bound_by'
| 'test_account'
| 'session_version'
| 'updated_at'
>
@@ -49,8 +65,15 @@ type AdminUserListResult = {
}
const ADMIN_USER_SELECT = `
SELECT au.*
SELECT
au.*,
d.device_id AS device_id,
d.machine_code_mask AS machine_code_mask,
d.bound_at AS device_bound_at,
d.last_used_at AS device_last_used_at
FROM admin_users au
LEFT JOIN admin_user_devices d
ON d.user_id = au.id AND d.status = 'active'
`
export async function getAdminUserById(userId: number | string): Promise<AdminUserRow | null> {
@@ -69,6 +92,13 @@ export async function getAdminUserByUsername(username: string): Promise<AdminUse
return result.rows[0] || null
}
export async function getAdminUserByPhone(phone: string): Promise<AdminUserRow | null> {
const result = await query<AdminUserRow>(`${ADMIN_USER_SELECT} WHERE au.phone = $1 LIMIT 1`, [
String(phone || '').trim(),
])
return result.rows[0] || null
}
export async function createAdminUser(input: AdminUserCreateInput): Promise<AdminUserRow | null> {
const result = await query<{ [column: string]: unknown; id: number }>(
`
@@ -78,10 +108,14 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
password_hash,
role,
status,
phone,
phone_bound_at,
phone_bound_by,
test_account,
session_version,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id
`,
[
@@ -90,6 +124,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
input.passwordHash,
input.role,
input.status,
String(input.phone || '').trim(),
input.phoneBoundAt || null,
input.phoneBoundBy ?? null,
input.testAccount === true,
1,
input.createdAt,
input.updatedAt,
@@ -118,9 +156,13 @@ export async function updateAdminUser(
password_hash = $3,
role = $4,
status = $5,
session_version = $6,
updated_at = $7
WHERE id = $8
phone = $6,
phone_bound_at = $7,
phone_bound_by = $8,
test_account = $9,
session_version = $10,
updated_at = $11
WHERE id = $12
RETURNING id
`,
[
@@ -129,6 +171,10 @@ export async function updateAdminUser(
next.password_hash,
next.role,
next.status,
String(next.phone || '').trim(),
next.phone_bound_at || null,
next.phone_bound_by ?? null,
next.test_account === true,
Number(next.session_version || 1),
next.updated_at,
Number(userId),
@@ -151,7 +197,9 @@ export async function listAdminUsers({
if (keyword) {
params.push(`%${keyword}%`)
filters.push(`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`)
filters.push(
`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length} OR au.phone ILIKE $${params.length})`,
)
}
if (role) {
+105
View File
@@ -5,13 +5,17 @@ import {
getAdminSessionSummary,
loginAdmin,
logoutAdmin,
prepareAdminLogin,
completeAdminLogin,
} from '../../services/admin/admin-auth-service.js'
import { sendAdminLoginSmsCode } from '../../services/admin/admin-sms-service.js'
import {
resolveClientIp,
resolveClientLocation,
resolveUserAgent,
} from '../../services/admin/admin-login-log-service.js'
import { createJsonHandler, extractBearerToken } from './session.js'
import { createHttpError } from '../../utils/http.js'
const router = Router()
@@ -47,6 +51,63 @@ router.post(
),
)
router.post(
'/auth/login/sms-code',
adminLoginIpRateLimit,
adminLoginAccountRateLimit,
createJsonHandler(
(req) =>
sendAdminLoginSmsCode(req.body?.username, req.body?.password, {
ip: resolveClientIp(req),
userAgent: resolveUserAgent(req),
location: resolveClientLocation(req),
}),
{
successMessage: '验证码已发送',
errorMessage: '验证码发送失败',
scope: '[admin/auth/login/sms-code]',
},
),
)
router.post(
'/auth/login/prepare',
adminLoginIpRateLimit,
adminLoginAccountRateLimit,
createJsonHandler(
(req) =>
prepareAdminLogin(req.body?.username, req.body?.password, req.body?.smsCode, {
ip: resolveClientIp(req),
userAgent: resolveUserAgent(req),
location: resolveClientLocation(req),
apiBase: resolveApiBase(req, req.body?.apiBase),
}),
{
successMessage: '请在本机 DCAuth 中确认登录',
errorMessage: '准备设备认证失败',
scope: '[admin/auth/login/prepare]',
},
),
)
router.post(
'/auth/login/complete',
adminLoginIpRateLimit,
createJsonHandler(
(req) =>
completeAdminLogin(req.body, {
ip: resolveClientIp(req),
userAgent: resolveUserAgent(req),
location: resolveClientLocation(req),
}),
{
successMessage: '登录成功',
errorMessage: '完成设备登录失败',
scope: '[admin/auth/login/complete]',
},
),
)
router.get(
'/auth/session',
createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), {
@@ -66,3 +127,47 @@ router.post(
)
export default router
function resolveApiBase(
req: { headers: Record<string, unknown>; protocol?: string },
value: unknown,
) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
.split(',')[0]
?.trim()
const forwardedHost = String(req.headers['x-forwarded-host'] || '')
.split(',')[0]
?.trim()
const origin = String(value || req.headers.origin || '').trim()
const raw =
origin ||
`${forwardedProto || req.protocol || 'http'}://${forwardedHost || String(req.headers.host || '')}`
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw createHttpError('登录来源地址无效', {
statusCode: 400,
errorCode: 'admin_api_base_invalid',
})
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw createHttpError('登录来源地址必须使用 http 或 https', {
statusCode: 400,
errorCode: 'admin_api_base_invalid',
})
}
if (
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash ||
parsed.username ||
parsed.password
) {
throw createHttpError('登录来源地址必须是不带路径的 origin', {
statusCode: 400,
errorCode: 'admin_api_base_invalid',
})
}
return `${parsed.protocol}//${parsed.host}`
}
+72
View File
@@ -4,7 +4,10 @@ import {
createManagedAdminUser,
getAdminUserList,
resetManagedAdminUserPassword,
resetManagedAdminUserDevice,
updateManagedAdminUserDisplayName,
updateManagedAdminUserPhone,
updateManagedAdminUserTestAccount,
updateManagedAdminUserRole,
updateManagedAdminUserStatus,
} from '../../services/admin/admin-auth-service.js'
@@ -160,4 +163,73 @@ router.post(
),
)
router.post(
'/users/:userId/phone',
createJsonHandler(
(req) =>
updateManagedAdminUserPhone(
String(req.params.userId || ''),
req.body,
getRequiredAdminSession(req),
),
{
successMessage: '手机号已绑定',
errorMessage: '绑定手机号失败',
scope: '[admin/users/:userId/phone]',
audit: (_req, data) => {
const result = data as AdminUserMutationResult
return {
action: 'admin_user_phone_updated',
targetType: 'admin_user',
targetId: String(result.user.userId),
data: { username: result.user.username },
}
},
},
),
)
router.post(
'/users/:userId/device/reset',
createJsonHandler(
(req) =>
resetManagedAdminUserDevice(String(req.params.userId || ''), getRequiredAdminSession(req)),
{
successMessage: '设备绑定已重置',
errorMessage: '重置设备绑定失败',
scope: '[admin/users/:userId/device/reset]',
audit: (_req, data) => {
const result = data as AdminUserMutationResult
return {
action: 'admin_user_device_reset',
targetType: 'admin_user',
targetId: String(result.user.userId),
data: { username: result.user.username },
}
},
},
),
)
router.post(
'/users/:userId/test-account',
createJsonHandler(
(req) => updateManagedAdminUserTestAccount(String(req.params.userId || ''), req.body),
{
successMessage: '测试账号设置已更新',
errorMessage: '更新测试账号设置失败',
scope: '[admin/users/:userId/test-account]',
audit: (_req, data) => {
const result = data as AdminUserMutationResult
return {
action: 'admin_user_test_account_updated',
targetType: 'admin_user',
targetId: String(result.user.userId),
data: { username: result.user.username },
}
},
},
),
)
export default router
+38
View File
@@ -0,0 +1,38 @@
import { Router } from 'express'
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js'
import {
authorizeAdminDevice,
getAdminDeviceChallengeStatus,
} from '../services/admin/admin-device-auth-service.js'
import { buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
const authorizeRateLimit = createRateLimitMiddleware({
scope: 'admin:device:authorize',
windowMs: 60_000,
max: 30,
key: getBodyFieldRateLimitKey('challengeId'),
})
router.post('/authorize', authorizeRateLimit, async (req, res) => {
try {
await authorizeAdminDevice((req.body || {}) as Record<string, unknown>)
res.json(buildSuccessPayload({ authorized: true }, '设备已确认'))
} catch (error) {
sendRouteError(res, error, '设备确认失败', '[dcauth/authorize]')
}
})
router.get('/challenge/:challengeId/status', async (req, res) => {
try {
const pollToken = String(req.headers['x-poll-token'] || req.query.pollToken || '').trim()
const data = await getAdminDeviceChallengeStatus(req.params.challengeId, pollToken)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取设备认证状态失败', '[dcauth/status]')
}
})
export default router
@@ -6,10 +6,12 @@ import {
countActiveAdminUsers,
createAdminUser,
getAdminUserById,
getAdminUserByPhone,
getAdminUserByUsername,
listAdminUsers,
updateAdminUser,
} from '../../repositories/admin-user-repo.js'
import { resetAdminUserDevice as resetAdminUserDeviceRecord } from '../../repositories/admin-device-repo.js'
import {
createAdminSession as createAdminSessionRecord,
getActiveAdminSession,
@@ -21,6 +23,12 @@ import { createHttpError } from '../../utils/http.js'
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
import { recordAdminLoginLog } from './admin-login-log-service.js'
import { listAdminPermissionKeysForRole } from './admin-rbac-service.js'
import {
consumeAdminDeviceLogin,
createAdminLoginDeviceChallenge,
ensureAdminDeviceBinding,
} from './admin-device-auth-service.js'
import { maskPhone, normalizeAdminPhone, verifyAdminLoginSmsCode } from './admin-sms-service.js'
type AdminUserRow = NonNullable<Awaited<ReturnType<typeof getAdminUserById>>>
@@ -32,6 +40,7 @@ export type AdminSession = {
role: AdminRole
expiresAt: string
sessionVersion: number
deviceId: string
permissions: string[]
}
@@ -123,10 +132,18 @@ export async function loginAdmin(
})
}
if (String(user.phone || '').trim() && user.test_account !== true) {
throw createHttpError('该账号已启用安全登录,请使用账号密码 + 短信验证码 + 本机验证', {
statusCode: 403,
errorCode: 'admin_device_auth_required',
})
}
const session = createAdminSession(user)
const createdSession = await createAdminSessionRecord({
sessionId: String(session.sessionId || ''),
userId: Number(user.id),
deviceId: '',
issuedAt: String(session.issuedAt),
expiresAt: String(session.expiresAt),
now: nowIso(),
@@ -142,11 +159,157 @@ export async function loginAdmin(
username: String(user.username || normalizedUsername),
role: normalizeAdminRole(user.role),
success: true,
loginMethod: user.test_account === true ? 'password_test' : 'password',
...pickLoginMeta(meta),
})
return session
}
export async function prepareAdminLogin(
usernameValue: unknown,
passwordValue: unknown,
smsCodeValue: unknown,
meta: {
ip?: string
userAgent?: string
location?: string
apiBase: string
},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const normalizedUsername = normalizeUsername(usernameValue)
const normalizedPassword = String(passwordValue || '').trim()
if (!normalizedUsername || !normalizedPassword) {
throw createHttpError('缺少后台账号或密码', {
statusCode: 400,
errorCode: 'admin_credentials_required',
})
}
const user = await getAdminUserByUsername(normalizedUsername)
if (
!user ||
user.status !== 'active' ||
!verifyAdminPassword(normalizedPassword, user.password_hash)
) {
await recordAdminLoginLog({
userId: user ? Number(user.id) : null,
username: normalizedUsername,
role: user ? normalizeAdminRole(user.role) : '',
success: false,
failureReason: 'invalid_credentials',
...pickLoginMeta(meta),
})
throw createHttpError('账号或密码错误', {
statusCode: 401,
errorCode: 'admin_login_failed',
})
}
if (!String(user.phone || '').trim()) {
throw createHttpError('该账号尚未绑定手机号,请联系管理员', {
statusCode: 409,
errorCode: 'admin_phone_unbound',
})
}
await verifyAdminLoginSmsCode(Number(user.id), smsCodeValue)
const challenge = await createAdminLoginDeviceChallenge(Number(user.id), meta.apiBase)
return {
...challenge,
phoneMasked: maskPhone(user.phone),
app: 'order_site',
}
}
export async function completeAdminLogin(
payload: JsonObject = {},
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const challengeId = String(payload.challengeId || '').trim()
const pollToken = String(payload.pollToken || '').trim()
const deviceTicket = String(payload.deviceTicket || '').trim()
if (!challengeId || !pollToken || !deviceTicket) {
throw createHttpError('缺少设备认证票据', {
statusCode: 400,
errorCode: 'admin_device_ticket_required',
})
}
const challenge = await getAdminDeviceChallengeForLogin(challengeId)
const user = await getAdminUserById(Number(challenge.user_id))
if (!user || user.status !== 'active') {
throw createHttpError('后台账号已不可用,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_user_invalid',
})
}
const consumed = await consumeAdminDeviceLogin(Number(user.id), {
challengeId,
pollToken,
deviceTicket,
})
await ensureAdminDeviceBinding(Number(user.id), consumed)
return createAdminLoginSession(user, consumed.device_id, meta, {
machineCodeMask: consumed.machine_code_mask,
})
}
async function createAdminLoginSession(
user: AdminUserRow,
deviceId: string,
meta: {
ip?: string
userAgent?: string
location?: string
},
extra: { machineCodeMask?: string } = {},
): Promise<JsonObject> {
const session = createAdminSession(user, deviceId)
const createdSession = await createAdminSessionRecord({
sessionId: String(session.sessionId || ''),
userId: Number(user.id),
deviceId,
issuedAt: String(session.issuedAt),
expiresAt: String(session.expiresAt),
now: nowIso(),
})
if (!createdSession) {
throw createHttpError('后台登录会话创建失败,请重试', {
statusCode: 503,
errorCode: 'admin_session_create_failed',
})
}
await recordAdminLoginLog({
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
success: true,
loginMethod: 'sms_device',
machineCodeMask: String(extra.machineCodeMask || ''),
...pickLoginMeta(meta),
})
return session
}
async function getAdminDeviceChallengeForLogin(challengeId: string) {
const { getAdminDeviceChallenge } = await import('../../repositories/admin-device-repo.js')
const challenge = await getAdminDeviceChallenge(challengeId)
if (!challenge || !challenge.user_id) {
throw createHttpError('设备认证会话不存在,请重新登录', {
statusCode: 404,
errorCode: 'admin_device_challenge_not_found',
})
}
return challenge
}
export async function verifyAdminSessionToken(token: unknown): Promise<AdminSession> {
ensureAdminAuthConfigured()
@@ -236,6 +399,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
role: normalizeAdminRole(user.role),
expiresAt,
sessionVersion: currentSessionVersion,
deviceId: String(activeSession.device_id || ''),
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
}
}
@@ -257,6 +421,7 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
username: session.username,
displayName: session.displayName,
role: session.role,
deviceId: session.deviceId,
permissions: session.permissions,
},
}
@@ -297,6 +462,7 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
const username = normalizeUsername(payload.username)
const displayName = normalizeDisplayName(payload.displayName, username) || username
const password = normalizePassword(payload.password)
const phone = normalizeAdminPhone(payload.phone)
if (!username) {
throw createHttpError('缺少后台账号', {
@@ -321,6 +487,12 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
errorCode: 'admin_user_exists',
})
}
if (phone && (await getAdminUserByPhone(phone))) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const created = await createAdminUser({
@@ -329,6 +501,10 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
passwordHash: hashAdminPassword(password),
role: normalizeAdminRole(payload.role),
status: normalizeAdminUserStatus(payload.status || 'active'),
phone,
phoneBoundAt: phone ? now : null,
phoneBoundBy: null,
testAccount: payload.testAccount === true,
createdAt: now,
updatedAt: now,
})
@@ -430,6 +606,92 @@ export async function updateManagedAdminUserDisplayName(
}
}
export async function updateManagedAdminUserPhone(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const phone = normalizeAdminPhone(payload.phone)
if (!phone) {
throw createHttpError('请输入手机号', {
statusCode: 400,
errorCode: 'admin_phone_required',
})
}
if (phone === String(user.phone || '').trim()) {
return { user: mapAdminUser(user) }
}
const existing = await getAdminUserByPhone(phone)
if (existing && Number(existing.id) !== Number(user.id)) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const updated = await updateAdminUser(user.id, {
phone,
phone_bound_at: now,
phone_bound_by: session.userId,
session_version: nextAdminSessionVersion(user),
updated_at: now,
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return { user: mapAdminUser(updated) }
}
export async function updateManagedAdminUserTestAccount(
userId: number | string,
payload: JsonObject = {},
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const testAccount = payload.testAccount === true
const updated = await updateAdminUser(user.id, {
test_account: testAccount,
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
testAccount,
}
}
export async function resetManagedAdminUserDevice(
userId: number | string,
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const reset = await resetAdminUserDeviceRecord(user.id, session.userId, nowIso())
const updated = await updateAdminUser(user.id, {
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
reset,
}
}
export async function resetManagedAdminUserPassword(
userId: number | string,
payload: JsonObject = {},
@@ -478,7 +740,7 @@ export function ensureAdminAuthConfigured(): void {
})
}
function createAdminSession(user: AdminUserRow): JsonObject {
function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
const issuedAt = nowIso()
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12))
const payload = {
@@ -486,6 +748,7 @@ function createAdminSession(user: AdminUserRow): JsonObject {
uid: Number(user.id),
usr: String(user.username || ''),
role: normalizeAdminRole(user.role),
dev: String(deviceId || ''),
ver: normalizeAdminSessionVersion(user.session_version),
iat: issuedAt,
exp: expiresAt,
@@ -513,7 +776,7 @@ export function hashAdminPassword(password: string): string {
return `scrypt$${salt}$${derived}`
}
function verifyAdminPassword(password: string, storedHash: string): boolean {
export function verifyAdminPassword(password: string, storedHash: string): boolean {
const [algorithm, salt, expectedHash] = String(storedHash || '').split('$')
if (algorithm !== 'scrypt' || !salt || !expectedHash) {
return false
@@ -586,6 +849,7 @@ function normalizeStatusQuery(status: unknown): string {
function normalizeUsername(username: unknown): string {
return String(username || '')
.trim()
.normalize('NFKC')
.toLowerCase()
}
@@ -615,8 +879,8 @@ function normalizePassword(password: unknown): string {
}
function validateUsername(username: string): void {
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
throw createHttpError('后台账号格式无效,需为 3-32 位字母数字或 ._-', {
if (!/^[\p{L}\p{N}._-]{2,32}$/u.test(username)) {
throw createHttpError('后台账号格式无效,需为 2-32 位中文、字母、数字或 ._-', {
statusCode: 400,
errorCode: 'admin_user_username_invalid',
})
@@ -688,11 +952,29 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
status: normalizeAdminUserStatus(user.status),
phone: String(user.phone || ''),
phoneMasked: maskPhone(user.phone),
phoneBoundAt: user.phone_bound_at || null,
testAccount: user.test_account === true,
deviceBound: Boolean(user.device_id),
deviceId: String(user.device_id || ''),
deviceIdMasked: maskDeviceId(user.device_id),
machineCodeMask: String(user.machine_code_mask || ''),
deviceBoundAt: user.device_bound_at || null,
deviceLastUsedAt: user.device_last_used_at || null,
createdAt: user.created_at,
updatedAt: user.updated_at,
}
}
function maskDeviceId(value: unknown): string {
const deviceId = String(value || '')
.trim()
.toUpperCase()
if (deviceId.length <= 8) return deviceId
return `${deviceId.slice(0, 6)}-****-${deviceId.slice(-4)}`
}
function pickLoginMeta(
meta: {
ip?: string
@@ -0,0 +1,357 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import {
authorizeAdminDeviceChallenge,
bindAdminUserDevice,
consumeAdminDeviceTicket,
createAdminDeviceChallenge,
getActiveAdminUserDevice,
getAdminDeviceChallenge,
issueAdminDeviceTicket,
touchAdminUserDevice,
type AdminDeviceChallengeRow,
type AdminUserDeviceRow,
} from '../../repositories/admin-device-repo.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
export const ADMIN_DEVICE_APP = 'order_site'
const CHALLENGE_TTL_SECONDS = 120
const MAX_CLOCK_SKEW_SECONDS = 300
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
export type AdminDeviceChallengeResult = {
challengeId: string
challenge: string
expiresAt: string
pollToken: string
launchUrl: string
}
export async function createAdminLoginDeviceChallenge(
userId: number,
apiBase: string,
): Promise<AdminDeviceChallengeResult> {
const challengeId = crypto.randomUUID()
const challenge = crypto.randomBytes(32).toString('base64url')
const pollToken = crypto.randomBytes(32).toString('base64url')
const now = nowIso()
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_SECONDS * 1000).toISOString()
const created = await createAdminDeviceChallenge({
challengeId,
app: ADMIN_DEVICE_APP,
purpose: 'login',
challenge,
pollTokenHash: hashToken(pollToken),
userId,
expiresAt,
now,
})
if (!created) {
throw createHttpError('创建设备认证会话失败,请重试', {
statusCode: 500,
errorCode: 'admin_device_challenge_create_failed',
})
}
return {
challengeId,
challenge,
expiresAt,
pollToken,
launchUrl: buildLaunchUrl(apiBase, challengeId, challenge),
}
}
export async function authorizeAdminDevice(payload: Record<string, unknown>): Promise<void> {
const app = String(payload.app || '').trim()
if (app !== ADMIN_DEVICE_APP) {
throw createHttpError('设备认证应用不匹配', {
statusCode: 400,
errorCode: 'admin_device_app_mismatch',
})
}
const challengeId = String(payload.challengeId || '').trim()
const challengeValue = String(payload.challenge || '').trim()
const deviceId = String(payload.deviceId || '')
.trim()
.toUpperCase()
const machineCode = String(payload.machineCode || '')
.trim()
.toUpperCase()
const publicKey = String(payload.publicKey || '').trim()
const signature = String(payload.signature || '').trim()
const timestamp = Number(payload.timestamp || 0)
const clientVersion = String(payload.clientVersion || '')
.trim()
.slice(0, 32)
if (!challengeId || !challengeValue || !deviceId || !machineCode || !publicKey || !signature) {
throw createHttpError('设备认证参数不完整', {
statusCode: 400,
errorCode: 'admin_device_payload_invalid',
})
}
if (!/^D1-[A-Z0-9-]{8,64}$/.test(deviceId)) {
throw createHttpError('设备码格式不正确', {
statusCode: 400,
errorCode: 'admin_device_id_invalid',
})
}
if (!/^M1-[A-Z0-9-]{8,64}$/.test(machineCode)) {
throw createHttpError('机器码格式不正确', {
statusCode: 400,
errorCode: 'admin_machine_code_invalid',
})
}
if (
!Number.isInteger(timestamp) ||
Math.abs(Date.now() / 1000 - timestamp) > MAX_CLOCK_SKEW_SECONDS
) {
throw createHttpError('设备时间与服务器偏差过大,请校准系统时间', {
statusCode: 400,
errorCode: 'admin_device_clock_invalid',
})
}
const challenge = await getAdminDeviceChallenge(challengeId)
if (!challenge || challenge.app !== ADMIN_DEVICE_APP || challenge.purpose !== 'login') {
throw createHttpError('设备认证会话不存在', {
statusCode: 404,
errorCode: 'admin_device_challenge_not_found',
})
}
if (!safeEqual(challenge.challenge, challengeValue)) {
throw createHttpError('设备认证 challenge 不匹配', {
statusCode: 400,
errorCode: 'admin_device_challenge_mismatch',
})
}
if (challenge.status !== 'pending' || new Date(challenge.expires_at).getTime() <= Date.now()) {
throw createHttpError('设备认证会话已失效,请重新登录', {
statusCode: 409,
errorCode: 'admin_device_challenge_expired',
})
}
const publicKeyBytes = decodeBase64Url(publicKey)
const signatureBytes = decodeBase64Url(signature)
if (publicKeyBytes.length !== 32 || signatureBytes.length !== 64) {
throw createHttpError('设备公钥或签名格式不正确', {
statusCode: 400,
errorCode: 'admin_device_signature_invalid',
})
}
const canonical = [
'dcauth.v1',
app,
challengeId,
challengeValue,
deviceId,
machineCode,
String(timestamp),
].join('\n')
const keyObject = crypto.createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]),
format: 'der',
type: 'spki',
})
const verified = crypto.verify(null, Buffer.from(canonical, 'utf8'), keyObject, signatureBytes)
if (!verified) {
throw createHttpError('设备签名校验失败', {
statusCode: 401,
errorCode: 'admin_device_signature_invalid',
})
}
const authorized = await authorizeAdminDeviceChallenge({
challengeId,
app,
deviceId,
publicKey,
machineCodeHash: hashMachineCode(machineCode),
machineCodeMask: maskMachineCode(machineCode),
clientVersion,
now: nowIso(),
})
if (!authorized) {
throw createHttpError('设备认证会话已被使用,请重新登录', {
statusCode: 409,
errorCode: 'admin_device_challenge_used',
})
}
}
export async function getAdminDeviceChallengeStatus(
challengeIdValue: unknown,
pollTokenValue: unknown,
): Promise<Record<string, unknown>> {
const challengeId = String(challengeIdValue || '').trim()
const pollToken = String(pollTokenValue || '').trim()
if (!challengeId || !pollToken) {
throw createHttpError('缺少设备认证参数', {
statusCode: 400,
errorCode: 'admin_device_poll_invalid',
})
}
const challenge = await getAdminDeviceChallenge(challengeId)
if (!challenge) {
return { status: 'rejected', message: '设备认证会话不存在' }
}
if (!safeEqual(challenge.poll_token_hash, hashToken(pollToken))) {
throw createHttpError('设备认证轮询密钥无效', {
statusCode: 403,
errorCode: 'admin_device_poll_forbidden',
})
}
if (new Date(challenge.expires_at).getTime() <= Date.now() && challenge.status !== 'consumed') {
return { status: 'expired', message: '设备认证会话已过期' }
}
if (challenge.status === 'authorized') {
const ticket = crypto.randomBytes(32).toString('base64url')
const issued = await issueAdminDeviceTicket({
challengeId,
ticketHash: hashToken(ticket),
now: nowIso(),
})
if (issued) {
return {
status: 'authorized',
deviceTicket: ticket,
deviceId: challenge.device_id,
machineCodeMask: challenge.machine_code_mask,
}
}
const latest = await getAdminDeviceChallenge(challengeId)
return { status: latest?.status || 'pending' }
}
return {
status: challenge.status,
deviceId: challenge.device_id || '',
machineCodeMask: challenge.machine_code_mask || '',
}
}
export async function consumeAdminDeviceLogin(
userId: number,
payload: Record<string, unknown>,
): Promise<AdminDeviceChallengeRow> {
const challengeId = String(payload.challengeId || '').trim()
const pollToken = String(payload.pollToken || '').trim()
const deviceTicket = String(payload.deviceTicket || '').trim()
if (!challengeId || !pollToken || !deviceTicket) {
throw createHttpError('缺少设备认证票据', {
statusCode: 400,
errorCode: 'admin_device_ticket_required',
})
}
const challenge = await consumeAdminDeviceTicket({
challengeId,
pollTokenHash: hashToken(pollToken),
ticketHash: hashToken(deviceTicket),
now: nowIso(),
})
if (!challenge || Number(challenge.user_id) !== Number(userId)) {
throw createHttpError('设备认证票据无效或已使用,请重新登录', {
statusCode: 401,
errorCode: 'admin_device_ticket_invalid',
})
}
return challenge
}
export async function ensureAdminDeviceBinding(
userId: number,
challenge: AdminDeviceChallengeRow,
): Promise<AdminUserDeviceRow> {
const current = await getActiveAdminUserDevice(userId)
if (current) {
if (
!safeEqual(current.device_id, challenge.device_id) ||
!safeEqual(current.public_key, challenge.public_key)
) {
throw createHttpError('当前设备与账号绑定设备不一致,请联系管理员重置设备', {
statusCode: 401,
errorCode: 'admin_device_mismatch',
})
}
await touchAdminUserDevice(current.device_id, nowIso())
return current
}
const bound = await bindAdminUserDevice({
userId,
deviceId: challenge.device_id,
publicKey: challenge.public_key,
machineCodeHash: challenge.machine_code_hash,
machineCodeMask: challenge.machine_code_mask,
clientVersion: challenge.client_version,
now: nowIso(),
})
if (!bound) {
throw createHttpError('绑定本机设备失败,请重试', {
statusCode: 500,
errorCode: 'admin_device_bind_failed',
})
}
return bound
}
export function maskMachineCode(machineCode: string): string {
const normalized = String(machineCode || '')
.trim()
.toUpperCase()
if (normalized.length <= 10) return normalized
return `${normalized.slice(0, 7)}-****-****-${normalized.slice(-4)}`
}
export function hashMachineCode(machineCode: string): string {
return crypto
.createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || 'dcauth'))
.update(
String(machineCode || '')
.trim()
.toUpperCase(),
)
.digest('hex')
}
function hashToken(value: string): string {
return crypto
.createHash('sha256')
.update(String(value || ''))
.digest('hex')
}
function decodeBase64Url(value: string): Buffer {
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
throw createHttpError('设备签名编码无效', {
statusCode: 400,
errorCode: 'admin_device_signature_invalid',
})
}
return Buffer.from(value, 'base64url')
}
function safeEqual(input: unknown, expected: unknown): boolean {
const left = Buffer.from(String(input || ''), 'utf8')
const right = Buffer.from(String(expected || ''), 'utf8')
if (left.length !== right.length) return false
return crypto.timingSafeEqual(left, right)
}
function buildLaunchUrl(apiBase: string, challengeId: string, challenge: string): string {
const query = new URLSearchParams({
app: ADMIN_DEVICE_APP,
challenge_id: challengeId,
challenge,
api: apiBase,
})
return `dcauth://authorize?${query.toString()}`
}
@@ -15,6 +15,8 @@ type RecordAdminLoginInput = {
ip?: string
location?: string
userAgent?: string
loginMethod?: string
machineCodeMask?: string
}
export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
@@ -28,6 +30,8 @@ export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
userAgent: String(input.userAgent || '').trim(),
success: input.success !== false,
failureReason: String(input.failureReason || '').trim(),
loginMethod: String(input.loginMethod || 'password').trim() || 'password',
machineCodeMask: String(input.machineCodeMask || '').trim(),
createdAt: nowIso(),
})
} catch (error) {
@@ -74,6 +78,8 @@ export async function getAdminLoginLogs(query: JsonObject = {}) {
userAgent: String(item.user_agent || '').trim(),
success: item.success !== false,
failureReason: String(item.failure_reason || '').trim(),
loginMethod: String(item.login_method || 'password').trim(),
machineCodeMask: String(item.machine_code_mask || '').trim(),
createdAt: item.created_at,
})),
pagination: { page, pageSize, total },
@@ -0,0 +1,216 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import { getAdminUserByUsername } from '../../repositories/admin-user-repo.js'
import {
consumeAdminSmsCode,
countAdminSmsCodesOnDay,
createAdminSmsCode,
getLatestAdminSmsCode,
recordAdminSmsCodeFailure,
} from '../../repositories/admin-sms-code-repo.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { getSmsProvider } from '../sms/index.js'
import { verifyAdminPassword } from './admin-auth-service.js'
import { recordAdminLoginLog } from './admin-login-log-service.js'
const MAX_VERIFY_ATTEMPTS = 5
const SMS_PURPOSE = 'login'
export type AdminSmsMeta = {
ip?: string
userAgent?: string
location?: string
}
export async function sendAdminLoginSmsCode(
usernameValue: unknown,
passwordValue: unknown,
meta: AdminSmsMeta = {},
): Promise<Record<string, unknown>> {
const username = normalizeAdminUsername(usernameValue)
const password = String(passwordValue || '').trim()
if (!username || !password) {
throw createHttpError('缺少账号或密码', {
statusCode: 400,
errorCode: 'admin_credentials_required',
})
}
const user = await getAdminUserByUsername(username)
if (
!user ||
user.status !== 'active' ||
!verifyAdminPassword(password, String(user.password_hash || ''))
) {
await recordAdminLoginLog({
userId: user ? Number(user.id) : null,
username,
role: user ? String(user.role || '') : '',
success: false,
failureReason: 'invalid_credentials',
...pickMeta(meta),
})
throw createHttpError('账号或密码错误', {
statusCode: 401,
errorCode: 'admin_login_failed',
})
}
const phone = normalizeAdminPhone(user.phone)
if (!phone) {
throw createHttpError('该账号尚未绑定手机号,请联系管理员', {
statusCode: 409,
errorCode: 'admin_phone_unbound',
})
}
const config = runtimeConfig.worker.sms
const cooldownMs = Math.max(10, Number(config.cooldownSeconds || 60)) * 1000
const dailyLimit = Math.max(1, Number(config.dailyLimit || 10))
const ttlSeconds = Math.max(60, Number(config.codeTtlSeconds || 300))
const latest = await getLatestAdminSmsCode(Number(user.id), SMS_PURPOSE)
if (
latest &&
!latest.consumed_at &&
new Date(latest.created_at).getTime() > Date.now() - cooldownMs
) {
throw createHttpError('验证码发送过于频繁,请稍后再试', {
statusCode: 429,
errorCode: 'admin_sms_cooldown',
})
}
const dayRange = resolveChinaDayRange()
if (
(await countAdminSmsCodesOnDay(Number(user.id), dayRange.start, dayRange.end)) >= dailyLimit
) {
throw createHttpError('今日验证码发送次数已达上限,请明天再试', {
statusCode: 429,
errorCode: 'admin_sms_daily_limit',
})
}
const code = generateSmsCode()
await getSmsProvider().sendCode(phone, code)
await createAdminSmsCode({
userId: Number(user.id),
phone,
purpose: SMS_PURPOSE,
codeHash: hashSmsCode(code),
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
requestIp: String(meta.ip || '').trim(),
now: nowIso(),
})
return {
sent: true,
phoneMasked: maskPhone(phone),
expiresInSeconds: ttlSeconds,
provider: String(config.provider || 'mock'),
}
}
export async function verifyAdminLoginSmsCode(userId: number, codeValue: unknown): Promise<void> {
const code = String(codeValue || '').trim()
if (!code) {
throw createHttpError('请输入短信验证码', {
statusCode: 400,
errorCode: 'admin_sms_code_required',
})
}
const latest = await getLatestAdminSmsCode(userId, SMS_PURPOSE)
const valid = Boolean(
latest &&
!latest.consumed_at &&
new Date(latest.expires_at).getTime() > Date.now() &&
latest.code_hash === hashSmsCode(code),
)
if (!valid) {
if (latest && !latest.consumed_at) {
const failed = await recordAdminSmsCodeFailure(latest.id, nowIso(), MAX_VERIFY_ATTEMPTS)
if (failed?.consumed_at) {
throw createHttpError('验证码错误次数过多,请重新获取验证码', {
statusCode: 400,
errorCode: 'admin_sms_code_locked',
})
}
}
throw createHttpError('短信验证码错误或已过期', {
statusCode: 400,
errorCode: 'admin_sms_code_invalid',
})
}
if (!(await consumeAdminSmsCode(latest!.id, nowIso()))) {
throw createHttpError('验证码已失效,请重新获取', {
statusCode: 400,
errorCode: 'admin_sms_code_invalid',
})
}
}
export function normalizeAdminPhone(value: unknown): string {
const phone = String(value || '').trim()
if (!phone) return ''
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw createHttpError('手机号格式不正确', {
statusCode: 400,
errorCode: 'admin_phone_invalid',
})
}
return phone
}
export function maskPhone(phoneValue: unknown): string {
const phone = String(phoneValue || '').trim()
if (!/^1[3-9]\d{9}$/.test(phone)) return phone
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
}
export function hashSmsCode(code: string): string {
return crypto
.createHash('sha256')
.update(String(code || ''))
.digest('hex')
}
function generateSmsCode(): string {
let code = ''
for (let index = 0; index < 6; index += 1) {
code += String(crypto.randomInt(10))
}
return code
}
function normalizeAdminUsername(value: unknown): string {
return String(value || '')
.trim()
.normalize('NFKC')
.toLowerCase()
}
function resolveChinaDayRange(): { start: string; end: string } {
const now = new Date()
const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const year = chinaNow.getUTCFullYear()
const month = chinaNow.getUTCMonth()
const day = chinaNow.getUTCDate()
const start = new Date(Date.UTC(year, month, day) - 8 * 60 * 60 * 1000)
const end = new Date(Date.UTC(year, month, day + 1) - 8 * 60 * 60 * 1000)
return { start: start.toISOString(), end: end.toISOString() }
}
function pickMeta(meta: AdminSmsMeta) {
const result: AdminSmsMeta = {}
if (typeof meta.ip === 'string' && meta.ip.trim()) result.ip = meta.ip.trim()
if (typeof meta.userAgent === 'string' && meta.userAgent.trim()) {
result.userAgent = meta.userAgent.trim()
}
if (typeof meta.location === 'string' && meta.location.trim()) {
result.location = meta.location.trim()
}
return result
}