0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(aliyun sms connector): add aliyun sms connector (#203)

* chore(SMS connector): add SMS connector and UT

* feat(Aliyun SMS): remove redundancy

* chore(Aliyun SMS connector): merge duplicates and fix accordingly
This commit is contained in:
Darcy Ye 2022-01-29 16:48:34 +08:00 committed by GitHub
parent ffeabbedcf
commit 30ce91810f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 229 additions and 49 deletions

View file

@ -4,9 +4,9 @@ import {
ConnectorConfigError,
ConnectorError,
ConnectorMetadata,
ConnectorType,
EmailSendMessageFunction,
ValidateConfig,
ConnectorType,
} from '../types';
import { getConnectorConfig } from '../utilities';
import { singleSendMail } from './single-send-mail';
@ -26,6 +26,24 @@ export const metadata: ConnectorMetadata = {
},
};
/**
* UsageType here is used to specify the use case of the template, can be either
* 'Register', 'SignIn', 'ForgotPassword' or 'Test'.
*/
const templateGuard = z.object({
usageType: z.string(),
subject: z.string(),
content: z.string(), // With variable {{code}}, support HTML
});
const configGuard = z.object({
accessKeyId: z.string(),
accessKeySecret: z.string(),
accountName: z.string(),
fromAlias: z.string().optional(),
templates: z.array(templateGuard),
});
export const validateConfig: ValidateConfig = async (config: unknown) => {
if (!config) {
throw new ConnectorConfigError('Missing config');
@ -38,45 +56,32 @@ export const validateConfig: ValidateConfig = async (config: unknown) => {
}
};
const configGuard = z.object({
accessKeyId: z.string(),
accessKeySecret: z.string(),
accountName: z.string(),
fromAlias: z.string().optional(),
templates: z.array(
z.object({
type: z.string(),
subject: z.string(),
content: z.string(), // With variable {{code}}, support HTML
})
),
});
export type AliyunDmConfig = z.infer<typeof configGuard>;
export const sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await getConnectorConfig<AliyunDmConfig>(metadata.id);
await validateConfig(config);
const template = config.templates.find((template) => template.type === type);
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
const template = templates.find((template) => template.usageType === type);
if (!template) {
throw new ConnectorError(`Can not find template for type: ${type}`);
throw new ConnectorError(`Cannot find template for type: ${type}`);
}
return singleSendMail(
{
AccessKeyId: config.accessKeyId,
AccountName: config.accountName,
AccessKeyId: accessKeyId,
AccountName: accountName,
ReplyToAddress: 'false',
AddressType: '1',
ToAddress: address,
FromAlias: config.fromAlias,
FromAlias: fromAlias,
Subject: template.subject,
HtmlBody:
typeof data.code === 'string'
? template.content.replaceAll('{{code}}', data.code)
: template.content,
},
config.accessKeySecret
accessKeySecret
);
};

View file

