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> { 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 { 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 }