278 lines
8.0 KiB
TypeScript
278 lines
8.0 KiB
TypeScript
import crypto from 'node:crypto';
|
||
|
||
import DypnsApiModule, {
|
||
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>;
|
||
};
|
||
|
||
type DypnsClientInstance = InstanceType<typeof DypnsApiModule>;
|
||
type DypnsClientConstructor = new (
|
||
config: OpenApiClient.Config,
|
||
) => DypnsClientInstance;
|
||
|
||
function resolveDypnsClientConstructor(): DypnsClientConstructor {
|
||
const directExport = DypnsApiModule as unknown;
|
||
if (typeof directExport === 'function') {
|
||
return directExport as DypnsClientConstructor;
|
||
}
|
||
|
||
// 兼容 CommonJS SDK 在 ESM/tsx 运行时被包一层 default 的情况。
|
||
const nestedDefault = (
|
||
DypnsApiModule as unknown as { default?: unknown }
|
||
).default;
|
||
if (typeof nestedDefault === 'function') {
|
||
return nestedDefault as DypnsClientConstructor;
|
||
}
|
||
|
||
throw new Error('阿里云短信 SDK Client 导出异常');
|
||
}
|
||
|
||
const DypnsClient = resolveDypnsClientConstructor();
|
||
|
||
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
|
||
return !config.accessKeyId || !config.accessKeySecret;
|
||
}
|
||
|
||
function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) {
|
||
if (!config.signName.trim()) {
|
||
throw new Error('ALIYUN_SMS_SIGN_NAME 未配置');
|
||
}
|
||
if (!config.templateCode.trim()) {
|
||
throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置');
|
||
}
|
||
if (!config.templateParamKey.trim()) {
|
||
throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置');
|
||
}
|
||
}
|
||
|
||
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 未配置');
|
||
}
|
||
assertAliyunRequiredConfig(config);
|
||
|
||
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##',
|
||
"min": this.config.validTimeSeconds,
|
||
});
|
||
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);
|
||
}
|