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
|
||||
}
|
||||
Reference in New Issue
Block a user