Files
order_site/apps/backend/src/services/admin/admin-sms-service.ts
T
yml2213 ef36453694 feat(dcauth): 移除机器码,改用纯设备码认证
- DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426
- 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示
- 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件
- Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态
- 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起
2026-10-21 20:46:25 +08:00

229 lines
6.7 KiB
TypeScript

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 provider = String(config.provider || 'mock')
.trim()
.toLowerCase()
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 (
shouldEnforceAdminSmsDailyLimit(provider) &&
(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
}
export function shouldEnforceAdminSmsDailyLimit(providerValue: unknown): boolean {
return (
String(providerValue || 'mock')
.trim()
.toLowerCase() !== 'mock'
)
}
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
}