- DCAuth 协议升级到 v2,签名原文只含设备码;客户端 2.0.0,旧版本返回 426 - 删除客户端机器码采集、后端 machine_code_* 字段与日志、后台机器码展示 - 102 迁移在生产执行前直接改为无机器码结构;SQL 守卫仅放行该文件 - Mock 短信不启用每日限额;修复慢电脑首次启动的协议转发竞态 - 新增 macOS DCAuth.app 打包脚本,注册 dcauth:// 协议,浏览器可直接唤起
229 lines
6.7 KiB
TypeScript
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
|
|
}
|