This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,239 @@
import crypto from 'node:crypto';
import DypnsClient, {
CheckSmsVerifyCodeRequest,
SendSmsVerifyCodeRequest,
} from '@alicloud/dypnsapi20170525';
import OpenApiClient from '@alicloud/openapi-client';
import type { Logger } from 'pino';
import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js';
import type { AppConfig } from '../config.js';
import {
badRequest,
unauthorized,
upstreamError,
} from '../errors.js';
export type SendLoginCodeResult = {
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
export type SmsVerificationService = {
sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise<SendLoginCodeResult>;
verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
): Promise<void>;
};
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
return !config.accessKeyId || !config.accessKeySecret;
}
function buildProviderErrorMessage(prefix: string, message: string) {
const normalizedMessage = message.trim();
return normalizedMessage ? `${prefix}${normalizedMessage}` : prefix;
}
class AliyunSmsVerificationService implements SmsVerificationService {
private readonly client: DypnsClient;
constructor(
private readonly config: AppConfig['smsAuth'],
private readonly logger: Logger,
) {
if (isAliyunConfigMissing(config)) {
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
}
const clientConfig = new OpenApiClient.Config({
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
endpoint: config.endpoint,
protocol: 'HTTPS',
});
this.client = new DypnsClient(clientConfig);
}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
const templateParam = JSON.stringify({
[this.config.templateParamKey]: '##code##',
});
const request = new SendSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
signName: this.config.signName,
templateCode: this.config.templateCode,
templateParam,
codeLength: this.config.codeLength,
codeType: this.config.codeType,
validTime: this.config.validTimeSeconds,
interval: this.config.intervalSeconds,
duplicatePolicy: this.config.duplicatePolicy,
returnVerifyCode: this.config.returnVerifyCode,
schemeName: this.config.schemeName || undefined,
outId: `login_${crypto.randomBytes(12).toString('hex')}`,
});
try {
const response = await this.client.sendSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'短信验证码发送失败',
body?.message ?? '',
body?.code ?? '',
);
}
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: body.requestId ?? body.model?.requestId ?? null,
} satisfies SendLoginCodeResult;
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms send failed',
);
throw upstreamError(
buildProviderErrorMessage(
'短信验证码发送失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const request = new CheckSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
verifyCode,
caseAuthPolicy: this.config.caseAuthPolicy,
schemeName: this.config.schemeName || undefined,
});
try {
const response = await this.client.checkSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'验证码校验失败',
body?.message ?? '',
body?.code ?? '',
);
}
if (body.model?.verifyResult !== 'PASS') {
throw unauthorized('验证码错误或已失效');
}
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms verify failed',
);
throw upstreamError(
buildProviderErrorMessage(
'验证码校验失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
private resolveAliyunRequestError(
fallbackMessage: string,
providerMessage: string,
providerCode: string,
) {
const normalizedCode = providerCode.trim().toUpperCase();
if (
normalizedCode.includes('MOBILE') ||
normalizedCode.includes('PHONE') ||
normalizedCode.includes('TEMPLATE') ||
normalizedCode.includes('SIGN')
) {
return badRequest(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
return upstreamError(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
}
class MockSmsVerificationService implements SmsVerificationService {
private readonly sentCodes = new Map<string, string>();
constructor(private readonly config: AppConfig['smsAuth']) {}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode);
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: 'mock-request-id',
} satisfies SendLoginCodeResult;
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const expectedCode = this.sentCodes.get(phoneNumber.e164);
if (!expectedCode || expectedCode !== verifyCode) {
throw unauthorized('验证码错误或已失效');
}
}
}
export function createSmsVerificationService(
config: AppConfig,
logger: Logger,
): SmsVerificationService {
if (!config.smsAuth.enabled) {
return {
async sendLoginCode() {
throw badRequest('短信验证码登录未启用');
},
async verifyLoginCode() {
throw badRequest('短信验证码登录未启用');
},
};
}
if (config.smsAuth.provider === 'mock') {
return new MockSmsVerificationService(config.smsAuth);
}
return new AliyunSmsVerificationService(config.smsAuth, logger);
}