@ -9,12 +9,12 @@ describe('singleSendMail', () => {
{
AccessKeyId: '<access-key-id>',
AccountName: 'noreply@example.com',
ReplyToAddress: 'false',
AddressType: '1',
ToAddress: 'user@example.com',
FromAlias: 'CompanyName',
Subject: 'test',
HtmlBody: 'test from logto',
ReplyToAddress: 'false',
Subject: 'test',
ToAddress: 'user@example.com',
},
'<access-key-secret>'
);

View file

@ -7,18 +7,25 @@ import { PublicParameters, request } from '../utilities/aliyun';
interface SingleSendMail {
AccountName: string;
AddressType: '0' | '1';
ReplyToAddress: 'true' | 'false';
Subject: string;
ToAddress: string;
ClickTrace?: '0' | '1';
FromAlias?: string;
HtmlBody?: string;
ReplyToAddress: 'true' | 'false';
Subject: string;
TagName?: string;
TextBody?: string;
ToAddress: string;
}
const Endpoint = 'https://dm.aliyuncs.com/';
const staticConfigs = {
Format: 'json',
SignatureMethod: 'HMAC-SHA1',
SignatureVersion: '1.0',
Version: '2015-11-23',
};
/**
* @doc https://help.aliyun.com/document_detail/29444.html
*
@ -27,9 +34,9 @@ export const singleSendMail = async (
parameters: PublicParameters & SingleSendMail,
accessKeySecret: string
) => {
return request<{ RequestId: string; EnvId: string }>(
return request<{ EnvId: string; RequestId: string }>(
Endpoint,
{ Action: 'SingleSendMail', ...parameters },
{ Action: 'SingleSendMail', ...staticConfigs, ...parameters },
accessKeySecret
);
};

View file

@ -0,0 +1,107 @@
import { z } from 'zod';
import {
ConnectorConfigError,
ConnectorError,
ConnectorMetadata,
ConnectorType,
SmsSendMessageFunction,
ValidateConfig,
} from '../types';
import { getConnectorConfig } from '../utilities';
import { sendSms } from './single-send-text';
export const metadata: ConnectorMetadata = {
id: 'aliyun-sms',
type: ConnectorType.SMS,
name: {
en: 'Aliyun Short Message Service',
zh_CN: '阿里云短信服务',
},
logo: './logo.png',
description: {
en: 'Short Message Service (SMS) has a batch sending feature and various API operations to send one-time password (OTP) messages, notification messages, and promotional messages to customers.',
zh_CN:
'短信服务Short Message Service是指通过调用短信发送API将指定短信内容发送给指定手机用户。',
},
};
/**
* Details of SmsTemplateType can be found at:
* https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/QuerySmsTemplateList.
*
* For our use case is to send passcode sms for passwordless sign-in/up as well as
* reset password, the default value of type code is set to be 2.
*
*/
enum SmsTemplateType {
Notification = 0,
Promotion = 1,
Passcode = 2,
InternationalMessage = 6,
PureNumber = 7,
}
/**
* UsageType here is used to specify the use case of the template, can be either
* 'Register', 'SignIn', 'ForgotPassword' or 'Test'.
*
* Type here in the template is used to specify the purpose of sending the sms,
* can be either item in SmsTemplateType.
* As the SMS is applied for sending passcode, the value should always be 2 in our case.
*
*/
const templateGuard = z.object({
type: z.nativeEnum(SmsTemplateType).default(2),
usageType: z.string(),
code: z.string().optional(),
name: z.string().min(1).max(30),
content: z.string().min(1).max(500),
remark: z.string(),
});
const configGuard = z.object({
accessKeyId: z.string(),
accessKeySecret: z.string(),
signName: z.string(),
templateCode: z.string(),
templates: z.array(templateGuard),
});
export const validateConfig: ValidateConfig = async (config: unknown) => {
if (!config) {
throw new ConnectorConfigError('Missing config');
}
const result = configGuard.safeParse(config);
if (!result.success) {
throw new ConnectorConfigError(result.error.message);
}
};
export type AliyunSmsConfig = z.infer<typeof configGuard>;
export const sendMessage: SmsSendMessageFunction = async (phone, type, { code }) => {
const config = await getConnectorConfig<AliyunSmsConfig>(metadata.id);
await validateConfig(config);
const { accessKeyId, accessKeySecret, signName, templateCode, templates } = config;
const template = templates.find(
({ code, usageType }) => code === templateCode && usageType === type
);
if (!template) {
throw new ConnectorError(`Cannot find template code: ${templateCode}`);
}
return sendSms(
{
AccessKeyId: accessKeyId,
PhoneNumbers: phone,
SignName: signName,
TemplateCode: templateCode,
TemplateParam: JSON.stringify({ code }),
},
accessKeySecret
);
};

View file

@ -0,0 +1,30 @@
import { customAlphabet } from 'nanoid';
import { request } from '../utilities/aliyun';
import { sendSms } from './single-send-text';
export const passcodeLength = 4;
const randomCode = customAlphabet('1234567890', passcodeLength);
jest.mock('../utilities/aliyun');
describe('sendSms', () => {
it('should call request with action sendSms', async () => {
const code = randomCode();
await sendSms(
{
AccessKeyId: '<access-key-id>',
PhoneNumbers: '13912345678',
SignName: '阿里云短信测试',
TemplateCode: ' SMS_154950909',
TemplateParam: JSON.stringify({ code }),
},
'<access-key-secret>'
);
const calledData = (request as jest.MockedFunction<typeof request>).mock.calls[0];
expect(calledData).not.toBeUndefined();
const payload = calledData?.[1];
expect(payload).toHaveProperty('Action', 'SendSms');
});
});

View file

@ -0,0 +1,36 @@
import { PublicParameters, request } from '../utilities/aliyun';
/**
* @doc https://help.aliyun.com/document_detail/101414.html
*
*/
interface SendSms {
OutId?: string;
PhoneNumbers: string; // 11 digits w/o prefix (can be multiple phone numbers with separator `,`)
SignName: string; // Name of SMS signature
SmsUpExtendCode?: string;
TemplateCode: string; // Text message template ID
TemplateParam?: string; // Stringified JSON (used to fill in text template)
}
const Endpoint = 'https://dysmsapi.aliyuncs.com/';
const staticConfigs = {
Format: 'json',
RegionId: 'cn-hangzhou',
SignatureMethod: 'HMAC-SHA1',
SignatureVersion: '1.0',
Version: '2017-05-25',
};
/**
* @doc https://help.aliyun.com/document_detail/101414.html
*
*/
export const sendSms = async (parameters: PublicParameters & SendSms, accessKeySecret: string) => {
return request<{ BizId: string; Code: string; Message: string; RequestId: string }>(
Endpoint,
{ Action: 'SendSms', ...staticConfigs, ...parameters },
accessKeySecret
);
};

View file

@ -2,10 +2,11 @@ import RequestError from '@/errors/RequestError';
import { findConnectorById, hasConnector, insertConnector } from '@/queries/connector';
import * as AliyunDM from './aliyun-dm';
import * as AliyunSMS from './aliyun-sms';
import * as GitHub from './github';
import { ConnectorInstance, ConnectorType } from './types';
const allConnectors: ConnectorInstance[] = [AliyunDM, GitHub];
const allConnectors: ConnectorInstance[] = [AliyunDM, AliyunSMS, GitHub];
export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
return Promise.all(

View file

@ -1,5 +1,5 @@
import { Languages } from '@logto/phrases';
import { ConnectorConfig, Connector } from '@logto/schemas';
import { ConnectorConfig, Connector, PasscodeType } from '@logto/schemas';
export enum ConnectorType {
SMS = 'SMS',
@ -37,7 +37,7 @@ export interface SocialConector extends BaseConnector {
getUserInfo: GetUserInfo;
}
export interface EmailMessageTypes {
type EmailMessageTypes = {
SignIn: {
code: string;
};
@ -48,7 +48,7 @@ export interface EmailMessageTypes {
code: string;
};
Test: Record<string, unknown>;
}
};
type SmsMessageTypes = EmailMessageTypes;
@ -59,11 +59,13 @@ export type EmailSendMessageFunction<T = unknown> = (
) => Promise<T>;
export type SmsSendMessageFunction<T = unknown> = (
address: string,
phone: string,
type: keyof SmsMessageTypes,
payload: SmsMessageTypes[typeof type]
) => Promise<T>;
export type TemplateType = PasscodeType | 'Test';
export class ConnectorError extends Error {}
export class ConnectorConfigError extends ConnectorError {}

View file

@ -43,30 +43,22 @@ export const getSignature = (
export interface PublicParameters {
AccessKeyId: string;
RegionId?: string;
Format?: string;
Version?: string;
SignatureMethod?: string;
Timestamp?: string;
Format?: string; // 'json' or 'xml', default: 'json'
RegionId?: string; // 'cn-hangzhou' | 'ap-southeast-1' | 'ap-southeast-2'
Signature?: string;
SignatureVersion?: string;
SignatureMethod?: string;
SignatureNonce?: string;
SignatureVersion?: string;
Timestamp?: string;
Version?: string;
}
const commonParameters = {
Version: '2015-11-23',
Format: 'json',
SignatureVersion: '1.0',
SignatureMethod: 'HMAC-SHA1',
};
export const request = async <T>(
url: string,
parameters: PublicParameters & Record<string, string>,
accessKeySecret: string
) => {
const finalParameters: Record<string, string> = {
...commonParameters,
...parameters,
SignatureNonce: String(Math.random()),
Timestamp: new Date().toISOString(),

View file

@ -44,7 +44,7 @@ const errors = {
insufficient_info: 'Insufficent sign-in info.',
},
connector: {
not_found: 'Can not find any available connector for type: {{type}}.',
not_found: 'Cannot find any available connector for type: {{type}}.',
},
passcode: {
phone_email_empty: 'Both phone and email are empty.',