@nocobase/plugin-verification 0.7.5-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +201 -0
  2. package/lib/Plugin.d.ts +21 -0
  3. package/lib/Plugin.js +216 -0
  4. package/lib/actions/index.d.ts +3 -0
  5. package/lib/actions/index.js +30 -0
  6. package/lib/actions/verifications.d.ts +2 -0
  7. package/lib/actions/verifications.js +195 -0
  8. package/lib/collections/verifications.d.ts +29 -0
  9. package/lib/collections/verifications.js +35 -0
  10. package/lib/collections/verifications_providers.d.ts +13 -0
  11. package/lib/collections/verifications_providers.js +24 -0
  12. package/lib/constants.d.ts +3 -0
  13. package/lib/constants.js +12 -0
  14. package/lib/index.d.ts +4 -0
  15. package/lib/index.js +46 -0
  16. package/lib/locale/index.d.ts +1 -0
  17. package/lib/locale/index.js +15 -0
  18. package/lib/locale/zh-CN.d.ts +7 -0
  19. package/lib/locale/zh-CN.js +13 -0
  20. package/lib/providers/index.d.ts +14 -0
  21. package/lib/providers/index.js +84 -0
  22. package/lib/providers/sms-aliyun.d.ts +7 -0
  23. package/lib/providers/sms-aliyun.js +111 -0
  24. package/package.json +25 -0
  25. package/src/Plugin.ts +141 -0
  26. package/src/__tests__/Plugin.test.ts +162 -0
  27. package/src/__tests__/collections/authors.ts +15 -0
  28. package/src/__tests__/index.ts +39 -0
  29. package/src/actions/index.ts +14 -0
  30. package/src/actions/verifications.ts +105 -0
  31. package/src/collections/verifications.ts +36 -0
  32. package/src/collections/verifications_providers.ts +22 -0
  33. package/src/constants.ts +4 -0
  34. package/src/index.ts +5 -0
  35. package/src/locale/index.ts +1 -0
  36. package/src/locale/zh-CN.ts +6 -0
  37. package/src/providers/index.ts +32 -0
  38. package/src/providers/sms-aliyun.ts +62 -0
