1
This commit is contained in:
239
server-node/src/services/smsVerificationService.ts
Normal file
239
server-node/src/services/smsVerificationService.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user