@@ -0,0 +1,162 @@
1
+ import { MockServer } from '@nocobase/test';
2
+ import Database from '@nocobase/database';
3
+
4
+ import Plugin, { Provider } from '..';
5
+
6
+ import { getApp, sleep } from '.';
7
+
8
+
9
+
10
+ describe('verification > Plugin', () => {
11
+ let app: MockServer;
12
+ let agent;
13
+ let db: Database;
14
+ let plugin;
15
+ let AuthorModel;
16
+ let AuthorRepo;
17
+ let VerificationModel;
18
+ let provider;
19
+
20
+ beforeEach(async () => {
21
+ app = await getApp();
22
+ agent = app.agent();
23
+ db = app.db;
24
+ plugin = <Plugin>app.getPlugin('@nocobase/plugin-verification');
25
+ VerificationModel = db.getCollection('verifications').model;
26
+ AuthorModel = db.getCollection('authors').model;
27
+ AuthorRepo = db.getCollection('authors').repository;
28
+
29
+ plugin.providers.register('fake', Provider);
30
+
31
+ const VerificationProviderModel = db.getCollection('verifications_providers').model;
32
+ provider = await VerificationProviderModel.create({
33
+ id: 'fake1',
34
+ type: 'fake',
35
+ });
36
+ });
37
+
38
+ afterEach(() => app.destroy());
39
+
40
+ describe('auto intercept', () => {
41
+ beforeEach(async () => {
42
+ plugin.interceptors.register('authors:create', {
43
+ provider: 'fake1',
44
+ getReceiver(ctx) {
45
+ return ctx.action.params.values.phone;
46
+ },
47
+ expiresIn: 2
48
+ });
49
+ });
50
+
51
+ it('submit in time', async () => {
52
+ const res1 = await agent.resource('authors').create({
53
+ values: { phone: '1' }
54
+ });
55
+ expect(res1.status).toBe(400);
56
+
57
+ const res2 = await agent.resource('verifications').create({
58
+ values: {
59
+ type: 'authors:create',
60
+ phone: '1'
61
+ }
62
+ });
63
+ expect(res2.status).toBe(200);
64
+ expect(res2.body.data.id).toBeDefined();
65
+ expect(res2.body.data.content).toBeUndefined();
66
+ const expiresAt = Date.parse(res2.body.data.expiresAt);
67
+ expect(expiresAt - Date.now()).toBeLessThan(2000);
68
+
69
+ const res3 = await agent.resource('verifications').create({
70
+ values: {
71
+ type: 'authors:create',
72
+ phone: '1'
73
+ }
74
+ });
75
+ expect(res3.status).toBe(429);
76
+
77
+ const verification = await VerificationModel.findByPk(res2.body.data.id);
78
+ const res4 = await agent.resource('authors').create({
79
+ values: { phone: '1', code: verification.get('content') }
80
+ });
81
+ expect(res4.status).toBe(200);
82
+ });
83
+
84
+ it('expired', async () => {
85
+ const res1 = await agent.resource('verifications').create({
86
+ values: {
87
+ type: 'authors:create',
88
+ phone: '1'
89
+ }
90
+ });
91
+
92
+ await sleep(2000);
93
+
94
+ const verification = await VerificationModel.findByPk(res1.body.data.id);
95
+ const res2 = await agent.resource('authors').create({
96
+ values: { phone: '1', code: verification.get('content') }
97
+ });
98
+ expect(res2.status).toBe(400);
99
+ });
100
+ });
101
+
102
+ describe('manually intercept', () => {
103
+ beforeEach(async () => {
104
+ plugin.interceptors.register('authors:create', {
105
+ manual: true,
106
+ provider: 'fake1',
107
+ getReceiver(ctx) {
108
+ return ctx.action.params.values.phone;
109
+ },
110
+ expiresIn: 2
111
+ });
112
+ });
113
+
114
+ it('will not intercept', async () => {
115
+ const res1 = await agent.resource('authors').create({
116
+ values: { phone: '1' }
117
+ });
118
+ expect(res1.status).toBe(200);
119
+ });
120
+
121
+ it('will intercept', async () => {
122
+ app.resourcer.registerActionHandler('authors:create', plugin.intercept);
123
+
124
+ const res1 = await agent.resource('authors').create({
125
+ values: { phone: '1' }
126
+ });
127
+ expect(res1.status).toBe(400);
128
+ });
129
+ });
130
+
131
+ describe('validate', () => {
132
+ beforeEach(async () => {
133
+ plugin.interceptors.register('authors:create', {
134
+ provider: 'fake1',
135
+ getReceiver(ctx) {
136
+ return ctx.action.params.values.phone;
137
+ },
138
+ validate: Boolean
139
+ });
140
+ });
141
+
142
+ it('valid', async () => {
143
+ const res1 = await agent.resource('verifications').create({
144
+ values: {
145
+ type: 'authors:create',
146
+ phone: '1'
147
+ }
148
+ });
149
+ expect(res1.status).toBe(200);
150
+ });
151
+
152
+ it('invalid', async () => {
153
+ const res1 = await agent.resource('verifications').create({
154
+ values: {
155
+ type: 'authors:create',
156
+ phone: ''
157
+ }
158
+ });
159
+ expect(res1.status).toBe(400);
160
+ });
161
+ });
162
+ });
@@ -0,0 +1,15 @@
1
+ import { CollectionOptions } from '@nocobase/database';
2
+
3
+ export default {
4
+ name: 'authors',
5
+ fields: [
6
+ {
7
+ type: 'string',
8
+ name: 'title',
9
+ },
10
+ {
11
+ type: 'string',
12
+ name: 'phone'
13
+ }
14
+ ]
15
+ } as CollectionOptions;
@@ -0,0 +1,39 @@
1
+ import path from 'path';
2
+ import { MockServer, mockServer } from '@nocobase/test';
3
+
4
+ import Plugin from '..';
5
+ import { ApplicationOptions } from '@nocobase/server';
6
+
7
+ export function sleep(ms: number) {
8
+ return new Promise(resolve => {
9
+ setTimeout(resolve, ms);
10
+ });
11
+ }
12
+
13
+ interface MockAppOptions extends ApplicationOptions {
14
+ manual?: boolean;
15
+ }
16
+
17
+ export async function getApp({ manual, ...options }: MockAppOptions = {}): Promise<MockServer> {
18
+ const app = mockServer(options);
19
+
20
+ app.plugin(Plugin);
21
+
22
+ await app.load();
23
+
24
+ await app.db.import({
25
+ directory: path.resolve(__dirname, './collections')
26
+ });
27
+
28
+ try {
29
+ await app.db.sync();
30
+ } catch (error) {
31
+ console.error(error);
32
+ }
33
+
34
+ if (!manual) {
35
+ await app.start();
36
+ }
37
+
38
+ return app;
39
+ }
@@ -0,0 +1,14 @@
1
+ import * as verifications from './verifications';
2
+
3
+ function make(name, mod) {
4
+ return Object.keys(mod).reduce((result, key) => ({
5
+ ...result,
6
+ [`${name}:${key}`]: mod[key]
7
+ }), {})
8
+ }
9
+
10
+ export default function ({ app }) {
11
+ app.actions({
12
+ ...make('verifications', verifications)
13
+ });
14
+ }
@@ -0,0 +1,105 @@
1
+ import { promisify } from 'util';
2
+ import { randomInt, randomUUID } from 'crypto';
3
+
4
+ import { Op } from '@nocobase/database';
5
+ import actions, { Context, Next } from '@nocobase/actions';
6
+
7
+ import Plugin, { namespace } from '..';
8
+ import { CODE_STATUS_UNUSED } from '../constants';
9
+ import moment from 'moment';
10
+
11
+ const asyncRandomInt = promisify(randomInt);
12
+
13
+ export async function create(context: Context, next: Next) {
14
+ const plugin = context.app.getPlugin('@nocobase/plugin-verification') as Plugin;
15
+
16
+ const { values } = context.action.params;
17
+ const interceptor = plugin.interceptors.get(values?.type);
18
+ if (!interceptor) {
19
+ return context.throw(400, 'Invalid action type');
20
+ }
21
+
22
+ const ProviderRepo = context.db.getRepository('verifications_providers');
23
+ const providerItem = await ProviderRepo.findOne({
24
+ filterByTk: interceptor.provider
25
+ });
26
+ if (!providerItem) {
27
+ console.error(`[verification] no provider for action (${values.type}) provided`);
28
+ return context.throw(500);
29
+ }
30
+
31
+ const receiver = interceptor.getReceiver(context);
32
+ if (!receiver) {
33
+ return context.throw(400, { code: 'InvalidReceiver', message: 'Invalid receiver' });
34
+ }
35
+ const VerificationModel = context.db.getModel('verifications');
36
+ const record = await VerificationModel.findOne({
37
+ where: {
38
+ type: values.type,
39
+ receiver,
40
+ status: CODE_STATUS_UNUSED,
41
+ expiresAt: {
42
+ [Op.gt]: new Date()
43
+ }
44
+ }
45
+ });
46
+ if (record) {
47
+ const seconds = moment(record.get('expiresAt')).diff(moment(), 'seconds');
48
+ // return context.throw(429, { code: 'RateLimit', message: context.t('Please don\'t retry in {{time}}', { time: moment().locale('zh').to(record.get('expiresAt')) }) });
49
+ return context.throw(429, { code: 'RateLimit', message: context.t('Please don\'t retry in {{time}} seconds', { time: seconds, ns: namespace }) });
50
+ }
51
+
52
+ const code = (<number>(await asyncRandomInt(999999))).toString(10).padStart(6, '0');
53
+ if (interceptor.validate) {
54
+ try {
55
+ await interceptor.validate(context, receiver);
56
+ } catch (err) {
57
+ return context.throw(400, { code: 'InvalidReceiver', message: err.message });
58
+ }
59
+ }
60
+
61
+ const ProviderType = plugin.providers.get(<string>providerItem.get('type'));
62
+ const provider = new ProviderType(plugin, providerItem.get('options'));
63
+
64
+ try {
65
+ await provider.send(receiver, { code });
66
+ console.log('verification code sent');
67
+ } catch (error) {
68
+ switch (error.name) {
69
+ case 'InvalidReceiver':
70
+ // TODO: message should consider email and other providers, maybe use "receiver"
71
+ return context.throw(400, context.t('Not a valid cellphone number, please re-enter', {ns: namespace }));
72
+ case 'RateLimit':
73
+ return context.throw(429, context.t('You are trying so frequently, please slow down', { ns: namespace }));
74
+ default:
75
+ console.error(error);
76
+ return context.throw(500, context.t('Verification send failed, please try later or contact to administrator', { ns: namespace }));
77
+ }
78
+ }
79
+
80
+ const data = {
81
+ id: randomUUID(),
82
+ type: values.type,
83
+ receiver,
84
+ content: code,
85
+ expiresAt: Date.now() + (interceptor.expiresIn ?? 60) * 1000,
86
+ status: CODE_STATUS_UNUSED,
87
+ providerId: providerItem.get('id')
88
+ };
89
+
90
+ context.action.mergeParams({
91
+ values: data
92
+ }, {
93
+ values: 'overwrite'
94
+ });
95
+
96
+ await actions.create(context, async () => {
97
+ const { body: result } = context;
98
+ context.body = {
99
+ id: result.id,
100
+ expiresAt: result.expiresAt
101
+ };
102
+
103
+ return next();
104
+ });
105
+ }
@@ -0,0 +1,36 @@
1
+ export default {
2
+ name: 'verifications',
3
+ fields: [
4
+ {
5
+ type: 'uuid',
6
+ name: 'id',
7
+ primaryKey: true
8
+ },
9
+ {
10
+ type: 'string',
11
+ name: 'type'
12
+ },
13
+ {
14
+ type: 'string',
15
+ name: 'receiver'
16
+ },
17
+ {
18
+ type: 'integer',
19
+ name: 'status',
20
+ defaultValue: 0
21
+ },
22
+ {
23
+ type: 'date',
24
+ name: 'expiresAt'
25
+ },
26
+ {
27
+ type: 'string',
28
+ name: 'content'
29
+ },
30
+ {
31
+ type: 'belongsTo',
32
+ name: 'provider',
33
+ target: 'verifications_providers',
34
+ }
35
+ ]
36
+ };
@@ -0,0 +1,22 @@
1
+ export default {
2
+ name: 'verifications_providers',
3
+ fields: [
4
+ {
5
+ type: 'string',
6
+ name: 'id',
7
+ primaryKey: true
8
+ },
9
+ {
10
+ type: 'string',
11
+ name: 'title',
12
+ },
13
+ {
14
+ type: 'string',
15
+ name: 'type'
16
+ },
17
+ {
18
+ type: 'jsonb',
19
+ name: 'options'
20
+ }
21
+ ]
22
+ };
@@ -0,0 +1,4 @@
1
+ export const PROVIDER_TYPE_SMS_ALIYUN = 'sms-aliyun';
2
+
3
+ export const CODE_STATUS_UNUSED = 0;
4
+ export const CODE_STATUS_USED = 1;
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './constants';
2
+ export { Provider } from './providers';
3
+ export { Interceptor, default } from './Plugin';
4
+
5
+ export const namespace = require('../package.json').name;
@@ -0,0 +1 @@
1
+ export { default as zhCN } from './zh-CN';
@@ -0,0 +1,6 @@
1
+ export default {
2
+ 'Verification send failed, please try later or contact to administrator': '验证码发送失败,请稍后重试或联系管理员',
3
+ 'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
4
+ "Please don't retry in {{time}} seconds": '请 {{time}} 秒后再试',
5
+ 'You are trying so frequently, please slow down': '您的操作太频繁,请稍后再试'
6
+ };
@@ -0,0 +1,32 @@
1
+ import path from 'path';
2
+
3
+ import { requireModule } from '@nocobase/utils';
4
+
5
+ import Plugin from '../Plugin';
6
+ import { PROVIDER_TYPE_SMS_ALIYUN } from '../constants';
7
+
8
+
9
+
10
+ export class Provider {
11
+ constructor(protected plugin: Plugin, protected options) {}
12
+
13
+ async send(receiver: string, data: { [key: string]: any }): Promise<any>{}
14
+ }
15
+
16
+ interface Providers {
17
+ [key: string]: typeof Provider
18
+ }
19
+
20
+ export default function(plugin: Plugin, more: Providers = {}) {
21
+ const { providers } = plugin;
22
+
23
+ const natives = [
24
+ PROVIDER_TYPE_SMS_ALIYUN
25
+ ].reduce((result, key) => Object.assign(result, {
26
+ [key]: requireModule(path.isAbsolute(key) ? key : path.join(__dirname, key)) as typeof Provider
27
+ }), {} as Providers);
28
+
29
+ for (const [name, provider] of Object.entries({ ...more, ...natives })) {
30
+ providers.register(name, provider);
31
+ }
32
+ }
@@ -0,0 +1,62 @@
1
+ import DysmsApi, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
2
+ import * as OpenApi from '@alicloud/openapi-client';
3
+ import { RuntimeOptions } from '@alicloud/tea-util';
4
+
5
+ import { Provider } from '.';
6
+
7
+
8
+ export default class extends Provider {
9
+ client: DysmsApi;
10
+
11
+ constructor(plugin, options) {
12
+ super(plugin, options);
13
+
14
+ const { accessKeyId, accessKeySecret, endpoint } = this.options;
15
+
16
+ let config = new OpenApi.Config({
17
+ // 您的 AccessKey ID
18
+ accessKeyId: accessKeyId,
19
+ // 您的 AccessKey Secret
20
+ accessKeySecret: accessKeySecret,
21
+ });
22
+ // 访问的域名
23
+ config.endpoint = endpoint;
24
+
25
+ this.client = new DysmsApi(config);
26
+ }
27
+
28
+ async send(phoneNumbers, data = {}) {
29
+ const request = new SendSmsRequest({
30
+ phoneNumbers,
31
+ signName: this.options.sign,
32
+ templateCode: this.options.template,
33
+ templateParam: JSON.stringify(data)
34
+ });
35
+
36
+ try {
37
+ const { body } = await this.client.sendSmsWithOptions(request, new RuntimeOptions({}));
38
+ let err = new Error(body.message);
39
+ switch (body.code) {
40
+ case 'OK':
41
+ break;
42
+
43
+ case 'isv.MOBILE_NUMBER_ILLEGAL':
44
+ err.name = 'InvalidReceiver';
45
+ return Promise.reject(err);
46
+
47
+ case 'isv.BUSINESS_LIMIT_CONTROL':
48
+ err.name = 'RateLimit';
49
+ console.error(body);
50
+ return Promise.reject(err);
51
+
52
+ default:
53
+ // should not let user to know
54
+ console.error(body);
55
+ err.name = 'SendSMSFailed';
56
+ return Promise.reject(err);
57
+ }
58
+ } catch (error) {
59
+ return Promise.reject(error);
60
+ }
61
+ }
62
+ }