mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-19 10:54:38 +08:00
refactor: verification (#6026)
* refactor: verification * refactor: update * chore: update * feat: verificator * chore: update * chore: crud * feat: bind * feat: unbind * fix: auth-sms * feat: update * chore: add test cases and fix bug * fix(auth-sms): tests * chore: update * chore: i18n * chore: update * chore: update * fix: required form fields * chore: uid field * fix: remote select * fix: test * fix: test * fix: menu * chore: description * fix: test * chore: update
This commit is contained in:
@@ -225,6 +225,24 @@ export class BaseAuth extends Auth {
|
||||
return null;
|
||||
}
|
||||
|
||||
async signNewToken(userId: number) {
|
||||
const tokenInfo = await this.tokenController.add({ userId });
|
||||
const expiresIn = Math.floor((await this.tokenController.getConfig()).tokenExpirationTime / 1000);
|
||||
const token = this.jwt.sign(
|
||||
{
|
||||
userId,
|
||||
temp: true,
|
||||
iat: Math.floor(tokenInfo.issuedTime / 1000),
|
||||
signInTime: tokenInfo.signInTime,
|
||||
},
|
||||
{
|
||||
jwtid: tokenInfo.jti,
|
||||
expiresIn,
|
||||
},
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async signIn() {
|
||||
let user: Model;
|
||||
try {
|
||||
@@ -240,20 +258,7 @@ export class BaseAuth extends Auth {
|
||||
code: AuthErrorCode.NOT_EXIST_USER,
|
||||
});
|
||||
}
|
||||
const tokenInfo = await this.tokenController.add({ userId: user.id });
|
||||
const expiresIn = Math.floor((await this.tokenController.getConfig()).tokenExpirationTime / 1000);
|
||||
const token = this.jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
temp: true,
|
||||
iat: Math.floor(tokenInfo.issuedTime / 1000),
|
||||
signInTime: tokenInfo.signInTime,
|
||||
},
|
||||
{
|
||||
jwtid: tokenInfo.jti,
|
||||
expiresIn,
|
||||
},
|
||||
);
|
||||
const token = await this.signNewToken(user.id);
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
|
||||
@@ -179,4 +179,17 @@ ActionModal.Footer = observer(
|
||||
{ displayName: 'ActionModal.Footer' },
|
||||
);
|
||||
|
||||
ActionModal.FootBar = observer(
|
||||
() => {
|
||||
const field = useField();
|
||||
const schema = useFieldSchema();
|
||||
return (
|
||||
<div className="ant-modal-footer">
|
||||
<NocoBaseRecursionField basePath={field.address} schema={schema} onlyRenderProperties />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ displayName: 'ActionModal.FootBar' },
|
||||
);
|
||||
|
||||
export default ActionModal;
|
||||
|
||||
@@ -25,6 +25,14 @@ export const Options = () => {
|
||||
public: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
verificator: {
|
||||
type: 'string',
|
||||
'x-component': 'VerificatorSelect',
|
||||
'x-component-props': {
|
||||
title: '{{t("Verificator")}}',
|
||||
scene: 'auth-sms',
|
||||
},
|
||||
},
|
||||
autoSignup: {
|
||||
'x-decorator': 'FormItem',
|
||||
type: 'boolean',
|
||||
|
||||
@@ -7,44 +7,35 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import { ISchema } from '@formily/react';
|
||||
import { SchemaComponent, useAPIClient, useCurrentUserContext, usePlugin } from '@nocobase/client';
|
||||
import { ISchema, useForm } from '@formily/react';
|
||||
import React from 'react';
|
||||
import VerificationCode from './VerificationCode';
|
||||
import { Authenticator, useSignIn } from '@nocobase/plugin-auth/client';
|
||||
import { Authenticator, useRedirect } from '@nocobase/plugin-auth/client';
|
||||
import PluginVerificationClient, { SMS_OTP_VERIFICATION_TYPE } from '@nocobase/plugin-verification/client';
|
||||
import { useAuthTranslation } from './locale';
|
||||
|
||||
const phoneForm: ISchema = {
|
||||
type: 'object',
|
||||
name: 'phoneForm',
|
||||
'x-component': 'Form',
|
||||
'x-component': 'FormV2',
|
||||
properties: {
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
'x-component': 'Input',
|
||||
'x-validator': 'phone',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component-props': { placeholder: '{{t("Phone")}}', style: {} },
|
||||
},
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
'x-component': 'VerificationCode',
|
||||
form: {
|
||||
type: 'void',
|
||||
'x-component': 'VerificationForm',
|
||||
'x-component-props': {
|
||||
actionType: 'auth:signIn',
|
||||
targetFieldName: 'phone',
|
||||
verificator: '{{ verificator }}',
|
||||
},
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
actions: {
|
||||
title: '{{t("Sign in")}}',
|
||||
type: 'void',
|
||||
title: '{{t("Sign in")}}',
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useVerifyActionProps',
|
||||
'x-component-props': {
|
||||
htmlType: 'submit',
|
||||
block: true,
|
||||
type: 'primary',
|
||||
useAction: '{{ useSMSSignIn }}',
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
@@ -58,12 +49,36 @@ const phoneForm: ISchema = {
|
||||
},
|
||||
};
|
||||
|
||||
const useVerifyActionProps = (authenticator: string) => {
|
||||
const form = useForm();
|
||||
const api = useAPIClient();
|
||||
const redirect = useRedirect();
|
||||
const { refreshAsync } = useCurrentUserContext();
|
||||
const { t } = useAuthTranslation();
|
||||
return {
|
||||
title: t('Sign in'),
|
||||
async onClick() {
|
||||
await form.submit();
|
||||
await api.auth.signIn(form.values, authenticator);
|
||||
await refreshAsync();
|
||||
redirect();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SigninPage = (props: { authenticator: Authenticator }) => {
|
||||
const authenticator = props.authenticator;
|
||||
const { name, options } = authenticator;
|
||||
const autoSignup = !!options?.autoSignup;
|
||||
const useSMSSignIn = () => {
|
||||
return useSignIn(name);
|
||||
};
|
||||
return <SchemaComponent schema={phoneForm} scope={{ useSMSSignIn, autoSignup }} components={{ VerificationCode }} />;
|
||||
const verficationPlugin = usePlugin('verification') as PluginVerificationClient;
|
||||
const smsVerification = verficationPlugin.verificationManager.getVerification(SMS_OTP_VERIFICATION_TYPE);
|
||||
const VerificationForm = smsVerification?.components.VerificationForm;
|
||||
|
||||
return (
|
||||
<SchemaComponent
|
||||
schema={phoneForm}
|
||||
scope={{ useVerifyActionProps: () => useVerifyActionProps(name), autoSignup, verificator: options?.verificator }}
|
||||
components={{ VerificationForm }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useForm } from '@formily/react';
|
||||
import { css, useAPIClient } from '@nocobase/client';
|
||||
import { Button, Input, message } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function VerificationCode({ targetFieldName = 'phone', actionType, value, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPIClient();
|
||||
const form = useForm();
|
||||
|
||||
const [count, setCountdown] = useState<number>(0);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (count <= 0 && timer.current) {
|
||||
clearInterval(timer.current);
|
||||
}
|
||||
}, [count]);
|
||||
|
||||
async function onGetCode() {
|
||||
if (count > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const {
|
||||
data: { data },
|
||||
} = await api.resource('verifications').create({
|
||||
values: {
|
||||
type: actionType,
|
||||
phone: form.values[targetFieldName],
|
||||
},
|
||||
});
|
||||
message.success(t('Operation succeeded'));
|
||||
if (value) {
|
||||
onChange('');
|
||||
}
|
||||
const expiresIn = data.expiresAt ? Math.ceil((Date.parse(data.expiresAt) - Date.now()) / 1000) : 60;
|
||||
setCountdown(expiresIn);
|
||||
timer.current = setInterval(() => {
|
||||
setCountdown((count) => count - 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={css`
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
`}
|
||||
>
|
||||
<Input value={value} onChange={onChange} placeholder={t('Verification code')} />
|
||||
<Button onClick={onGetCode} disabled={count > 0}>
|
||||
{count > 0 ? t('Retry after {{count}} seconds', { count }) : t('Send code')}
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -12,5 +12,5 @@ import { useTranslation } from 'react-i18next';
|
||||
export const NAMESPACE = 'auth-sms';
|
||||
|
||||
export function useAuthTranslation() {
|
||||
return useTranslation(NAMESPACE);
|
||||
return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' });
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"Sign in via SMS": "Sign in via SMS",
|
||||
"User will be registered automatically if not exists.": "User will be registered automatically if not exists.",
|
||||
"Sign up automatically when the user does not exist": "Sign up automatically when the user does not exist",
|
||||
"SMS": "SMS"
|
||||
"SMS": "SMS",
|
||||
"Verificator": "Verificator"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"Sign in via SMS": "短信登录",
|
||||
"User will be registered automatically if not exists.": "用户不存在时将自动注册。",
|
||||
"Sign up automatically when the user does not exist": "用户不存在时自动注册",
|
||||
"SMS": "短信"
|
||||
"SMS": "短信",
|
||||
"Verificator": "验证器"
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ class Provider {
|
||||
describe('signin', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let verificationModel: ModelStatic<Model>;
|
||||
let authenticator: Model;
|
||||
let verificator: Model;
|
||||
let agent;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -35,24 +35,33 @@ describe('signin', () => {
|
||||
db = app.db;
|
||||
agent = app.agent();
|
||||
|
||||
const verificationPlugin: VerificationPlugin = app.getPlugin('verification');
|
||||
verificationPlugin.providers.register('fake', Provider as any);
|
||||
const VerificationProviderRepo = db.getRepository('verifications_providers');
|
||||
await VerificationProviderRepo.create({
|
||||
const verificationPlugin: VerificationPlugin = app.pm.get('verification');
|
||||
verificationPlugin.smsOTPProviderManager.registerProvider('fake', {
|
||||
title: 'Fake',
|
||||
provider: Provider as any,
|
||||
});
|
||||
const verificatorRepo = db.getRepository('verificators');
|
||||
verificator = await verificatorRepo.create({
|
||||
values: {
|
||||
id: 'fake1',
|
||||
type: 'fake',
|
||||
default: true,
|
||||
name: 'sms-otp',
|
||||
title: 'SMS OTP',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'fake',
|
||||
},
|
||||
},
|
||||
});
|
||||
verificationModel = db.getCollection('verifications').model;
|
||||
|
||||
const authenticatorRepo = db.getRepository('authenticators');
|
||||
authenticator = await authenticatorRepo.create({
|
||||
values: {
|
||||
name: 'sms-auth',
|
||||
authType: authType,
|
||||
enabled: 1,
|
||||
options: {
|
||||
public: {
|
||||
verificator: verificator.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -62,22 +71,23 @@ describe('signin', () => {
|
||||
});
|
||||
|
||||
it('should create new user and sign in via phone number', async () => {
|
||||
let res = await agent.resource('verifications').create({
|
||||
let res = await agent.resource('smsOTP').publicCreate({
|
||||
values: {
|
||||
type: 'auth:signIn',
|
||||
phone: '1',
|
||||
verificator: verificator.name,
|
||||
action: 'auth:signIn',
|
||||
uuid: '1',
|
||||
},
|
||||
});
|
||||
const verification = await verificationModel.findByPk(res.body.data.id);
|
||||
expect(res.status).toBe(200);
|
||||
const otpRecord = await db.getRepository('otpRecords').findOne({ filterByTk: res.body.data.id });
|
||||
res = await agent.set({ 'X-Authenticator': 'sms-auth' }).post('/auth:signIn').send({
|
||||
phone: '1',
|
||||
code: verification.content,
|
||||
uuid: '1',
|
||||
code: otpRecord.code,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
await db.getCollection('verifications').repository.update({
|
||||
await db.getRepository('otpRecords').update({
|
||||
filter: {
|
||||
id: verification.id,
|
||||
id: otpRecord.id,
|
||||
},
|
||||
values: {
|
||||
status: 0,
|
||||
@@ -92,13 +102,14 @@ describe('signin', () => {
|
||||
options: {
|
||||
public: {
|
||||
autoSignup: true,
|
||||
verificator: verificator.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
res = await agent.set({ 'X-Authenticator': 'sms-auth' }).post('/auth:signIn').send({
|
||||
phone: '1',
|
||||
code: verification.content,
|
||||
uuid: '1',
|
||||
code: otpRecord.code,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.body.data;
|
||||
@@ -121,16 +132,18 @@ describe('signin', () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
let res = await agent.resource('verifications').create({
|
||||
let res = await agent.resource('smsOTP').publicCreate({
|
||||
values: {
|
||||
type: 'auth:signIn',
|
||||
phone: '2',
|
||||
verificator: verificator.name,
|
||||
action: 'auth:signIn',
|
||||
uuid: '2',
|
||||
},
|
||||
});
|
||||
const verification = await verificationModel.findByPk(res.body.data.id);
|
||||
expect(res.status).toBe(200);
|
||||
const otpRecord = await db.getRepository('otpRecords').findOne({ filterByTk: res.body.data.id });
|
||||
res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'sms-auth' }).send({
|
||||
phone: '2',
|
||||
code: verification.content,
|
||||
uuid: '2',
|
||||
code: otpRecord.code,
|
||||
});
|
||||
expect(res.statusCode).toEqual(200);
|
||||
const data = res.body.data;
|
||||
@@ -149,16 +162,18 @@ describe('signin', () => {
|
||||
phone: phone,
|
||||
},
|
||||
});
|
||||
let res = await agent.resource('verifications').create({
|
||||
let res = await agent.resource('smsOTP').publicCreate({
|
||||
values: {
|
||||
type: 'auth:signIn',
|
||||
phone: '3',
|
||||
verificator: verificator.name,
|
||||
action: 'auth:signIn',
|
||||
uuid: '3',
|
||||
},
|
||||
});
|
||||
const verification = await verificationModel.findByPk(res.body.data.id);
|
||||
expect(res.status).toBe(200);
|
||||
const otpRecord = await db.getRepository('otpRecords').findOne({ filterByTk: res.body.data.id });
|
||||
res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'sms-auth' }).send({
|
||||
phone: '3',
|
||||
code: verification.content,
|
||||
uuid: '3',
|
||||
code: otpRecord.code,
|
||||
});
|
||||
expect(res.statusCode).toEqual(200);
|
||||
const data = res.body.data;
|
||||
|
||||
@@ -22,17 +22,10 @@ export class PluginAuthSMSServer extends Plugin {
|
||||
this.app.logger.warn('auth-sms: @nocobase/plugin-verification is required');
|
||||
return;
|
||||
}
|
||||
verificationPlugin.interceptors.register('auth:signIn', {
|
||||
verificationPlugin.verificationManager.registerAction('auth:signIn', {
|
||||
manual: true,
|
||||
getReceiver: (ctx) => {
|
||||
return ctx.action.params.values.phone;
|
||||
},
|
||||
expiresIn: 120,
|
||||
validate: async (ctx, phone) => {
|
||||
if (!phone) {
|
||||
throw new Error(ctx.t('Not a valid cellphone number, please re-enter'));
|
||||
}
|
||||
return true;
|
||||
getBoundInfoFromCtx: (ctx) => {
|
||||
return ctx.action.params.values || {};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -26,12 +26,18 @@ export class SMSAuth extends BaseAuth {
|
||||
const ctx = this.ctx;
|
||||
const verificationPlugin: VerificationPlugin = ctx.app.getPlugin('verification');
|
||||
if (!verificationPlugin) {
|
||||
throw new Error('auth-sms: @nocobase/plugin-verification is required');
|
||||
ctx.log.error('auth-sms: @nocobase/plugin-verification is required', { method: 'validate' });
|
||||
ctx.throw(500);
|
||||
}
|
||||
let user: Model;
|
||||
await verificationPlugin.intercept(ctx, async () => {
|
||||
ctx.action.mergeParams({
|
||||
values: {
|
||||
verificator: this.options.public?.verificator,
|
||||
},
|
||||
});
|
||||
await verificationPlugin.verificationManager.verify(ctx, async () => {
|
||||
const {
|
||||
values: { phone },
|
||||
values: { uuid: phone },
|
||||
} = ctx.action.params;
|
||||
try {
|
||||
// History data compatible processing
|
||||
@@ -61,7 +67,7 @@ export class SMSAuth extends BaseAuth {
|
||||
throw new Error(ctx.t('The phone number is not registered, please register first', { ns: namespace }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
ctx.log.error(err, { method: 'validate' });
|
||||
throw new Error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,8 +51,14 @@ export class PluginAuthClient extends Plugin {
|
||||
this.app.pluginSettingsManager.add(NAMESPACE, {
|
||||
icon: 'LoginOutlined',
|
||||
title: `{{t("Authentication", { ns: "${NAMESPACE}" })}}`,
|
||||
aclSnippet: 'pm.auth',
|
||||
});
|
||||
this.app.pluginSettingsManager.add('auth.authenticators', {
|
||||
icon: 'LoginOutlined',
|
||||
title: `{{t("Authenticators", { ns: "${NAMESPACE}" })}}`,
|
||||
Component: Authenticator,
|
||||
aclSnippet: 'pm.auth.authenticators',
|
||||
sort: 1,
|
||||
});
|
||||
|
||||
this.router.add('auth', {
|
||||
@@ -106,7 +112,11 @@ const useSignIn = function (name: string) {
|
||||
const useSignIn = useLazy<typeof import('./basic').useSignIn>(() => import('./basic'), 'useSignIn');
|
||||
return useSignIn(name);
|
||||
};
|
||||
const useRedirect = function (next = '/admin') {
|
||||
const useRedirect = useLazy<typeof import('./basic').useRedirect>(() => import('./basic'), 'useRedirect');
|
||||
return useRedirect(next);
|
||||
};
|
||||
|
||||
export { useSignIn };
|
||||
export { useSignIn, useRedirect };
|
||||
|
||||
export default PluginAuthClient;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-verification",
|
||||
"displayName": "Verification",
|
||||
"displayName.zh-CN": "验证码",
|
||||
"description": "verification setting.",
|
||||
"description.zh-CN": "验证码配置。",
|
||||
"displayName.zh-CN": "验证",
|
||||
"description": "User identity verification management, including SMS, TOTP authenticator, with extensibility.",
|
||||
"description.zh-CN": "用户身份验证管理,包含短信、TOTP 认证器等,可扩展。",
|
||||
"version": "1.6.0-alpha.29",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "./dist/server/index.js",
|
||||
@@ -33,6 +33,8 @@
|
||||
},
|
||||
"gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1",
|
||||
"keywords": [
|
||||
"Authentication"
|
||||
"Authentication",
|
||||
"Verification",
|
||||
"Security"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { FormLayout } from '@formily/antd-v5';
|
||||
import { Field } from '@formily/core';
|
||||
import { RecursionField, Schema, observer, useField, useForm } from '@formily/react';
|
||||
import { useUpdateEffect } from 'ahooks';
|
||||
import React, { useState } from 'react';
|
||||
import providerTypes from './providerTypes';
|
||||
|
||||
const Verification = observer(
|
||||
(props) => {
|
||||
const form = useForm();
|
||||
const field = useField<Field>();
|
||||
const [s, setSchema] = useState(new Schema(providerTypes.get(form.values.type) || {}));
|
||||
useUpdateEffect(() => {
|
||||
form.clearFormGraph('options.*');
|
||||
setSchema(new Schema(providerTypes.get(form.values.type) || {}));
|
||||
}, [form.values.type]);
|
||||
return (
|
||||
<FormLayout layout={'vertical'}>
|
||||
<RecursionField
|
||||
key={form.values.type || 'sms-aliyun'}
|
||||
basePath={field.address}
|
||||
onlyRenderProperties
|
||||
schema={s}
|
||||
/>
|
||||
</FormLayout>
|
||||
);
|
||||
},
|
||||
{ displayName: 'Verification' },
|
||||
);
|
||||
|
||||
export default Verification;
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionContextProvider,
|
||||
DropdownVisibleContext,
|
||||
SchemaComponent,
|
||||
SchemaSettingsItem,
|
||||
useAPIClient,
|
||||
useActionContext,
|
||||
usePlugin,
|
||||
useRequest,
|
||||
useZIndexContext,
|
||||
zIndexContext,
|
||||
} from '@nocobase/client';
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { List, Tag, message, Tabs } from 'antd';
|
||||
import { uid } from '@formily/shared';
|
||||
import { Schema, useForm } from '@formily/react';
|
||||
import { useVerificationTranslation } from './locale';
|
||||
import PluginVerificationClient from '.';
|
||||
import { createForm } from '@formily/core';
|
||||
|
||||
export const UserVerificatorsContext = createContext<{
|
||||
refresh: () => void;
|
||||
}>(null);
|
||||
|
||||
const useBindActionProps = (verificator: string) => {
|
||||
const form = useForm();
|
||||
const api = useAPIClient();
|
||||
const { t } = useVerificationTranslation();
|
||||
const { refresh } = useContext(UserVerificatorsContext);
|
||||
const { setVisible } = useActionContext();
|
||||
|
||||
return {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
onClick: async () => {
|
||||
await form.submit();
|
||||
await api.resource('verificators').bind({
|
||||
values: {
|
||||
verificator,
|
||||
...form.values,
|
||||
},
|
||||
});
|
||||
message.success(t('Bound successfully'));
|
||||
setVisible(false);
|
||||
refresh();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useUnbindActionProps = () => {
|
||||
const form = useForm();
|
||||
const api = useAPIClient();
|
||||
const { t } = useVerificationTranslation();
|
||||
const { refresh } = useContext(UserVerificatorsContext);
|
||||
const { setVisible } = useActionContext();
|
||||
|
||||
return {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
onClick: async () => {
|
||||
await form.submit();
|
||||
await api.resource('verificators').unbind({
|
||||
values: {
|
||||
...form.values,
|
||||
},
|
||||
});
|
||||
message.success(t('Unbound successfully'));
|
||||
setVisible(false);
|
||||
refresh();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useCancelActionProps = () => {
|
||||
const { setVisible } = useActionContext();
|
||||
return {
|
||||
onClick: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useFormProps = (verificator: string, unbindVerificator: string) => {
|
||||
const form = useMemo(
|
||||
() =>
|
||||
createForm({
|
||||
initialValues: {
|
||||
verificator,
|
||||
unbindVerificator,
|
||||
},
|
||||
}),
|
||||
[verificator, unbindVerificator],
|
||||
);
|
||||
return {
|
||||
form,
|
||||
};
|
||||
};
|
||||
|
||||
const BindModal: React.FC<{
|
||||
verificator: {
|
||||
name: string;
|
||||
title: string;
|
||||
verificationType: string;
|
||||
};
|
||||
}> = ({ verificator }) => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const plugin = usePlugin('verification') as PluginVerificationClient;
|
||||
if (!verificator) {
|
||||
return null;
|
||||
}
|
||||
const verification = plugin.verificationManager.getVerification(verificator.verificationType);
|
||||
const C = verification?.components?.BindForm;
|
||||
|
||||
return (
|
||||
<SchemaComponent
|
||||
components={{ C }}
|
||||
scope={{ useBindActionProps: () => useBindActionProps(verificator.name), useCancelActionProps }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
type: 'object',
|
||||
'x-component': 'Action.Modal',
|
||||
'x-component-props': {
|
||||
width: 520,
|
||||
},
|
||||
title: t(verificator.title),
|
||||
'x-decorator': 'FormV2',
|
||||
properties: {
|
||||
form: {
|
||||
type: 'void',
|
||||
'x-component': 'C',
|
||||
'x-component-props': {
|
||||
verificator: verificator.name,
|
||||
actionType: 'verificators:bind',
|
||||
isLogged: true,
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Modal.Footer',
|
||||
properties: {
|
||||
close: {
|
||||
title: t('Cancel'),
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'default',
|
||||
},
|
||||
'x-use-component-props': 'useCancelActionProps',
|
||||
},
|
||||
submit: {
|
||||
title: t('Bind'),
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useBindActionProps',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UnbindForm: React.FC<{
|
||||
verificators: any[];
|
||||
unbindVerificator: string;
|
||||
}> = ({ verificators, unbindVerificator }) => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const plugin = usePlugin('verification') as PluginVerificationClient;
|
||||
|
||||
const tabs = verificators
|
||||
.map((verificator) => {
|
||||
const verification = plugin.verificationManager.getVerification(verificator.verificationType);
|
||||
const C = verification?.components?.VerificationForm;
|
||||
if (!C) {
|
||||
return;
|
||||
}
|
||||
const defaultTabTitle = Schema.compile(verificator.verificationTypeTitle || verificator.verificationType, { t });
|
||||
return {
|
||||
component: (
|
||||
<SchemaComponent
|
||||
components={{ C }}
|
||||
scope={{
|
||||
useCancelActionProps,
|
||||
useUnbindActionProps,
|
||||
useFormProps: () => useFormProps(verificator.name, unbindVerificator),
|
||||
}}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
form: {
|
||||
type: 'object',
|
||||
'x-component': 'FormV2',
|
||||
'x-use-component-props': 'useFormProps',
|
||||
properties: {
|
||||
bind: {
|
||||
type: 'void',
|
||||
'x-component': 'C',
|
||||
'x-component-props': {
|
||||
actionType: 'verificators:unbind',
|
||||
verificator: verificator.name,
|
||||
boundInfo: verificator.boundInfo,
|
||||
isLogged: true,
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Modal.FootBar',
|
||||
properties: {
|
||||
close: {
|
||||
title: t('Cancel'),
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'default',
|
||||
},
|
||||
'x-use-component-props': 'useCancelActionProps',
|
||||
},
|
||||
submit: {
|
||||
title: t('Unbind'),
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useUnbindActionProps',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
tabTitle: verificator.title || defaultTabTitle,
|
||||
...verificator,
|
||||
};
|
||||
})
|
||||
.filter((i) => i);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.length ? (
|
||||
<Tabs
|
||||
destroyInactiveTabPane={true}
|
||||
items={tabs.map((tab) => ({ label: tab.tabTitle, key: tab.name, children: tab.component }))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UnbindModal: React.FC<{
|
||||
verificator: {
|
||||
name: string;
|
||||
title: string;
|
||||
verificationType: string;
|
||||
};
|
||||
}> = ({ verificator }) => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const api = useAPIClient();
|
||||
const { data: verificators, loading } = useRequest<any[]>(
|
||||
() =>
|
||||
api
|
||||
.resource('verificators')
|
||||
.listForVerify({
|
||||
scene: 'unbind-verificator',
|
||||
})
|
||||
.then((res) => res?.data?.data),
|
||||
{
|
||||
refreshDeps: [verificator],
|
||||
},
|
||||
);
|
||||
|
||||
if (!verificator || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SchemaComponent
|
||||
components={{ UnbindForm }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
type: 'object',
|
||||
'x-component': 'Action.Modal',
|
||||
'x-component-props': {
|
||||
width: 520,
|
||||
},
|
||||
title: t('Unbind verificator'),
|
||||
properties: {
|
||||
[uid()]: {
|
||||
type: 'void',
|
||||
'x-component': 'UnbindForm',
|
||||
'x-component-props': {
|
||||
verificators,
|
||||
unbindVerificator: verificator.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Verificators: React.FC = () => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const api = useAPIClient();
|
||||
const { data, refresh } = useRequest(() =>
|
||||
api
|
||||
.resource('verificators')
|
||||
.listByUser()
|
||||
.then((res) => res?.data?.data),
|
||||
);
|
||||
const [openBindModal, setOpenBindModal] = useState(false);
|
||||
const [openUnbindModal, setOpenUnbindModal] = useState(false);
|
||||
const [verificator, setVerificator] = useState(null);
|
||||
const setBindInfo = (item: any) => {
|
||||
setOpenBindModal(true);
|
||||
setVerificator(item);
|
||||
};
|
||||
const setUnbindInfo = (item: any) => {
|
||||
setOpenUnbindModal(true);
|
||||
setVerificator(item);
|
||||
};
|
||||
|
||||
return (
|
||||
<UserVerificatorsContext.Provider value={{ refresh }}>
|
||||
<List
|
||||
bordered
|
||||
dataSource={data as any}
|
||||
renderItem={(item: {
|
||||
title: string;
|
||||
description?: string;
|
||||
boundInfo?: { bound: boolean; publicInfo?: string };
|
||||
}) => (
|
||||
<List.Item
|
||||
actions={
|
||||
item.boundInfo?.bound
|
||||
? [
|
||||
<a key="unbind" onClick={() => setUnbindInfo(item)}>
|
||||
{t('Unbind')}
|
||||
</a>,
|
||||
]
|
||||
: [
|
||||
<a key="bind" onClick={() => setBindInfo(item)}>
|
||||
{t('Bind')}
|
||||
</a>,
|
||||
]
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<>
|
||||
{Schema.compile(item.title, { t })}
|
||||
{item.boundInfo?.bound ? (
|
||||
<Tag color="success" style={{ marginLeft: '10px' }}>
|
||||
{t('Configured')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="warning" style={{ marginLeft: '10px' }}>
|
||||
{t('Not configured')}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={Schema.compile(item.description, { t })}
|
||||
/>
|
||||
<div style={{ marginLeft: '10px' }}>{item.boundInfo?.publicInfo}</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<ActionContextProvider value={{ visible: openBindModal, setVisible: setOpenBindModal }}>
|
||||
{openBindModal ? <BindModal verificator={verificator} /> : null}
|
||||
</ActionContextProvider>
|
||||
<ActionContextProvider value={{ visible: openUnbindModal, setVisible: setOpenUnbindModal }}>
|
||||
{openUnbindModal ? <UnbindModal verificator={verificator} /> : null}
|
||||
</ActionContextProvider>
|
||||
</UserVerificatorsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Verification = () => {
|
||||
const ctx = useContext(DropdownVisibleContext);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useVerificationTranslation();
|
||||
const parentZIndex = useZIndexContext();
|
||||
const zIndex = parentZIndex + 10;
|
||||
|
||||
// 避免重复渲染的 click 处理
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
ctx?.setVisible?.(false);
|
||||
setVisible((prev) => (prev ? prev : true)); // 只有 `visible` 变化时才触发更新
|
||||
},
|
||||
[ctx],
|
||||
);
|
||||
|
||||
// 避免 `SchemaComponent` 结构重新创建
|
||||
const schemaComponent = useMemo(() => {
|
||||
return (
|
||||
<SchemaComponent
|
||||
components={{ Verificators }}
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-component-props': { zIndex },
|
||||
type: 'void',
|
||||
title: '{{t("Verification")}}',
|
||||
properties: {
|
||||
form: {
|
||||
type: 'void',
|
||||
'x-component': 'Verificators',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [zIndex]);
|
||||
|
||||
return (
|
||||
<zIndexContext.Provider value={zIndex}>
|
||||
<SchemaSettingsItem eventKey="Verification" title="Verification">
|
||||
<div onClick={handleClick}>{t('Verification')}</div>
|
||||
</SchemaSettingsItem>
|
||||
<ActionContextProvider value={{ visible, setVisible }}>
|
||||
{visible && <div onClick={(e) => e.stopPropagation()}>{schemaComponent}</div>}
|
||||
</ActionContextProvider>
|
||||
</zIndexContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { Card } from 'antd';
|
||||
|
||||
import providers from './schemas/providers';
|
||||
import ProviderOptions from './ProviderOptions';
|
||||
|
||||
export function VerificationProviders() {
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<SchemaComponent
|
||||
schema={providers}
|
||||
components={{
|
||||
ProviderOptions,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,45 @@
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/client';
|
||||
// import { VerificationProviders } from './VerificationProviders';
|
||||
import { lazy } from '@nocobase/client';
|
||||
const { VerificationProviders } = lazy(() => import('./VerificationProviders'), 'VerificationProviders');
|
||||
const { Verificators } = lazy(() => import('./verificators/Verificators'), 'Verificators');
|
||||
const { VerificatorSelect } = lazy(() => import('./verificators/VerificatorSelect'), 'VerificatorSelect');
|
||||
// const { Verification } = lazy(() => import('./VerificationMenu'), 'Verification');
|
||||
import { NAMESPACE } from './locale';
|
||||
import { PROVIDER_TYPE_SMS_ALIYUN, PROVIDER_TYPE_SMS_TENCENT, SMS_OTP_VERIFICATION_TYPE } from '../constants';
|
||||
import { VerificationManager } from './verification-manager';
|
||||
import { smsAliyunProviderOptions, smsOTPVerificationOptions, smsTencentProviderOptions } from './otp-verification/sms';
|
||||
import { SMSOTPProviderManager } from './otp-verification/sms/provider-manager';
|
||||
import { Verification } from './VerificationMenu';
|
||||
|
||||
export class PluginVerificationClient extends Plugin {
|
||||
verificationManager = new VerificationManager();
|
||||
smsOTPProviderManager = new SMSOTPProviderManager();
|
||||
|
||||
async load() {
|
||||
this.app.pluginSettingsManager.add(NAMESPACE, {
|
||||
icon: 'CheckCircleOutlined',
|
||||
title: `{{t("Verification", { ns: "${NAMESPACE}" })}}`,
|
||||
Component: VerificationProviders,
|
||||
aclSnippet: 'pm.verification.providers',
|
||||
Component: Verificators,
|
||||
aclSnippet: 'pm.verification.verificators',
|
||||
});
|
||||
|
||||
this.app.addComponents({
|
||||
VerificatorSelect,
|
||||
});
|
||||
|
||||
this.app.addUserCenterSettingsItem({
|
||||
name: 'verification',
|
||||
Component: Verification,
|
||||
sort: 150,
|
||||
});
|
||||
|
||||
this.verificationManager.registerVerificationType(SMS_OTP_VERIFICATION_TYPE, smsOTPVerificationOptions);
|
||||
this.smsOTPProviderManager.registerProvider(PROVIDER_TYPE_SMS_ALIYUN, smsAliyunProviderOptions);
|
||||
this.smsOTPProviderManager.registerProvider(PROVIDER_TYPE_SMS_TENCENT, smsTencentProviderOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export { SMS_OTP_VERIFICATION_TYPE };
|
||||
export { UserVerificatorsContext } from './VerificationMenu';
|
||||
export default PluginVerificationClient;
|
||||
|
||||
@@ -15,9 +15,9 @@ export const NAMESPACE = 'verification';
|
||||
// i18n.addResources('zh-CN', NAMESPACE, zhCN);
|
||||
|
||||
export function lang(key: string) {
|
||||
return i18n.t(key, { ns: NAMESPACE });
|
||||
return i18n.t(key, { ns: [NAMESPACE, 'client'] });
|
||||
}
|
||||
|
||||
export function useVerificationTranslation() {
|
||||
return useTranslation(NAMESPACE);
|
||||
return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' });
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useForm } from '@formily/react';
|
||||
import { css, useAPIClient, withDynamicSchemaProps } from '@nocobase/client';
|
||||
import { Button, Input, message } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const VerificationCode: React.FC<{
|
||||
actionType: string;
|
||||
verificator: string;
|
||||
value: string;
|
||||
onChange: (value: any) => void;
|
||||
isLogged?: boolean;
|
||||
}> = withDynamicSchemaProps(
|
||||
({ actionType, verificator, value, onChange, isLogged }) => {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPIClient();
|
||||
const form = useForm();
|
||||
|
||||
const [count, setCountdown] = useState<number>(0);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (count <= 0 && timer.current) {
|
||||
clearInterval(timer.current);
|
||||
}
|
||||
}, [count]);
|
||||
|
||||
async function onGetCode() {
|
||||
if (count > 0) {
|
||||
return;
|
||||
}
|
||||
const method = isLogged ? 'create' : 'publicCreate';
|
||||
try {
|
||||
const {
|
||||
data: { data },
|
||||
} = await api.resource('smsOTP')[method]({
|
||||
values: {
|
||||
action: actionType,
|
||||
verificator,
|
||||
...form.values,
|
||||
},
|
||||
});
|
||||
message.success(t('Operation succeeded'));
|
||||
if (value) {
|
||||
onChange('');
|
||||
}
|
||||
const expiresIn = data.expiresAt ? Math.ceil((Date.parse(data.expiresAt) - Date.now()) / 1000) : 60;
|
||||
setCountdown(expiresIn);
|
||||
timer.current = setInterval(() => {
|
||||
setCountdown((count) => count - 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={css`
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
`}
|
||||
>
|
||||
<Input value={value} onChange={onChange} />
|
||||
<Button onClick={onGetCode} disabled={count > 0}>
|
||||
{count > 0 ? t('Retry after {{count}} seconds', { count }) : t('Send code')}
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
{
|
||||
displayName: 'VerificationCode',
|
||||
},
|
||||
);
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { SchemaComponent, usePlugin } from '@nocobase/client';
|
||||
import { tval } from '@nocobase/utils/client';
|
||||
import React from 'react';
|
||||
import { NAMESPACE } from '../../locale';
|
||||
import { observer, useForm } from '@formily/react';
|
||||
import PluginVerificationClient from '../..';
|
||||
|
||||
export const useAdminSettingsForm = (providerType: string) => {
|
||||
const plugin = usePlugin('verification') as PluginVerificationClient;
|
||||
const provider = plugin.smsOTPProviderManager.getProvider(providerType);
|
||||
return provider?.components?.AdminSettingsForm;
|
||||
};
|
||||
|
||||
export const Settings = observer(
|
||||
() => {
|
||||
const form = useForm();
|
||||
const Component = useAdminSettingsForm(form.values.options?.provider);
|
||||
return Component ? <Component /> : null;
|
||||
},
|
||||
{ displayName: 'SMSOTPVerificationSettings' },
|
||||
);
|
||||
|
||||
export const AdminSettingsForm: React.FC = () => {
|
||||
return (
|
||||
<SchemaComponent
|
||||
components={{ Settings }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
provider: {
|
||||
title: tval('Provider', { ns: NAMESPACE }),
|
||||
type: 'string',
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'RemoteSelect',
|
||||
'x-component-props': {
|
||||
manual: false,
|
||||
fieldNames: {
|
||||
label: 'title',
|
||||
value: 'name',
|
||||
},
|
||||
service: {
|
||||
resource: 'smsOTPProviders',
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
'x-component': 'Settings',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ISchema } from '@formily/react';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { VerificationCode } from '../VerificationCode';
|
||||
import { BindFormProps } from '../../verification-manager';
|
||||
|
||||
const schema: ISchema = {
|
||||
type: 'void',
|
||||
name: 'sms-otp',
|
||||
properties: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
'x-component': 'Input',
|
||||
'x-decorator': 'FormItem',
|
||||
title: '{{t("Phone")}}',
|
||||
'x-component-props': {
|
||||
style: {},
|
||||
},
|
||||
},
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: '{{t("Verification code")}}',
|
||||
'x-component': 'VerificationCode',
|
||||
'x-component-props': {
|
||||
targetFieldName: 'phone',
|
||||
actionType: '{{ actionType }}',
|
||||
verificator: '{{ verificator }}',
|
||||
},
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BindForm = (props: BindFormProps) => {
|
||||
const { verificator, actionType } = props;
|
||||
return <SchemaComponent scope={{ verificator, actionType }} schema={schema} components={{ VerificationCode }} />;
|
||||
};
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ISchema } from '@formily/react';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { VerificationCode } from '../VerificationCode';
|
||||
import { VerificationFormProps } from '../../verification-manager';
|
||||
|
||||
const schema: ISchema = {
|
||||
type: 'void',
|
||||
name: 'sms-otp',
|
||||
properties: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
'x-component': 'Input',
|
||||
'x-decorator': 'FormItem',
|
||||
title: '{{t("Phone")}}',
|
||||
'x-component-props': {
|
||||
style: {},
|
||||
},
|
||||
'x-read-pretty': '{{ phone ? true : false }}',
|
||||
default: '{{ phone }}',
|
||||
},
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: '{{t("Verification code")}}',
|
||||
'x-component': 'VerificationCode',
|
||||
'x-use-component-props': 'useVerificationCodeProps',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const VerificationForm = (props: VerificationFormProps) => {
|
||||
const { verificator, actionType, boundInfo, isLogged } = props;
|
||||
return (
|
||||
<SchemaComponent
|
||||
schema={schema}
|
||||
scope={{
|
||||
phone: boundInfo?.publicInfo,
|
||||
useVerificationCodeProps: () => {
|
||||
return {
|
||||
actionType,
|
||||
verificator,
|
||||
isLogged,
|
||||
};
|
||||
},
|
||||
}}
|
||||
components={{ VerificationCode }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { AliyunSettings } from './providers/AliyunSettings';
|
||||
import { TencentSettings } from './providers/TencentSettings';
|
||||
import { VerificationForm } from './VerificationForm';
|
||||
import { AdminSettingsForm } from './AdminSettingsForm';
|
||||
import { BindForm } from './BindForm';
|
||||
|
||||
export const smsOTPVerificationOptions = {
|
||||
components: {
|
||||
VerificationForm,
|
||||
AdminSettingsForm,
|
||||
BindForm,
|
||||
},
|
||||
};
|
||||
|
||||
export const smsAliyunProviderOptions = {
|
||||
components: {
|
||||
AdminSettingsForm: AliyunSettings,
|
||||
},
|
||||
};
|
||||
|
||||
export const smsTencentProviderOptions = {
|
||||
components: {
|
||||
AdminSettingsForm: TencentSettings,
|
||||
},
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
export type SMSOTPProviderOptions = {
|
||||
components: {
|
||||
AdminSettingsForm: ComponentType;
|
||||
};
|
||||
};
|
||||
|
||||
export class SMSOTPProviderManager {
|
||||
providers = new Registry<SMSOTPProviderOptions>();
|
||||
|
||||
registerProvider(type: string, options: SMSOTPProviderOptions) {
|
||||
this.providers.register(type, options);
|
||||
}
|
||||
|
||||
getProvider(type: string) {
|
||||
return this.providers.get(type);
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -7,11 +7,12 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ISchema } from '@formily/react';
|
||||
import { NAMESPACE } from '../../../locale';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
|
||||
import { NAMESPACE } from '../locale';
|
||||
|
||||
export default {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessKeyId: {
|
||||
@@ -19,6 +20,7 @@ export default {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
accessKeySecret: {
|
||||
title: `{{t("Access Key Secret", { ns: "${NAMESPACE}" })}}`,
|
||||
@@ -26,24 +28,32 @@ export default {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': { password: true },
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
title: `{{t("Endpoint", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
sign: {
|
||||
title: `{{t("Sign", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
template: {
|
||||
title: `{{t("Template code", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
} as ISchema;
|
||||
|
||||
export const AliyunSettings: React.FC = () => {
|
||||
return <SchemaComponent schema={schema} />;
|
||||
};
|
||||
+13
-3
@@ -7,11 +7,12 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ISchema } from '@formily/react';
|
||||
import { NAMESPACE } from '../../../locale';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
|
||||
import { NAMESPACE } from '../locale';
|
||||
|
||||
export default {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
secretId: {
|
||||
@@ -19,6 +20,7 @@ export default {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
secretKey: {
|
||||
title: `{{t("Secret Key", { ns: "${NAMESPACE}" })}}`,
|
||||
@@ -26,12 +28,14 @@ export default {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': { password: true },
|
||||
required: true,
|
||||
},
|
||||
region: {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
title: `{{t("Endpoint", { ns: "${NAMESPACE}" })}}`,
|
||||
@@ -51,12 +55,18 @@ export default {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
TemplateId: {
|
||||
title: `{{t("Template Id", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
} as ISchema;
|
||||
|
||||
export const TencentSettings: React.FC = () => {
|
||||
return <SchemaComponent schema={schema} />;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ISchema } from '@formily/react';
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import SMSAliyun from './sms-aliyun';
|
||||
import SMSTencent from './sms-tencent';
|
||||
|
||||
const providerTypes: Registry<ISchema> = new Registry();
|
||||
|
||||
providerTypes.register('sms-aliyun', SMSAliyun);
|
||||
providerTypes.register('sms-tencent', SMSTencent);
|
||||
|
||||
export default providerTypes;
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ISchema } from '@formily/react';
|
||||
|
||||
export const createVerificatorSchema = {
|
||||
type: 'void',
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
title: '{{ t("Add new") }}',
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-decorator': 'FormV2',
|
||||
'x-use-decorator-props': 'useCreateFormProps',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
title: '{{ t("UID") }}',
|
||||
'x-component': 'Input',
|
||||
'x-validator': (value: string) => {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return 'a-z, A-Z, 0-9, _, -';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
title: '{{ t("Title") }}',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
description: {
|
||||
'x-decorator': 'FormItem',
|
||||
type: 'string',
|
||||
title: '{{ t("Description") }}',
|
||||
'x-component': 'Input.TextArea',
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
'x-component': 'Settings',
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer.Footer',
|
||||
properties: {
|
||||
cancel: {
|
||||
title: '{{ t("Cancel") }}',
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useCancelActionProps',
|
||||
},
|
||||
submit: {
|
||||
title: '{{ t("Submit") }}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
},
|
||||
'x-use-component-props': 'useCreateActionProps',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const verficatorsSchema: ISchema = {
|
||||
type: 'void',
|
||||
properties: {
|
||||
card: {
|
||||
type: 'void',
|
||||
'x-component': 'CardItem',
|
||||
'x-component-props': {
|
||||
heightMode: 'fullHeight',
|
||||
},
|
||||
'x-decorator': 'TableBlockProvider',
|
||||
'x-decorator-props': {
|
||||
collection: 'verificators',
|
||||
action: 'list',
|
||||
},
|
||||
properties: {
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
style: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
refresh: {
|
||||
title: "{{t('Refresh')}}",
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useRefreshActionProps',
|
||||
'x-component-props': {
|
||||
icon: 'ReloadOutlined',
|
||||
},
|
||||
},
|
||||
bulkDelete: {
|
||||
title: "{{t('Delete')}}",
|
||||
'x-action': 'destroy',
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useBulkDestroyActionProps',
|
||||
'x-component-props': {
|
||||
icon: 'DeleteOutlined',
|
||||
confirm: {
|
||||
title: "{{t('Delete record')}}",
|
||||
content: "{{t('Are you sure you want to delete it?')}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
add: {
|
||||
type: 'void',
|
||||
'x-component': 'AddNew',
|
||||
title: "{{t('Add new')}}",
|
||||
'x-align': 'right',
|
||||
},
|
||||
},
|
||||
},
|
||||
table: {
|
||||
type: 'array',
|
||||
'x-component': 'TableV2',
|
||||
'x-use-component-props': 'useTableBlockProps',
|
||||
'x-component-props': {
|
||||
rowKey: 'name',
|
||||
rowSelection: {
|
||||
type: 'checkbox',
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
column1: {
|
||||
type: 'void',
|
||||
title: '{{ t("UID") }}',
|
||||
'x-component': 'TableV2.Column',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
column2: {
|
||||
type: 'void',
|
||||
title: '{{ t("Title") }}',
|
||||
'x-component': 'TableV2.Column',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
column3: {
|
||||
type: 'void',
|
||||
title: '{{ t("Verification type") }}',
|
||||
'x-component': 'TableV2.Column',
|
||||
properties: {
|
||||
verificationType: {
|
||||
type: 'string',
|
||||
'x-component': 'Select',
|
||||
'x-read-pretty': true,
|
||||
enum: '{{ types }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
column4: {
|
||||
type: 'void',
|
||||
title: '{{ t("Description") }}',
|
||||
'x-component': 'TableV2.Column',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
'x-component': 'Input.TextArea',
|
||||
'x-component-props': {
|
||||
ellipsis: true,
|
||||
},
|
||||
'x-pattern': 'readPretty',
|
||||
},
|
||||
},
|
||||
},
|
||||
column5: {
|
||||
type: 'void',
|
||||
title: '{{ t("Actions") }}',
|
||||
'x-decorator': 'TableV2.Column.ActionBar',
|
||||
'x-component': 'TableV2.Column',
|
||||
properties: {
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'Space',
|
||||
'x-component-props': {
|
||||
split: '|',
|
||||
},
|
||||
properties: {
|
||||
edit: {
|
||||
type: 'void',
|
||||
title: '{{ t("Edit") }}',
|
||||
'x-action': 'update',
|
||||
'x-component': 'Action.Link',
|
||||
'x-component-props': {
|
||||
openMode: 'drawer',
|
||||
icon: 'EditOutlined',
|
||||
},
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
title: '{{ t("Edit record") }}',
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-decorator': 'FormV2',
|
||||
'x-use-decorator-props': 'useEditFormProps',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
title: '{{ t("Title") }}',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
description: {
|
||||
'x-decorator': 'FormItem',
|
||||
type: 'string',
|
||||
title: '{{ t("Description") }}',
|
||||
'x-component': 'Input.TextArea',
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
'x-component': 'Settings',
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer.Footer',
|
||||
properties: {
|
||||
cancel: {
|
||||
title: '{{ t("Cancel") }}',
|
||||
'x-component': 'Action',
|
||||
'x-use-component-props': 'useCancelActionProps',
|
||||
},
|
||||
submit: {
|
||||
title: '{{ t("Submit") }}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
},
|
||||
'x-use-component-props': 'useEditActionProps',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
destroy: {
|
||||
type: 'void',
|
||||
title: '{{ t("Delete") }}',
|
||||
'x-action': 'destroy',
|
||||
'x-component': 'Action.Link',
|
||||
'x-use-component-props': 'useDestroyActionProps',
|
||||
'x-component-props': {
|
||||
confirm: {
|
||||
title: "{{t('Delete record')}}",
|
||||
content: "{{t('Are you sure you want to delete it?')}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
export type VerificationFormProps = {
|
||||
verificator: string;
|
||||
actionType: string;
|
||||
boundInfo: any;
|
||||
isLogged?: boolean;
|
||||
};
|
||||
|
||||
export type BindFormProps = {
|
||||
verificator: string;
|
||||
actionType: string;
|
||||
isLogged?: boolean;
|
||||
};
|
||||
|
||||
export type VerificationTypeOptions = {
|
||||
components: {
|
||||
AdminSettingsForm: ComponentType;
|
||||
VerificationForm: ComponentType<VerificationFormProps>;
|
||||
BindForm?: ComponentType<BindFormProps>;
|
||||
};
|
||||
};
|
||||
|
||||
export class VerificationManager {
|
||||
verifications = new Registry<VerificationTypeOptions>();
|
||||
|
||||
registerVerificationType(type: string, options: VerificationTypeOptions) {
|
||||
this.verifications.register(type, options);
|
||||
}
|
||||
|
||||
getVerification(type: string) {
|
||||
return this.verifications.get(type);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { connect, mapReadPretty, useField, Schema } from '@formily/react';
|
||||
import { Select, Tag } from 'antd';
|
||||
import { ArrayField } from '@formily/core';
|
||||
import { EllipsisWithTooltip, useAPIClient, useRequest } from '@nocobase/client';
|
||||
import { useVerificationTranslation } from '../locale';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormItem } from '@formily/antd-v5';
|
||||
|
||||
const ReadPretty: React.FC = () => {
|
||||
const field = useField<ArrayField>();
|
||||
return field.value?.length ? (
|
||||
<EllipsisWithTooltip ellipsis={true}>
|
||||
{field.value.map((item) => (
|
||||
<Tag key={item.name}>{item.title}</Tag>
|
||||
))}
|
||||
</EllipsisWithTooltip>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const VerificatorSelect = connect((props) => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const { scene, value, title, onChange } = props;
|
||||
let { multiple } = props;
|
||||
multiple = multiple ? 'multiple' : undefined;
|
||||
const api = useAPIClient();
|
||||
const { data } = useRequest(() =>
|
||||
api
|
||||
.resource('verificators')
|
||||
.listByScene({
|
||||
scene,
|
||||
})
|
||||
.then((res) => res?.data?.data),
|
||||
);
|
||||
const { verificators = [], availableTypes = [] } = (data as any) || {};
|
||||
const options = useMemo(
|
||||
() => verificators?.map((item: { title: string; name: string }) => ({ label: item.title, value: item.name })),
|
||||
[verificators],
|
||||
);
|
||||
return (
|
||||
<FormItem
|
||||
label={title || t('Verificators')}
|
||||
extra={
|
||||
<>
|
||||
{t('The following types of verificators are available:')}
|
||||
{availableTypes.map((item: { title: string }) => Schema.compile(item.title, { t })).join(', ')}
|
||||
{'. '}
|
||||
{t('Go to')} <Link to="/admin/settings/verification">{t('create verificators')}</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select allowClear options={options} value={value} mode={multiple} onChange={onChange} />
|
||||
</FormItem>
|
||||
);
|
||||
}, mapReadPretty(ReadPretty));
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionContextProvider,
|
||||
ExtendCollectionsProvider,
|
||||
SchemaComponent,
|
||||
useAPIClient,
|
||||
useActionContext,
|
||||
useCollection,
|
||||
useCollectionRecordData,
|
||||
useDataBlockRequest,
|
||||
useDataBlockResource,
|
||||
usePlugin,
|
||||
useRequest,
|
||||
} from '@nocobase/client';
|
||||
import { verficatorsSchema, createVerificatorSchema } from '../schemas/verificators';
|
||||
import verificators from '../../collections/verificators';
|
||||
import { useVerificationTranslation } from '../locale';
|
||||
import { Button, Dropdown, App } from 'antd';
|
||||
import { PlusOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { VerificationTypeContext, VerificationTypesContext, useVerificationTypes } from './verification-types';
|
||||
import { Schema, observer, useForm } from '@formily/react';
|
||||
import { createForm } from '@formily/core';
|
||||
import { uid } from '@formily/shared';
|
||||
import PluginVerificationClient from '..';
|
||||
|
||||
const useCreateFormProps = () => {
|
||||
const { type } = useContext(VerificationTypeContext);
|
||||
const form = useMemo(
|
||||
() =>
|
||||
createForm({
|
||||
initialValues: {
|
||||
name: `v_${uid()}`,
|
||||
verificationType: type,
|
||||
},
|
||||
}),
|
||||
[type],
|
||||
);
|
||||
return {
|
||||
form,
|
||||
};
|
||||
};
|
||||
|
||||
const useEditFormProps = () => {
|
||||
const record = useCollectionRecordData();
|
||||
const form = useMemo(
|
||||
() =>
|
||||
createForm({
|
||||
initialValues: record,
|
||||
}),
|
||||
[record],
|
||||
);
|
||||
return {
|
||||
form,
|
||||
};
|
||||
};
|
||||
|
||||
const useCancelActionProps = () => {
|
||||
const { setVisible } = useActionContext();
|
||||
const form = useForm();
|
||||
return {
|
||||
type: 'default',
|
||||
onClick() {
|
||||
setVisible(false);
|
||||
form.reset();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useCreateActionProps = () => {
|
||||
const { setVisible } = useActionContext();
|
||||
const { message } = App.useApp();
|
||||
const form = useForm();
|
||||
const resource = useDataBlockResource();
|
||||
const { refresh } = useDataBlockRequest();
|
||||
const { t } = useVerificationTranslation();
|
||||
|
||||
return {
|
||||
type: 'primary',
|
||||
async onClick() {
|
||||
await form.submit();
|
||||
const values = form.values;
|
||||
await resource.create({
|
||||
values,
|
||||
});
|
||||
refresh();
|
||||
message.success(t('Saved successfully'));
|
||||
setVisible(false);
|
||||
form.reset();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useEditActionProps = () => {
|
||||
const { setVisible } = useActionContext();
|
||||
const { message } = App.useApp();
|
||||
const form = useForm();
|
||||
const resource = useDataBlockResource();
|
||||
const { refresh } = useDataBlockRequest();
|
||||
const collection = useCollection();
|
||||
const filterTk = collection.getFilterTargetKey();
|
||||
const { t } = useVerificationTranslation();
|
||||
|
||||
return {
|
||||
type: 'primary',
|
||||
async onClick() {
|
||||
await form.submit();
|
||||
const values = form.values;
|
||||
await resource.update({
|
||||
values,
|
||||
filterByTk: values[filterTk],
|
||||
});
|
||||
refresh();
|
||||
message.success(t('Saved successfully'));
|
||||
setVisible(false);
|
||||
form.reset();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const AddNew = () => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [type, setType] = useState('');
|
||||
const types = useVerificationTypes();
|
||||
const items = types.map((item) => ({
|
||||
...item,
|
||||
onClick: () => {
|
||||
setVisible(true);
|
||||
setType(item.value);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ visible, setVisible }}>
|
||||
<VerificationTypeContext.Provider value={{ type }}>
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button icon={<PlusOutlined />} type={'primary'}>
|
||||
{t('Add new')} <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<SchemaComponent scope={{ setType, useCreateFormProps }} schema={createVerificatorSchema} />
|
||||
</VerificationTypeContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminSettingsForm = (verificationType: string) => {
|
||||
const plugin = usePlugin('verification') as PluginVerificationClient;
|
||||
const verification = plugin.verificationManager.getVerification(verificationType);
|
||||
return verification?.components?.AdminSettingsForm;
|
||||
};
|
||||
|
||||
export const Settings = observer(
|
||||
() => {
|
||||
const form = useForm();
|
||||
const record = useCollectionRecordData();
|
||||
const Component = useAdminSettingsForm(form.values.verificationType || record.verificationType);
|
||||
return Component ? <Component /> : null;
|
||||
},
|
||||
{ displayName: 'VerificationSettings' },
|
||||
);
|
||||
|
||||
export const Verificators: React.FC = () => {
|
||||
const { t } = useVerificationTranslation();
|
||||
const [types, setTypes] = useState([]);
|
||||
const api = useAPIClient();
|
||||
useRequest(
|
||||
() =>
|
||||
api
|
||||
.resource('verificators')
|
||||
.listTypes()
|
||||
.then((res) => {
|
||||
const types = res?.data?.data || [];
|
||||
return types.map((type: { name: string; title?: string }) => ({
|
||||
key: type.name,
|
||||
label: Schema.compile(type.title || type.name, { t }),
|
||||
value: type.name,
|
||||
}));
|
||||
}),
|
||||
{
|
||||
onSuccess: (types) => {
|
||||
setTypes(types);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<VerificationTypesContext.Provider value={{ types }}>
|
||||
<ExtendCollectionsProvider collections={[verificators]}>
|
||||
<SchemaComponent
|
||||
schema={verficatorsSchema}
|
||||
components={{ AddNew, Settings }}
|
||||
scope={{ types, useEditFormProps, useCancelActionProps, useCreateActionProps, useEditActionProps }}
|
||||
/>
|
||||
</ExtendCollectionsProvider>
|
||||
</VerificationTypesContext.Provider>
|
||||
);
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const VerificationTypeContext = createContext<{
|
||||
type: string;
|
||||
}>({ type: '' });
|
||||
VerificationTypeContext.displayName = 'VerificationTypeContext';
|
||||
|
||||
export const VerificationTypesContext = createContext<{
|
||||
types: {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}>({ types: [] });
|
||||
VerificationTypesContext.displayName = 'VerificationTypesContext';
|
||||
|
||||
export const useVerificationTypes = () => {
|
||||
const { types } = useContext(VerificationTypesContext);
|
||||
return types;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'verificators',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
{
|
||||
type: 'uid',
|
||||
name: 'name',
|
||||
primaryKey: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'verificationType',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
},
|
||||
{
|
||||
type: 'jsonb',
|
||||
name: 'options',
|
||||
},
|
||||
{
|
||||
interface: 'm2m',
|
||||
type: 'belongsToMany',
|
||||
name: 'users',
|
||||
target: 'users',
|
||||
foreignKey: 'verificator',
|
||||
otherKey: 'userId',
|
||||
onDelete: 'CASCADE',
|
||||
sourceKey: 'name',
|
||||
targetKey: 'id',
|
||||
through: 'usersVerificators',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const SMS_OTP_VERIFICATION_TYPE = 'sms-otp';
|
||||
export const PROVIDER_TYPE_SMS_ALIYUN = 'sms-aliyun';
|
||||
export const PROVIDER_TYPE_SMS_TENCENT = 'sms-tencent';
|
||||
@@ -18,5 +18,21 @@
|
||||
"Not a valid cellphone number, please re-enter": "Not a valid cellphone number, please re-enter",
|
||||
"Please don't retry in {{time}} seconds": "Please don't retry in {{time}} seconds",
|
||||
"You are trying so frequently, please slow down": "You are trying so frequently, please slow down",
|
||||
"Verification code is invalid": "Verification code is invalid"
|
||||
"Verification code is invalid": "Verification code is invalid",
|
||||
"SMS OTP": "SMS OTP",
|
||||
"Get one-time codes sent to your phone via SMS to complete authentication requests.": "Get one-time codes sent to your phone via SMS to complete authentication requests.",
|
||||
"Unbind": "Unbind",
|
||||
"Bind": "Bind",
|
||||
"Configured": "Configured",
|
||||
"Unbind verificator": "Unbind verificator",
|
||||
"Not configured": "Not configured",
|
||||
"Unbound successfully": "Unbound successfully",
|
||||
"Bound successfully": "Bound successfully",
|
||||
"Verification type": "Verification type",
|
||||
"Provider": "Provider",
|
||||
"Verificator": "Verificator",
|
||||
"Verificators": "Verificators",
|
||||
"The following types of verificators are available:": "The following types of verifications are available: ",
|
||||
"Go to": "Go to",
|
||||
"create verificators": "create verificators"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"Verification": "验证码",
|
||||
"Verification": "验证",
|
||||
"Verification providers": "验证码提供商",
|
||||
"Provider type": "提供商类型",
|
||||
"Aliyun SMS": "阿里云短信服务",
|
||||
"Tencent SMS": "腾讯云短信服务",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Access Key Secret": "Access Key Secret",
|
||||
"Endpoint": "接入点",
|
||||
@@ -18,5 +19,21 @@
|
||||
"Not a valid cellphone number, please re-enter": "不是有效的手机号,请重新输入",
|
||||
"Please don't retry in {{time}} seconds": "请 {{time}} 秒后再试",
|
||||
"You are trying so frequently, please slow down": "您的操作太频繁,请稍后再试",
|
||||
"Verification code is invalid": "无效的验证码"
|
||||
"Verification code is invalid": "无效的验证码",
|
||||
"SMS OTP": "短信验证码",
|
||||
"Get one-time codes sent to your phone via SMS to complete authentication requests.": "获取一次性短信验证码,以完成身份验证请求。",
|
||||
"Unbind": "解绑",
|
||||
"Bind": "绑定",
|
||||
"Configured": "已配置",
|
||||
"Unbind verificator": "解绑验证器",
|
||||
"Not configured": "未配置",
|
||||
"Unbound successfully": "解绑成功",
|
||||
"Bound successfully": "绑定成功",
|
||||
"Verification type": "验证类型",
|
||||
"Provider": "服务商",
|
||||
"Verificator": "验证器",
|
||||
"Verificators": "验证器",
|
||||
"The following types of verificators are available:": "以下类型的验证器可选:",
|
||||
"Go to": "前往",
|
||||
"create verificators": "创建验证器"
|
||||
}
|
||||
|
||||
@@ -7,86 +7,76 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { Op } from '@nocobase/database';
|
||||
import { HandlerType } from '@nocobase/resourcer';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { Registry } from '@nocobase/utils';
|
||||
|
||||
import { Provider, namespace } from '.';
|
||||
import initActions from './actions';
|
||||
import { CODE_STATUS_UNUSED, CODE_STATUS_USED, PROVIDER_TYPE_SMS_ALIYUN } from './constants';
|
||||
import initProviders from './providers';
|
||||
|
||||
export interface Interceptor {
|
||||
manual?: boolean;
|
||||
expiresIn?: number;
|
||||
|
||||
getReceiver(ctx): string;
|
||||
|
||||
getCode?(ctx): string;
|
||||
|
||||
validate?(ctx: Context, receiver: string): boolean | Promise<boolean>;
|
||||
}
|
||||
import { tval } from '@nocobase/utils';
|
||||
import { namespace } from '.';
|
||||
import { PROVIDER_TYPE_SMS_ALIYUN, PROVIDER_TYPE_SMS_TENCENT } from '../constants';
|
||||
import { VerificationManager } from './verification-manager';
|
||||
import { SMSOTPProviderManager, SMSOTPVerification } from './otp-verification/sms';
|
||||
import { SMS_OTP_VERIFICATION_TYPE } from '../constants';
|
||||
import verificatorsActions from './actions/verificators';
|
||||
import smsAliyun from './otp-verification/sms/providers/sms-aliyun';
|
||||
import smsTencent from './otp-verification/sms/providers/sms-tencent';
|
||||
import smsOTPProviders from './otp-verification/sms/resource/sms-otp-providers';
|
||||
import smsOTP from './otp-verification/sms/resource/sms-otp';
|
||||
|
||||
export default class PluginVerficationServer extends Plugin {
|
||||
providers: Registry<typeof Provider> = new Registry();
|
||||
interceptors: Registry<Interceptor> = new Registry();
|
||||
verificationManager = new VerificationManager({ db: this.db });
|
||||
smsOTPProviderManager = new SMSOTPProviderManager();
|
||||
|
||||
intercept: HandlerType = async (context, next) => {
|
||||
const { resourceName, actionName, values } = context.action.params;
|
||||
const key = `${resourceName}:${actionName}`;
|
||||
const interceptor = this.interceptors.get(key);
|
||||
async load() {
|
||||
// add middleware to action
|
||||
this.app.dataSourceManager.use(this.verificationManager.middleware());
|
||||
this.app.resourceManager.define(smsOTPProviders);
|
||||
this.app.resourceManager.define(smsOTP);
|
||||
|
||||
if (!interceptor) {
|
||||
return context.throw(400);
|
||||
}
|
||||
Object.entries(verificatorsActions).forEach(([action, handler]) =>
|
||||
this.app.resourceManager.registerActionHandler(`verificators:${action}`, handler),
|
||||
);
|
||||
|
||||
const receiver = interceptor.getReceiver(context);
|
||||
const content = interceptor.getCode ? interceptor.getCode(context) : values.code;
|
||||
if (!receiver || !content) {
|
||||
return context.throw(400);
|
||||
}
|
||||
|
||||
// check if code match, then call next
|
||||
// find the code based on action params
|
||||
const VerificationRepo = this.db.getRepository('verifications');
|
||||
const item = await VerificationRepo.findOne({
|
||||
filter: {
|
||||
receiver,
|
||||
type: key,
|
||||
content,
|
||||
expiresAt: {
|
||||
[Op.gt]: new Date(),
|
||||
},
|
||||
status: CODE_STATUS_UNUSED,
|
||||
},
|
||||
this.app.acl.allow('verificators', 'listByUser', 'loggedIn');
|
||||
this.app.acl.allow('verificators', 'listForVerify', 'loggedIn');
|
||||
this.app.acl.allow('verificators', 'bind', 'loggedIn');
|
||||
this.app.acl.allow('verificators', 'unbind', 'loggedIn');
|
||||
this.app.acl.allow('smsOTP', 'create', 'loggedIn');
|
||||
this.app.acl.allow('smsOTP', 'publicCreate');
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}.verificators`,
|
||||
actions: ['verificators:*', 'smsOTPProviders:*'],
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return context.throw(400, {
|
||||
code: 'InvalidVerificationCode',
|
||||
message: context.t('Verification code is invalid', { ns: namespace }),
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: code should be removed if exists in values
|
||||
// context.action.mergeParams({
|
||||
// values: {
|
||||
|
||||
// }
|
||||
// });
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
// or delete
|
||||
await item.update({
|
||||
status: CODE_STATUS_USED,
|
||||
});
|
||||
}
|
||||
};
|
||||
this.verificationManager.registerVerificationType(SMS_OTP_VERIFICATION_TYPE, {
|
||||
title: tval('SMS OTP', { ns: namespace }),
|
||||
description: tval('Get one-time codes sent to your phone via SMS to complete authentication requests.', {
|
||||
ns: namespace,
|
||||
}),
|
||||
bindingRequired: true,
|
||||
verification: SMSOTPVerification,
|
||||
});
|
||||
this.verificationManager.addSceneRule(
|
||||
(scene, verificationType) =>
|
||||
['auth-sms', 'unbind-verificator'].includes(scene) && verificationType === SMS_OTP_VERIFICATION_TYPE,
|
||||
);
|
||||
this.verificationManager.registerAction('verificators:bind', {
|
||||
manual: true,
|
||||
getBoundInfoFromCtx: (ctx) => {
|
||||
return ctx.action.params.values || {};
|
||||
},
|
||||
});
|
||||
this.verificationManager.registerScene('unbind-verificator', {
|
||||
actions: {
|
||||
'verificators:unbind': {},
|
||||
},
|
||||
});
|
||||
this.smsOTPProviderManager.registerProvider(PROVIDER_TYPE_SMS_ALIYUN, {
|
||||
title: tval('Aliyun SMS', { ns: namespace }),
|
||||
provider: smsAliyun,
|
||||
});
|
||||
this.smsOTPProviderManager.registerProvider(PROVIDER_TYPE_SMS_TENCENT, {
|
||||
title: tval('Tencent SMS', { ns: namespace }),
|
||||
provider: smsTencent,
|
||||
});
|
||||
}
|
||||
|
||||
async install() {
|
||||
const {
|
||||
@@ -129,41 +119,4 @@ export default class PluginVerficationServer extends Plugin {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
const { app, db, options } = this;
|
||||
|
||||
await this.importCollections(path.resolve(__dirname, 'collections'));
|
||||
|
||||
await initProviders(this);
|
||||
initActions(this);
|
||||
|
||||
const self = this;
|
||||
// add middleware to action
|
||||
app.resourceManager.use(async function verificationIntercept(context, next) {
|
||||
const { resourceName, actionName, values } = context.action.params;
|
||||
const key = `${resourceName}:${actionName}`;
|
||||
const interceptor = self.interceptors.get(key);
|
||||
if (!interceptor || interceptor.manual) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return self.intercept(context, next);
|
||||
});
|
||||
|
||||
app.acl.allow('verifications', 'create', 'public');
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}.providers`,
|
||||
actions: ['verifications_providers:*'],
|
||||
});
|
||||
}
|
||||
|
||||
async getDefault() {
|
||||
const providerRepo = this.db.getRepository('verifications_providers');
|
||||
return providerRepo.findOne({
|
||||
filter: {
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import Database from '@nocobase/database';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
|
||||
import Plugin, { Provider } from '..';
|
||||
|
||||
import { getApp, sleep } from '.';
|
||||
|
||||
describe('verification > Plugin', () => {
|
||||
let app: MockServer;
|
||||
let agent;
|
||||
let db: Database;
|
||||
let plugin;
|
||||
let AuthorModel;
|
||||
let AuthorRepo;
|
||||
let VerificationModel;
|
||||
let provider;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp();
|
||||
agent = app.agent();
|
||||
db = app.db;
|
||||
plugin = <Plugin>app.pm.get('verification');
|
||||
VerificationModel = db.getCollection('verifications').model;
|
||||
AuthorModel = db.getCollection('authors').model;
|
||||
AuthorRepo = db.getCollection('authors').repository;
|
||||
|
||||
plugin.providers.register('fake', Provider);
|
||||
|
||||
const VerificationProviderModel = db.getCollection('verifications_providers').model;
|
||||
provider = await VerificationProviderModel.create({
|
||||
id: 'fake1',
|
||||
type: 'fake',
|
||||
default: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => app.destroy());
|
||||
|
||||
describe('auto intercept', () => {
|
||||
beforeEach(async () => {
|
||||
plugin.interceptors.register('authors:create', {
|
||||
getReceiver(ctx) {
|
||||
return ctx.action.params.values.phone;
|
||||
},
|
||||
expiresIn: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('submit in time', async () => {
|
||||
const res1 = await agent.resource('authors').create({
|
||||
values: { phone: '1' },
|
||||
});
|
||||
expect(res1.status).toBe(400);
|
||||
|
||||
const res2 = await agent.resource('verifications').create({
|
||||
values: {
|
||||
type: 'authors:create',
|
||||
phone: '1',
|
||||
},
|
||||
});
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.data.id).toBeDefined();
|
||||
expect(res2.body.data.content).toBeUndefined();
|
||||
const expiresAt = Date.parse(res2.body.data.expiresAt);
|
||||
expect(expiresAt - Date.now()).toBeLessThan(2000);
|
||||
|
||||
const res3 = await agent.resource('verifications').create({
|
||||
values: {
|
||||
type: 'authors:create',
|
||||
phone: '1',
|
||||
},
|
||||
});
|
||||
expect(res3.status).toBe(429);
|
||||
|
||||
const verification = await VerificationModel.findByPk(res2.body.data.id);
|
||||
const res4 = await agent.resource('authors').create({
|
||||
values: { phone: '1', code: verification.get('content') },
|
||||
});
|
||||
expect(res4.status).toBe(200);
|
||||
});
|
||||
|
||||
it('expired', async () => {
|
||||
const res1 = await agent.resource('verifications').create({
|
||||
values: {
|
||||
type: 'authors:create',
|
||||
phone: '1',
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
const verification = await VerificationModel.findByPk(res1.body.data.id);
|
||||
const res2 = await agent.resource('authors').create({
|
||||
values: { phone: '1', code: verification.get('content') },
|
||||
});
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manually intercept', () => {
|
||||
beforeEach(async () => {
|
||||
plugin.interceptors.register('authors:create', {
|
||||
manual: true,
|
||||
getReceiver(ctx) {
|
||||
return ctx.action.params.values.phone;
|
||||
},
|
||||
expiresIn: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('will not intercept', async () => {
|
||||
const res1 = await agent.resource('authors').create({
|
||||
values: { phone: '1' },
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
});
|
||||
|
||||
it('will intercept', async () => {
|
||||
app.resourcer.registerActionHandler('authors:create', plugin.intercept);
|
||||
|
||||
const res1 = await agent.resource('authors').create({
|
||||
values: { phone: '1' },
|
||||
});
|
||||
expect(res1.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
beforeEach(async () => {
|
||||
plugin.interceptors.register('authors:create', {
|
||||
getReceiver(ctx) {
|
||||
return ctx.action.params.values.phone;
|
||||
},
|
||||
validate: Boolean,
|
||||
});
|
||||
});
|
||||
|
||||
it('valid', async () => {
|
||||
const res1 = await agent.resource('verifications').create({
|
||||
values: {
|
||||
type: 'authors:create',
|
||||
phone: '1',
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
});
|
||||
|
||||
it('invalid', async () => {
|
||||
const res1 = await agent.resource('verifications').create({
|
||||
values: {
|
||||
type: 'authors:create',
|
||||
phone: '',
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockServer, createMockServer } from '@nocobase/test';
|
||||
import { VerificationManager } from '../../verification-manager';
|
||||
import PluginVerficationServer from '../../Plugin';
|
||||
import { Verification } from '../../verification';
|
||||
|
||||
describe('actions of verificators', async () => {
|
||||
describe('not user related', async () => {
|
||||
let app: MockServer;
|
||||
let agent: any;
|
||||
let manager: VerificationManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['verification'],
|
||||
});
|
||||
agent = app.agent();
|
||||
const plugin = app.pm.get('verification') as PluginVerficationServer;
|
||||
manager = plugin.verificationManager;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('listTypes', async () => {
|
||||
const res = await agent.resource('verificators').listTypes();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual(manager.listTypes());
|
||||
});
|
||||
|
||||
it('listByScene', async () => {
|
||||
const verificator = await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'sms-otp',
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('verificators').listByScene({
|
||||
scene: 'test',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
verificators: [],
|
||||
availableTypes: [],
|
||||
});
|
||||
manager.addSceneRule((scene, verificationType) => scene === 'test' && verificationType === 'sms-otp');
|
||||
const res2 = await agent.resource('verificators').listByScene({
|
||||
scene: 'test',
|
||||
});
|
||||
const verificationType = manager.verificationTypes.get('sms-otp');
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.data).toMatchObject({
|
||||
verificators: [{ name: verificator.name, title: verificator.title }],
|
||||
availableTypes: [{ name: 'sms-otp', title: verificationType.title }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('user related', async () => {
|
||||
let app: MockServer;
|
||||
let agent: any;
|
||||
let manager: VerificationManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
acl: true,
|
||||
plugins: ['verification', 'users', 'field-sort', 'auth'],
|
||||
});
|
||||
agent = await app.agent().login(1);
|
||||
const plugin = app.pm.get('verification') as PluginVerficationServer;
|
||||
manager = plugin.verificationManager;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('listByUser', async () => {
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
bindingRequired: true,
|
||||
verification: class extends Verification {
|
||||
async verify() {}
|
||||
async getPublicBoundInfo() {
|
||||
return {
|
||||
bound: true,
|
||||
publicInfo: 'test-uuid',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
const verificators = await app.db.getRepository('verificators').create({
|
||||
values: [
|
||||
{
|
||||
name: 'test',
|
||||
verificationType: 'test',
|
||||
},
|
||||
{
|
||||
name: 'sms-otp',
|
||||
verificationType: 'sms-otp',
|
||||
},
|
||||
],
|
||||
});
|
||||
await verificators[0].addUser(1, {
|
||||
through: {
|
||||
uuid: 'test-uuid',
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('verificators').listByUser();
|
||||
const smsOTP = manager.verificationTypes.get('sms-otp');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject([
|
||||
{
|
||||
title: smsOTP.title,
|
||||
name: 'sms-otp',
|
||||
boundInfo: {
|
||||
bound: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Test',
|
||||
name: 'test',
|
||||
boundInfo: {
|
||||
bound: true,
|
||||
publicInfo: 'test-uuid',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('listForVerify', async () => {
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
bindingRequired: true,
|
||||
verification: class extends Verification {
|
||||
async verify() {}
|
||||
async getPublicBoundInfo() {
|
||||
return {
|
||||
bound: true,
|
||||
publicInfo: 'test-uuid',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
manager.addSceneRule((scene, verificationType) => scene === 'unbind-verificator' && verificationType === 'test');
|
||||
const verificators = await app.db.getRepository('verificators').create({
|
||||
values: [
|
||||
{
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'test',
|
||||
},
|
||||
{
|
||||
name: 'sms-otp',
|
||||
verificationType: 'sms-otp',
|
||||
},
|
||||
],
|
||||
});
|
||||
await verificators[0].addUser(1, {
|
||||
through: {
|
||||
uuid: 'test-uuid',
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('verificators').listForVerify({
|
||||
scene: 'unbind-verificator',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject([
|
||||
{
|
||||
title: 'Test',
|
||||
name: 'test',
|
||||
verificationType: 'test',
|
||||
verificationTypeTitle: 'Test',
|
||||
boundInfo: {
|
||||
bound: true,
|
||||
publicInfo: 'test-uuid',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('bind', async () => {
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
bindingRequired: true,
|
||||
verification: class extends Verification {
|
||||
async verify() {}
|
||||
async bind() {
|
||||
return {
|
||||
uuid: 'test-uuid',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'test',
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('verificators').bind({
|
||||
values: {
|
||||
verificator: 'invalid',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error.text).toBe('Invalid verificator');
|
||||
const res1 = await agent.resource('verificators').bind({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
const record = await manager.getBoundRecord(1, 'test');
|
||||
expect(record).toBeDefined();
|
||||
expect(record.uuid).toBe('test-uuid');
|
||||
expect(record.verificator).toBe('test');
|
||||
expect(record.userId).toBe(1);
|
||||
const res2 = await agent.resource('verificators').bind({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
});
|
||||
expect(res2.status).toBe(400);
|
||||
expect(res2.error.text).toBe('You have already bound this verificator');
|
||||
});
|
||||
|
||||
it('unbind', async () => {
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
bindingRequired: true,
|
||||
verification: class extends Verification {
|
||||
async verify() {}
|
||||
async bind() {
|
||||
return {
|
||||
uuid: 'test-uuid',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
manager.addSceneRule((scene, verificationType) => scene === 'unbind-verificator' && verificationType === 'test');
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'test',
|
||||
},
|
||||
});
|
||||
await agent.resource('verificators').bind({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('verificators').unbind({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
unbindVerificator: 'invalid',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error.text).toBe('Invalid verificator');
|
||||
const res1 = await agent.resource('verificators').unbind({
|
||||
values: {
|
||||
unbindVerificator: 'test',
|
||||
verificator: 'test',
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
const record = await manager.getBoundRecord(1, 'test');
|
||||
expect(record).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockServer, createMockServer } from '@nocobase/test';
|
||||
|
||||
import { ApplicationOptions } from '@nocobase/server';
|
||||
import authors from './collections/authors';
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
interface MockAppOptions extends ApplicationOptions {
|
||||
manual?: boolean;
|
||||
}
|
||||
|
||||
export async function getApp(options: MockAppOptions = {}): Promise<MockServer> {
|
||||
const app = await createMockServer({
|
||||
...options,
|
||||
async beforeInstall(app) {
|
||||
app.db.collection(authors);
|
||||
},
|
||||
plugins: ['verification'],
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
|
||||
import Migration from '../../migrations/20250111192640-providers2verificators';
|
||||
|
||||
describe('providers to verificators', () => {
|
||||
let app: MockServer;
|
||||
let db: MockDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
version: '1.6.0',
|
||||
plugins: ['verification', 'auth', 'field-sort'],
|
||||
});
|
||||
db = app.db;
|
||||
await db.getRepository('verifications_providers').create({
|
||||
values: [
|
||||
{
|
||||
id: 'test1',
|
||||
title: 'Test1',
|
||||
type: 'sms-aliyun',
|
||||
options: {
|
||||
accessKeyId: 'test',
|
||||
},
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'test2',
|
||||
title: 'Test2',
|
||||
type: 'sms-tencent',
|
||||
options: {
|
||||
accessKeyId: 'test',
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
context: {},
|
||||
});
|
||||
await db.getRepository('authenticators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
authType: 'SMS',
|
||||
title: 'SMS',
|
||||
options: {
|
||||
public: {
|
||||
allowSignUp: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should migrate providers to verificators', async () => {
|
||||
const migration = new Migration({
|
||||
db: db,
|
||||
// @ts-ignore
|
||||
app,
|
||||
});
|
||||
await migration.up();
|
||||
const verificators = await db.getRepository('verificators').find();
|
||||
expect(verificators.length).toBe(2);
|
||||
expect(verificators).toMatchObject(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Test1',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'sms-aliyun',
|
||||
settings: {
|
||||
accessKeyId: 'test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: 'Test2',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'sms-tencent',
|
||||
settings: {
|
||||
accessKeyId: 'test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const authenticator = await db.getRepository('authenticators').findOne({
|
||||
filter: {
|
||||
name: 'test',
|
||||
},
|
||||
});
|
||||
expect(authenticator.options).toMatchObject({
|
||||
public: {
|
||||
allowSignUp: true,
|
||||
verificator: verificators.find((item) => item.title === 'Test1').name,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockServer, createMockServer } from '@nocobase/test';
|
||||
import PluginVerficationServer from '../../Plugin';
|
||||
import { VerificationManager } from '../../verification-manager';
|
||||
import { SMSProvider } from '../../otp-verification/sms/providers';
|
||||
|
||||
class MockSMSProvider extends SMSProvider {
|
||||
async send() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('verify', async () => {
|
||||
let app: MockServer;
|
||||
let manager: VerificationManager;
|
||||
let agent: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['verification'],
|
||||
});
|
||||
agent = app.agent();
|
||||
app.resourceManager.define({
|
||||
name: 'test',
|
||||
actions: {
|
||||
verify: async (ctx, next) => {
|
||||
ctx.body = {};
|
||||
await next();
|
||||
},
|
||||
},
|
||||
});
|
||||
const plugin = app.pm.get('verification') as PluginVerficationServer;
|
||||
manager = plugin.verificationManager;
|
||||
plugin.smsOTPProviderManager.registerProvider('mock', {
|
||||
title: 'Mock',
|
||||
provider: MockSMSProvider,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should create sms otp record', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: async (ctx) => ctx.action.params.values || {},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'mock',
|
||||
settings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('smsOTP').create({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
action: 'test:verify',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const record = await app.db.getRepository('otpRecords').findOne({
|
||||
filter: {
|
||||
action: 'test:verify',
|
||||
receiver: '13888888888',
|
||||
verificatorName: 'test',
|
||||
},
|
||||
});
|
||||
expect(record).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rate limit', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: async (ctx) => ctx.action.params.values || {},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'mock',
|
||||
settings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('smsOTP').create({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
action: 'test:verify',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const res1 = await agent.resource('smsOTP').create({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
action: 'test:verify',
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(429);
|
||||
});
|
||||
|
||||
it('verify failed', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: async (ctx) => ctx.action.params.values || {},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'mock',
|
||||
settings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('smsOTP').create({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
action: 'test:verify',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await app.db.getRepository('otpRecords').findOne({
|
||||
filter: {
|
||||
action: 'test:verify',
|
||||
receiver: '13888888888',
|
||||
verificatorName: 'test',
|
||||
},
|
||||
});
|
||||
const res1 = await agent.resource('test').verify({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(400);
|
||||
expect(res1.error.text).toBe('Verification code is invalid');
|
||||
const res2 = await agent.resource('test').verify({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
code: '123456',
|
||||
},
|
||||
});
|
||||
expect(res2.status).toBe(400);
|
||||
expect(res2.error.text).toBe('Verification code is invalid');
|
||||
});
|
||||
|
||||
it('verify', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: async (ctx) => ctx.action.params.values || {},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'mock',
|
||||
settings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('smsOTP').create({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
action: 'test:verify',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const record = await app.db.getRepository('otpRecords').findOne({
|
||||
filter: {
|
||||
action: 'test:verify',
|
||||
receiver: '13888888888',
|
||||
verificatorName: 'test',
|
||||
},
|
||||
});
|
||||
expect(record.status).toBe(0);
|
||||
const res1 = await agent.resource('test').verify({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
code: record.code,
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
const record2 = await app.db.getRepository('otpRecords').findOne({
|
||||
filter: {
|
||||
action: 'test:verify',
|
||||
receiver: '13888888888',
|
||||
verificatorName: 'test',
|
||||
},
|
||||
});
|
||||
expect(record2.status).toBe(1);
|
||||
});
|
||||
|
||||
it('verify expire', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: async (ctx) => ctx.action.params.values || {},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
verificationType: 'sms-otp',
|
||||
options: {
|
||||
provider: 'mock',
|
||||
settings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await agent.resource('smsOTP').create({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
action: 'test:verify',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const record = await app.db.getRepository('otpRecords').findOne({
|
||||
filter: {
|
||||
action: 'test:verify',
|
||||
receiver: '13888888888',
|
||||
verificatorName: 'test',
|
||||
},
|
||||
});
|
||||
await record.update({
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
const res1 = await agent.resource('test').verify({
|
||||
values: {
|
||||
verificator: 'test',
|
||||
uuid: '13888888888',
|
||||
code: record.code,
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(400);
|
||||
expect(res1.error.text).toBe('Verification code is invalid');
|
||||
});
|
||||
});
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockServer, createMockServer } from '@nocobase/test';
|
||||
import { VerificationManager } from '../../verification-manager';
|
||||
import { Verification } from '../../verification';
|
||||
import PluginVerficationServer from '../../Plugin';
|
||||
|
||||
class MockVerfication extends Verification {
|
||||
async verify({
|
||||
resource,
|
||||
action,
|
||||
boundInfo,
|
||||
verifyParams,
|
||||
}: {
|
||||
resource: any;
|
||||
action: any;
|
||||
boundInfo: any;
|
||||
verifyParams: any;
|
||||
}): Promise<any> {}
|
||||
getBoundInfo(userId: number): Promise<any> {
|
||||
return Promise.resolve({
|
||||
key: 'value',
|
||||
});
|
||||
}
|
||||
validateBoundInfo(boundInfo: any) {
|
||||
if (boundInfo?.key !== 'value') {
|
||||
throw new Error('Invalid bound info');
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onActionComplete({ verifyResult }) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('action and verify', async () => {
|
||||
let app: MockServer;
|
||||
let manager: VerificationManager;
|
||||
let agent: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['verification'],
|
||||
});
|
||||
agent = app.agent();
|
||||
app.resourceManager.define({
|
||||
name: 'test',
|
||||
actions: {
|
||||
async verify(ctx, next) {
|
||||
ctx.body = {};
|
||||
await next();
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.db.getRepository('verificators').create({
|
||||
values: {
|
||||
name: 'test',
|
||||
verificationType: 'test',
|
||||
},
|
||||
});
|
||||
const plugin = app.pm.get('verification') as PluginVerficationServer;
|
||||
manager = plugin.verificationManager;
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
verification: MockVerfication,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await app.db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should check action', async () => {
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'invalid',
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid action');
|
||||
}
|
||||
});
|
||||
|
||||
it('should check verificator', async () => {
|
||||
manager.registerAction('test:verify', {});
|
||||
expect.assertions(2);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid verificator');
|
||||
}
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid verificator');
|
||||
}
|
||||
});
|
||||
|
||||
it('should check verificator for scene, check by type', async () => {
|
||||
manager.registerScene('test', {
|
||||
actions: {
|
||||
'test:verify': {
|
||||
getUserIdFromCtx: () => 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid verificator');
|
||||
}
|
||||
manager.addSceneRule((scene, type) => scene === 'test' && type === 'test');
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
});
|
||||
|
||||
it('should check verificator for scene, check by verificators', async () => {
|
||||
manager.registerScene('test', {
|
||||
getVerificators: async () => ['valid'],
|
||||
actions: {
|
||||
'test:verify': {
|
||||
getUserIdFromCtx: () => 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
manager.registerScene('test2', {
|
||||
getVerificators: async () => ['test'],
|
||||
actions: {
|
||||
'test:verify2': {
|
||||
getUserIdFromCtx: () => 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
manager.addSceneRule((scene, type) => ['test', 'test2'].includes(scene) && type === 'test');
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid verificator');
|
||||
}
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify2',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
});
|
||||
|
||||
it('should check verify params', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getVerifyParams: async () => null,
|
||||
});
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid verify params');
|
||||
}
|
||||
});
|
||||
|
||||
it('get bound info from context of action', async () => {
|
||||
const fn = vi.fn();
|
||||
fn.mockResolvedValue({
|
||||
key: 'value',
|
||||
});
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: fn,
|
||||
});
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(fn).toBeCalled();
|
||||
});
|
||||
|
||||
it('get get user id from context of action', async () => {
|
||||
const fn = vi.fn();
|
||||
fn.mockResolvedValue(1);
|
||||
manager.registerAction('test:verify', {
|
||||
getUserIdFromCtx: fn,
|
||||
});
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(fn).toBeCalled();
|
||||
});
|
||||
|
||||
it('should check user id', async () => {
|
||||
manager.registerAction('test:verify', {});
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid user id');
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate bound info', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
getBoundInfoFromCtx: async () => null,
|
||||
});
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid bound info');
|
||||
}
|
||||
});
|
||||
|
||||
it('should verify', async () => {
|
||||
manager.registerAction('test:verify', {});
|
||||
const spy = vi.spyOn(MockVerfication.prototype, 'verify');
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(spy).toBeCalled();
|
||||
});
|
||||
|
||||
it('on verify success', async () => {
|
||||
const fn = vi.fn();
|
||||
manager.registerAction('test:verify', {
|
||||
onVerifySuccess: fn,
|
||||
});
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(fn).toBeCalled();
|
||||
});
|
||||
|
||||
it('on verify fail', async () => {
|
||||
const fn = vi.fn();
|
||||
manager.registerAction('test:verify', {
|
||||
onVerifyFail: fn,
|
||||
});
|
||||
vi.spyOn(MockVerfication.prototype, 'verify').mockRejectedValue(new Error('Test error'));
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(fn).toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('on action complete', async () => {
|
||||
const spy = vi.spyOn(MockVerfication.prototype, 'onActionComplete');
|
||||
manager.registerAction('test:verify', {});
|
||||
await manager.verify(
|
||||
{
|
||||
app,
|
||||
db: app.db,
|
||||
action: {
|
||||
resourceName: 'test',
|
||||
actionName: 'verify',
|
||||
params: {
|
||||
values: {
|
||||
verificator: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
throw: app.context.throw,
|
||||
} as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(spy).toBeCalled();
|
||||
});
|
||||
|
||||
it('auto verify', async () => {
|
||||
manager.registerAction('test:verify', {});
|
||||
const spy = vi.spyOn(manager, 'verify');
|
||||
await agent.resource('test').verify();
|
||||
expect(spy).toBeCalled();
|
||||
});
|
||||
|
||||
it('manual verify', async () => {
|
||||
manager.registerAction('test:verify', {
|
||||
manual: true,
|
||||
});
|
||||
const spy = vi.spyOn(manager, 'verify');
|
||||
await agent.resource('test').verify();
|
||||
expect(spy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Verification } from '../../verification';
|
||||
import { VerificationManager } from '../../verification-manager';
|
||||
|
||||
class MockVerification extends Verification {
|
||||
async verify() {}
|
||||
}
|
||||
|
||||
describe('verification-manager, register and get', async () => {
|
||||
let manager: VerificationManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
manager = new VerificationManager({ db: {} as any });
|
||||
});
|
||||
|
||||
it('register verification type', async () => {
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
description: 'Test description',
|
||||
verification: MockVerification,
|
||||
});
|
||||
const options = manager.verificationTypes.get('test');
|
||||
expect(options.title).toBe('Test');
|
||||
expect(options.description).toBe('Test description');
|
||||
expect(options.verification).toBe(MockVerification);
|
||||
const verification = manager.getVerification('test');
|
||||
expect(verification).toBe(MockVerification);
|
||||
});
|
||||
|
||||
it('list types', async () => {
|
||||
manager.registerVerificationType('test1', {
|
||||
title: 'Test1',
|
||||
verification: MockVerification,
|
||||
});
|
||||
manager.registerVerificationType('test2', {
|
||||
title: 'Test2',
|
||||
verification: MockVerification,
|
||||
});
|
||||
const types = manager.listTypes();
|
||||
expect(types).toEqual([
|
||||
{ name: 'test1', title: 'Test1' },
|
||||
{ name: 'test2', title: 'Test2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('add scene rule', async () => {
|
||||
manager.registerVerificationType('test', {
|
||||
title: 'Test',
|
||||
verification: MockVerification,
|
||||
});
|
||||
manager.addSceneRule((scene, verificationType) => scene === 'testScene' && verificationType === 'test');
|
||||
const types = manager.getVerificationTypesByScene('testScene');
|
||||
expect(types).toEqual([{ type: 'test', title: 'Test' }]);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import * as verifications from './verifications';
|
||||
|
||||
function make(name, mod) {
|
||||
return Object.keys(mod).reduce(
|
||||
(result, key) => ({
|
||||
...result,
|
||||
[`${name}:${key}`]: mod[key],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export default function ({ app }) {
|
||||
app.actions({
|
||||
...make('verifications', verifications),
|
||||
});
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import actions, { Context, Next } from '@nocobase/actions';
|
||||
import { Op } from '@nocobase/database';
|
||||
import dayjs from 'dayjs';
|
||||
import { randomInt, randomUUID } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import Plugin, { namespace } from '..';
|
||||
import { CODE_STATUS_UNUSED } from '../constants';
|
||||
|
||||
const asyncRandomInt = promisify(randomInt);
|
||||
|
||||
export async function create(context: Context, next: Next) {
|
||||
const plugin = context.app.getPlugin('verification') as Plugin;
|
||||
|
||||
const { values } = context.action.params;
|
||||
const interceptor = plugin.interceptors.get(values?.type);
|
||||
if (!interceptor) {
|
||||
return context.throw(400, 'Invalid action type');
|
||||
}
|
||||
|
||||
const providerItem = await plugin.getDefault();
|
||||
if (!providerItem) {
|
||||
console.error(`[verification] no provider for action (${values.type}) provided`);
|
||||
return context.throw(500);
|
||||
}
|
||||
|
||||
const receiver = interceptor.getReceiver(context);
|
||||
if (!receiver) {
|
||||
return context.throw(400, {
|
||||
code: 'InvalidReceiver',
|
||||
message: context.t('Not a valid cellphone number, please re-enter', { ns: namespace }),
|
||||
});
|
||||
}
|
||||
const VerificationModel = context.db.getModel('verifications');
|
||||
const record = await VerificationModel.findOne({
|
||||
where: {
|
||||
type: values.type,
|
||||
receiver,
|
||||
status: CODE_STATUS_UNUSED,
|
||||
expiresAt: {
|
||||
[Op.gt]: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (record) {
|
||||
const seconds = dayjs(record.get('expiresAt')).diff(dayjs(), 'seconds');
|
||||
// return context.throw(429, { code: 'RateLimit', message: context.t('Please don\'t retry in {{time}}', { time: moment().locale('zh').to(record.get('expiresAt')) }) });
|
||||
return context.throw(429, {
|
||||
code: 'RateLimit',
|
||||
message: context.t("Please don't retry in {{time}} seconds", { time: seconds, ns: namespace }),
|
||||
});
|
||||
}
|
||||
|
||||
const code = (<number>await asyncRandomInt(999999)).toString(10).padStart(6, '0');
|
||||
if (interceptor.validate) {
|
||||
try {
|
||||
await interceptor.validate(context, receiver);
|
||||
} catch (err) {
|
||||
return context.throw(400, { code: 'InvalidReceiver', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const ProviderType = plugin.providers.get(<string>providerItem.get('type'));
|
||||
const provider = new ProviderType(plugin, providerItem.get('options'));
|
||||
|
||||
try {
|
||||
await provider.send(receiver, { code });
|
||||
console.log('verification code sent');
|
||||
} catch (error) {
|
||||
switch (error.name) {
|
||||
case 'InvalidReceiver':
|
||||
// TODO: message should consider email and other providers, maybe use "receiver"
|
||||
return context.throw(400, {
|
||||
code: 'InvalidReceiver',
|
||||
message: context.t('Not a valid cellphone number, please re-enter', { ns: namespace }),
|
||||
});
|
||||
case 'RateLimit':
|
||||
return context.throw(429, context.t('You are trying so frequently, please slow down', { ns: namespace }));
|
||||
default:
|
||||
console.error(error);
|
||||
return context.throw(
|
||||
500,
|
||||
context.t('Verification send failed, please try later or contact to administrator', { ns: namespace }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: randomUUID(),
|
||||
type: values.type,
|
||||
receiver,
|
||||
content: code,
|
||||
expiresAt: Date.now() + (interceptor.expiresIn ?? 60) * 1000,
|
||||
status: CODE_STATUS_UNUSED,
|
||||
providerId: providerItem.get('id'),
|
||||
};
|
||||
|
||||
context.action.mergeParams(
|
||||
{
|
||||
values: data,
|
||||
},
|
||||
{
|
||||
values: 'overwrite',
|
||||
},
|
||||
);
|
||||
|
||||
await actions.create(context, async () => {
|
||||
const { body: result } = context;
|
||||
context.body = {
|
||||
id: result.id,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import PluginVerificationServer from '../Plugin';
|
||||
import pkg from '../../../package.json';
|
||||
|
||||
export default {
|
||||
listTypes: async (ctx: Context, next: Next) => {
|
||||
const plugin = ctx.app.pm.get('verification') as PluginVerificationServer;
|
||||
ctx.body = plugin.verificationManager.listTypes();
|
||||
await next();
|
||||
},
|
||||
listByScene: async (ctx: Context, next: Next) => {
|
||||
const { scene } = ctx.action.params || {};
|
||||
const plugin = ctx.app.pm.get('verification') as PluginVerificationServer;
|
||||
const verificationTypes = plugin.verificationManager.getVerificationTypesByScene(scene);
|
||||
if (!verificationTypes.length) {
|
||||
ctx.body = { verificators: [], availableTypes: [] };
|
||||
return next();
|
||||
}
|
||||
const verificators = await ctx.db.getRepository('verificators').find({
|
||||
filter: {
|
||||
verificationType: verificationTypes.map((item) => item.type),
|
||||
},
|
||||
});
|
||||
ctx.body = {
|
||||
verificators: (verificators || []).map((item: { name: string; title: string }) => ({
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
})),
|
||||
availableTypes: verificationTypes.map((item) => ({
|
||||
name: item.type,
|
||||
title: item.title,
|
||||
})),
|
||||
};
|
||||
await next();
|
||||
},
|
||||
listByUser: async (ctx: Context, next: Next) => {
|
||||
const plugin = ctx.app.pm.get('verification') as PluginVerificationServer;
|
||||
const verificationTypes = plugin.verificationManager.verificationTypes;
|
||||
const bindingRequiredTypes = Array.from(verificationTypes.getEntities())
|
||||
.filter(([, options]) => options.bindingRequired)
|
||||
.map(([type]) => type);
|
||||
const verificators = await ctx.db.getRepository('verificators').find({
|
||||
filter: {
|
||||
verificationType: bindingRequiredTypes,
|
||||
},
|
||||
});
|
||||
const result = [];
|
||||
for (const verificator of verificators) {
|
||||
try {
|
||||
const verificationType = plugin.verificationManager.verificationTypes.get(verificator.verificationType);
|
||||
const Verification = plugin.verificationManager.getVerification(verificator.verificationType);
|
||||
const verification = new Verification({ ctx, verificator, options: verificator.options });
|
||||
const boundInfo = await verification.getPublicBoundInfo(ctx.auth.user.id);
|
||||
result.push({
|
||||
...verificator.dataValues,
|
||||
title: verificator.title || verificationType.title,
|
||||
description: verificator.description || verificationType.description,
|
||||
boundInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(error);
|
||||
}
|
||||
}
|
||||
ctx.body = result;
|
||||
await next();
|
||||
},
|
||||
listForVerify: async (ctx: Context, next: Next) => {
|
||||
const { scene } = ctx.action.params || {};
|
||||
const plugin = ctx.app.pm.get('verification') as PluginVerificationServer;
|
||||
const verificationTypes = plugin.verificationManager.getVerificationTypesByScene(scene);
|
||||
if (!verificationTypes.length) {
|
||||
ctx.body = [];
|
||||
return next();
|
||||
}
|
||||
const verificators = await ctx.db.getRepository('verificators').find({
|
||||
filter: {
|
||||
verificationType: verificationTypes.map((item) => item.type),
|
||||
},
|
||||
});
|
||||
if (!verificators.length) {
|
||||
ctx.body = [];
|
||||
return next();
|
||||
}
|
||||
const result = [];
|
||||
for (const verificator of verificators) {
|
||||
const verificationType = plugin.verificationManager.verificationTypes.get(verificator.verificationType);
|
||||
const Verification = plugin.verificationManager.getVerification(verificator.verificationType);
|
||||
const verification = new Verification({ ctx, verificator, options: verificator.options });
|
||||
const publicBoundInfo = await verification.getPublicBoundInfo(ctx.auth.user.id);
|
||||
if (!publicBoundInfo?.bound) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
name: verificator.name,
|
||||
title: verificator.title,
|
||||
verificationType: verificator.verificationType,
|
||||
verificationTypeTitle: verificationType?.title,
|
||||
boundInfo: publicBoundInfo,
|
||||
});
|
||||
}
|
||||
ctx.body = result;
|
||||
await next();
|
||||
},
|
||||
bind: async (ctx: Context, next: Next) => {
|
||||
const { verificator: name } = ctx.action.params.values || {};
|
||||
const user = ctx.auth.user;
|
||||
const verificationPlugin = ctx.app.pm.get('verification') as PluginVerificationServer;
|
||||
const record = await verificationPlugin.verificationManager.getBoundRecord(user.id, name);
|
||||
if (record) {
|
||||
return ctx.throw(400, ctx.t('You have already bound this verificator', { ns: pkg.name }));
|
||||
}
|
||||
const verificator = await verificationPlugin.verificationManager.getVerificator(name);
|
||||
if (!verificator) {
|
||||
return ctx.throw(400, ctx.t('Invalid verificator'));
|
||||
}
|
||||
const Verification = verificationPlugin.verificationManager.getVerification(verificator.verificationType);
|
||||
const verification = new Verification({ ctx, verificator, options: verificator.options });
|
||||
const { uuid, meta } = await verification.bind(user.id);
|
||||
await verificator.addUser(user.id, {
|
||||
through: {
|
||||
uuid,
|
||||
meta,
|
||||
},
|
||||
});
|
||||
ctx.body = {};
|
||||
await next();
|
||||
},
|
||||
unbind: async (ctx: Context, next: Next) => {
|
||||
const { unbindVerificator: name } = ctx.action.params.values || {};
|
||||
const user = ctx.auth.user;
|
||||
const verificationPlugin = ctx.app.pm.get('verification') as PluginVerificationServer;
|
||||
const verificator = await verificationPlugin.verificationManager.getVerificator(name);
|
||||
if (!verificator) {
|
||||
return ctx.throw(400, ctx.t('Invalid verificator'));
|
||||
}
|
||||
await verificator.removeUser(user.id);
|
||||
await next();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dumpRules: {
|
||||
group: 'log',
|
||||
},
|
||||
migrationRules: ['schema-only', 'skip'],
|
||||
name: 'otpRecords',
|
||||
shared: true,
|
||||
fields: [
|
||||
{
|
||||
type: 'uuid',
|
||||
name: 'id',
|
||||
primaryKey: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'action',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'receiver',
|
||||
},
|
||||
{
|
||||
type: 'integer',
|
||||
name: 'status',
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
type: 'unixTimestamp',
|
||||
name: 'expiresAt',
|
||||
accuracy: 'millisecond',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'code',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'verificator',
|
||||
target: 'verificators',
|
||||
targetKey: 'name',
|
||||
},
|
||||
],
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
/**
|
||||
* Collection for user information of extended authentication methods,
|
||||
* such as saml, oicd, oauth, sms, etc.
|
||||
*/
|
||||
export default defineCollection({
|
||||
migrationRules: ['schema-only', 'overwrite', 'skip'],
|
||||
name: 'usersVerificators',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
logging: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'uuid',
|
||||
interface: 'input',
|
||||
type: 'string',
|
||||
allowNull: false,
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("UUID")}}',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'meta',
|
||||
defaultValue: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
+6
-14
@@ -7,18 +7,10 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
import verificators from '../../collections/verificators';
|
||||
|
||||
export default {
|
||||
name: 'authors',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'phone',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
||||
export default defineCollection({
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
...verificators,
|
||||
});
|
||||
@@ -7,8 +7,5 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const PROVIDER_TYPE_SMS_ALIYUN = 'sms-aliyun';
|
||||
export const PROVIDER_TYPE_SMS_TENCENT = 'sms-tencent';
|
||||
|
||||
export const CODE_STATUS_UNUSED = 0;
|
||||
export const CODE_STATUS_USED = 1;
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
|
||||
// @ts-ignore
|
||||
import { name } from '../../package.json';
|
||||
export { Interceptor, default } from './Plugin';
|
||||
export { default } from './Plugin';
|
||||
export * from './constants';
|
||||
export { Provider } from './providers/Provider';
|
||||
export { SMSOTPVerification } from './otp-verification/sms';
|
||||
export { Verification } from './verification';
|
||||
export { VerificationManager } from './verification-manager';
|
||||
export { SMSProvider } from './otp-verification/sms/providers';
|
||||
|
||||
export const namespace = name;
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Migration } from '@nocobase/server';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { SMS_OTP_VERIFICATION_TYPE } from '../../constants';
|
||||
|
||||
export default class extends Migration {
|
||||
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
|
||||
appVersion = '<1.6.1';
|
||||
|
||||
async up() {
|
||||
const providers = await this.db.getRepository('verifications_providers').find();
|
||||
const verificators = [];
|
||||
let defaultVerificator: any;
|
||||
providers.forEach((provider: any) => {
|
||||
const verificator = {
|
||||
name: `v_${uid()}`,
|
||||
title: provider.title,
|
||||
verificationType: SMS_OTP_VERIFICATION_TYPE,
|
||||
options: {
|
||||
provider: provider.type,
|
||||
settings: provider.options,
|
||||
},
|
||||
};
|
||||
verificators.push(verificator);
|
||||
if (provider.default) {
|
||||
defaultVerificator = verificator;
|
||||
}
|
||||
});
|
||||
const smsAuth = await this.db.getRepository('authenticators').find({
|
||||
filter: {
|
||||
authType: 'SMS',
|
||||
},
|
||||
});
|
||||
await this.db.sequelize.transaction(async (transaction) => {
|
||||
const verificatorModel = this.db.getModel('verificators');
|
||||
await verificatorModel.bulkCreate(verificators, { transaction });
|
||||
for (const item of smsAuth) {
|
||||
await item.update(
|
||||
{
|
||||
options: {
|
||||
...item.options,
|
||||
public: {
|
||||
...item.options?.public,
|
||||
verificator: defaultVerificator.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Verification } from '../verification';
|
||||
import { CODE_STATUS_UNUSED, CODE_STATUS_USED } from '../constants';
|
||||
import pkg from '../../../package.json';
|
||||
|
||||
export class OTPVerification extends Verification {
|
||||
expiresIn = 120;
|
||||
|
||||
async verify({ resource, action, boundInfo, verifyParams }): Promise<any> {
|
||||
const { uuid: receiver } = boundInfo;
|
||||
const code = verifyParams.code;
|
||||
if (!code) {
|
||||
return this.ctx.throw(400, 'Verification code is invalid');
|
||||
}
|
||||
const VerificationRepo = this.ctx.db.getRepository('otpRecords');
|
||||
const item = await VerificationRepo.findOne({
|
||||
filter: {
|
||||
receiver,
|
||||
action: `${resource}:${action}`,
|
||||
code,
|
||||
expiresAt: {
|
||||
$dateAfter: new Date(),
|
||||
},
|
||||
status: CODE_STATUS_UNUSED,
|
||||
verificatorName: this.verificator.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return this.ctx.throw(400, {
|
||||
code: 'InvalidVerificationCode',
|
||||
message: this.ctx.t('Verification code is invalid', { ns: pkg.name }),
|
||||
});
|
||||
}
|
||||
|
||||
return { codeInfo: item };
|
||||
}
|
||||
|
||||
async bind(userId: number, resource?: string, action?: string): Promise<{ uuid: string; meta?: any }> {
|
||||
const { uuid, code } = this.ctx.action.params.values || {};
|
||||
await this.verify({
|
||||
resource: resource || 'verificators',
|
||||
action: action || 'bind',
|
||||
boundInfo: { uuid },
|
||||
verifyParams: { code },
|
||||
});
|
||||
return { uuid };
|
||||
}
|
||||
|
||||
async onActionComplete({ verifyResult }) {
|
||||
const { codeInfo } = verifyResult;
|
||||
await codeInfo.update({
|
||||
status: CODE_STATUS_USED,
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Registry } from '@nocobase/utils';
|
||||
import { OTPVerification } from '..';
|
||||
import { SMSProvider } from './providers';
|
||||
import PluginVerficationServer from '../../Plugin';
|
||||
|
||||
type SMSProviderOptions = {
|
||||
title: string;
|
||||
provider: typeof SMSProvider;
|
||||
};
|
||||
|
||||
export class SMSOTPProviderManager {
|
||||
providers = new Registry<SMSProviderOptions>();
|
||||
|
||||
registerProvider(type: string, options: SMSProviderOptions) {
|
||||
this.providers.register(type, options);
|
||||
}
|
||||
|
||||
listProviders() {
|
||||
return Array.from(this.providers.getEntities()).map(([providerType, options]) => ({
|
||||
name: providerType,
|
||||
title: options.title,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export class SMSOTPVerification extends OTPVerification {
|
||||
async getProvider() {
|
||||
const { provider: providerType, settings } = this.options;
|
||||
if (!providerType) {
|
||||
return null;
|
||||
}
|
||||
const plugin = this.ctx.app.pm.get('verification') as PluginVerficationServer;
|
||||
const providerOptions = plugin.smsOTPProviderManager.providers.get(providerType);
|
||||
if (!providerOptions) {
|
||||
return null;
|
||||
}
|
||||
const Provider = providerOptions.provider;
|
||||
if (!Provider) {
|
||||
return null;
|
||||
}
|
||||
const options = this.ctx.app.environment.renderJsonTemplate(settings);
|
||||
return new Provider(options);
|
||||
}
|
||||
|
||||
async getPublicBoundInfo(userId: number) {
|
||||
const boundInfo = await this.getBoundInfo(userId);
|
||||
if (!boundInfo) {
|
||||
return { bound: false };
|
||||
}
|
||||
const { uuid: phone } = boundInfo;
|
||||
return {
|
||||
bound: true,
|
||||
publicInfo: '*'.repeat(phone.length - 4) + phone.slice(-4),
|
||||
};
|
||||
}
|
||||
|
||||
async validateBoundInfo({ uuid: phone }): Promise<boolean> {
|
||||
if (!phone) {
|
||||
throw new Error(this.ctx.t('Not a valid cellphone number, please re-enter'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -7,16 +7,8 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import Plugin from '../Plugin';
|
||||
|
||||
export class Provider {
|
||||
protected options: any;
|
||||
constructor(
|
||||
protected plugin: Plugin,
|
||||
options: any,
|
||||
) {
|
||||
this.options = plugin.app.environment.renderJsonTemplate(options);
|
||||
}
|
||||
export class SMSProvider {
|
||||
constructor(protected options: any) {}
|
||||
|
||||
async send(receiver: string, data: { [key: string]: any }): Promise<any> {}
|
||||
}
|
||||
+4
-5
@@ -10,14 +10,13 @@
|
||||
import DysmsApi, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
|
||||
import * as OpenApi from '@alicloud/openapi-client';
|
||||
import { RuntimeOptions } from '@alicloud/tea-util';
|
||||
import { SMSProvider } from '.';
|
||||
|
||||
import { Provider } from './Provider';
|
||||
|
||||
export default class extends Provider {
|
||||
export default class extends SMSProvider {
|
||||
client: DysmsApi;
|
||||
|
||||
constructor(plugin, options) {
|
||||
super(plugin, options);
|
||||
constructor(options: any) {
|
||||
super(options);
|
||||
|
||||
const { accessKeyId, accessKeySecret, endpoint } = this.options;
|
||||
|
||||
+4
-4
@@ -8,16 +8,16 @@
|
||||
*/
|
||||
|
||||
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
|
||||
import { Provider } from './Provider';
|
||||
import { SMSProvider } from '.';
|
||||
|
||||
// 导入对应产品模块的client models。
|
||||
const smsClient = tencentcloud.sms.v20210111.Client;
|
||||
|
||||
export default class extends Provider {
|
||||
export default class extends SMSProvider {
|
||||
client: InstanceType<typeof smsClient>;
|
||||
|
||||
constructor(plugin, options) {
|
||||
super(plugin, options);
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
const { secretId, secretKey, region, endpoint } = this.options;
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import PluginVerficationServer from '../../../Plugin';
|
||||
|
||||
export default {
|
||||
name: 'smsOTPProviders',
|
||||
actions: {
|
||||
list: async (ctx: Context, next: Next) => {
|
||||
const plugin = ctx.app.pm.get('verification') as PluginVerficationServer;
|
||||
ctx.body = plugin.smsOTPProviderManager.listProviders();
|
||||
await next();
|
||||
},
|
||||
},
|
||||
};
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import dayjs from 'dayjs';
|
||||
import { randomInt, randomUUID } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import PluginVerificationServer from '../../../Plugin';
|
||||
import { SMSOTPVerification } from '..';
|
||||
import { CODE_STATUS_UNUSED } from '../../../constants';
|
||||
import { namespace } from '../../..';
|
||||
const asyncRandomInt = promisify(randomInt);
|
||||
|
||||
async function create(ctx: Context, next: Next) {
|
||||
const { action: actionName, verificator: verificatorName } = ctx.action.params?.values || {};
|
||||
const plugin = ctx.app.getPlugin('verification') as PluginVerificationServer;
|
||||
const verificationManager = plugin.verificationManager;
|
||||
const action = verificationManager.actions.get(actionName);
|
||||
if (!action) {
|
||||
return ctx.throw(400, 'Invalid action type');
|
||||
}
|
||||
if (!verificatorName) {
|
||||
return ctx.throw(400, 'Invalid verificator');
|
||||
}
|
||||
const verificator = await ctx.db.getRepository('verificators').findOne({
|
||||
filter: {
|
||||
name: verificatorName,
|
||||
},
|
||||
});
|
||||
if (!verificator) {
|
||||
return ctx.throw(400, 'Invalid verificator');
|
||||
}
|
||||
const Verification = verificationManager.getVerification(verificator.verificationType);
|
||||
const verification = new Verification({
|
||||
ctx,
|
||||
verificator,
|
||||
options: verificator.options,
|
||||
}) as SMSOTPVerification;
|
||||
const provider = await verification.getProvider();
|
||||
if (!provider) {
|
||||
ctx.log.error(`[verification] no provider for action (${actionName}) provided`);
|
||||
return ctx.throw(500, 'Invalid provider');
|
||||
}
|
||||
const { boundInfo } = await verificationManager.getAndValidateBoundInfo(ctx, action, verification);
|
||||
const { uuid: receiver } = boundInfo;
|
||||
const record = await ctx.db.getRepository('otpRecords').findOne({
|
||||
filter: {
|
||||
action: actionName,
|
||||
receiver,
|
||||
status: CODE_STATUS_UNUSED,
|
||||
expiresAt: {
|
||||
$dateAfter: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (record) {
|
||||
const seconds = dayjs(record.get('expiresAt')).diff(dayjs(), 'seconds');
|
||||
// return ctx.throw(429, { code: 'RateLimit', message: ctx.t('Please don\'t retry in {{time}}', { time: moment().locale('zh').to(record.get('expiresAt')) }) });
|
||||
return ctx.throw(429, {
|
||||
code: 'RateLimit',
|
||||
message: ctx.t("Please don't retry in {{time}} seconds", { time: seconds, ns: namespace }),
|
||||
});
|
||||
}
|
||||
|
||||
const code = (<number>await asyncRandomInt(999999)).toString(10).padStart(6, '0');
|
||||
try {
|
||||
await provider.send(receiver, { code });
|
||||
} catch (error) {
|
||||
switch (error.name) {
|
||||
case 'InvalidReceiver':
|
||||
// TODO: message should consider email and other providers, maybe use "receiver"
|
||||
return ctx.throw(400, {
|
||||
code: 'InvalidReceiver',
|
||||
message: ctx.t('Not a valid cellphone number, please re-enter', { ns: namespace }),
|
||||
});
|
||||
case 'RateLimit':
|
||||
return ctx.throw(429, ctx.t('You are trying so frequently, please slow down', { ns: namespace }));
|
||||
default:
|
||||
console.error(error);
|
||||
return ctx.throw(
|
||||
500,
|
||||
ctx.t('Verification send failed, please try later or contact to administrator', { ns: namespace }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ctx.db.getRepository('otpRecords').create({
|
||||
values: {
|
||||
id: randomUUID(),
|
||||
action: actionName,
|
||||
receiver,
|
||||
code,
|
||||
expiresAt: Date.now() + (verification.expiresIn ?? 60) * 1000,
|
||||
status: CODE_STATUS_UNUSED,
|
||||
verificatorName,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
id: result.id,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'smsOTP',
|
||||
actions: {
|
||||
create,
|
||||
publicCreate: create,
|
||||
},
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import Plugin from '../Plugin';
|
||||
import { PROVIDER_TYPE_SMS_ALIYUN, PROVIDER_TYPE_SMS_TENCENT } from '../constants';
|
||||
import { Provider } from './Provider';
|
||||
import smsAliyun from './sms-aliyun';
|
||||
import smsTencent from './sms-tencent';
|
||||
|
||||
interface Providers {
|
||||
[key: string]: typeof Provider;
|
||||
}
|
||||
|
||||
export default async function (plugin: Plugin, more: Providers = {}) {
|
||||
const { providers } = plugin;
|
||||
|
||||
providers.register(PROVIDER_TYPE_SMS_ALIYUN, smsAliyun);
|
||||
providers.register(PROVIDER_TYPE_SMS_TENCENT, smsTencent);
|
||||
|
||||
for (const [name, provider] of Object.entries({ ...more })) {
|
||||
providers.register(name, provider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Registry } from '@nocobase/utils';
|
||||
import { Verification, VerificationExtend } from './verification';
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import PluginVerficationServer from './Plugin';
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
|
||||
export type VerificationTypeOptions = {
|
||||
title: string;
|
||||
description?: string;
|
||||
bindingRequired?: boolean;
|
||||
verification: VerificationExtend<Verification>;
|
||||
};
|
||||
|
||||
type SceneRule = (scene: string, verificationType: string) => boolean;
|
||||
|
||||
export interface ActionOptions {
|
||||
manual?: boolean;
|
||||
getUserIdFromCtx?(ctx: Context): number | Promise<number>;
|
||||
getBoundInfoFromCtx?(ctx: Context): any | Promise<any>;
|
||||
getVerifyParams?(ctx: Context): any | Promise<any>;
|
||||
onVerifySuccess?(ctx: Context, userId: number, verifyResult: any): any | Promise<any>;
|
||||
onVerifyFail?(ctx: Context, err: Error, userId: number): any | Promise<any>;
|
||||
}
|
||||
|
||||
export interface SceneOptions {
|
||||
actions: {
|
||||
[key: string]: ActionOptions;
|
||||
};
|
||||
getVerificators?(ctx: Context): Promise<string[]>;
|
||||
}
|
||||
|
||||
export class VerificationManager {
|
||||
db: Database;
|
||||
verificationTypes = new Registry<VerificationTypeOptions>();
|
||||
scenes = new Registry<SceneOptions>();
|
||||
sceneRules = new Array<SceneRule>();
|
||||
actions = new Registry<ActionOptions & { scene?: string }>();
|
||||
|
||||
constructor({ db }) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
registerVerificationType(type: string, options: VerificationTypeOptions) {
|
||||
this.verificationTypes.register(type, options);
|
||||
}
|
||||
|
||||
listTypes() {
|
||||
return Array.from(this.verificationTypes.getEntities()).map(([verificationType, options]) => ({
|
||||
name: verificationType,
|
||||
title: options.title,
|
||||
}));
|
||||
}
|
||||
|
||||
addSceneRule(rule: SceneRule) {
|
||||
this.sceneRules.push(rule);
|
||||
}
|
||||
|
||||
registerAction(action: string, options: ActionOptions) {
|
||||
this.actions.register(action, options);
|
||||
}
|
||||
|
||||
registerScene(scene: string, options: SceneOptions) {
|
||||
this.scenes.register(scene, options);
|
||||
const { actions } = options;
|
||||
for (const [action, actionOptions] of Object.entries(actions)) {
|
||||
this.actions.register(action, {
|
||||
...actionOptions,
|
||||
scene,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getVerificationTypesByScene(scene: string) {
|
||||
const verificationTypes = [];
|
||||
for (const [type, options] of this.verificationTypes.getEntities()) {
|
||||
const item = { type, title: options.title };
|
||||
if (this.sceneRules.some((rule) => rule(scene, type))) {
|
||||
verificationTypes.push(item);
|
||||
}
|
||||
}
|
||||
return verificationTypes;
|
||||
}
|
||||
|
||||
getVerification(type: string) {
|
||||
const verificationType = this.verificationTypes.get(type);
|
||||
if (!verificationType) {
|
||||
throw new Error(`Invalid verification type: ${type}`);
|
||||
}
|
||||
return verificationType.verification;
|
||||
}
|
||||
|
||||
async getVerificator(verificatorName: string) {
|
||||
return await this.db.getRepository('verificators').findOne({
|
||||
filter: {
|
||||
name: verificatorName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getVerificators(verificatorNames: string[]) {
|
||||
return await this.db.getRepository('verificators').find({
|
||||
filter: {
|
||||
name: verificatorNames,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBoundRecord(userId: number, verificator: string) {
|
||||
return await this.db.getRepository('usersVerificators').findOne({
|
||||
filter: {
|
||||
userId,
|
||||
verificator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAndValidateBoundInfo(ctx: Context, action: ActionOptions, verification: Verification) {
|
||||
let userId: number;
|
||||
let boundInfo: { uuid: string };
|
||||
if (action.getBoundInfoFromCtx) {
|
||||
boundInfo = await action.getBoundInfoFromCtx(ctx);
|
||||
} else {
|
||||
if (action.getUserIdFromCtx) {
|
||||
userId = await action.getUserIdFromCtx(ctx);
|
||||
} else {
|
||||
userId = ctx.auth?.user?.id;
|
||||
}
|
||||
if (!userId) {
|
||||
ctx.throw(400, 'Invalid user id');
|
||||
}
|
||||
boundInfo = await verification.getBoundInfo(userId);
|
||||
}
|
||||
await verification.validateBoundInfo(boundInfo);
|
||||
return { boundInfo, userId };
|
||||
}
|
||||
|
||||
private async validateAndGetVerificator(ctx: Context, scene: string, verificatorName: string) {
|
||||
let verificator: Model;
|
||||
if (!verificatorName) {
|
||||
return null;
|
||||
}
|
||||
if (scene) {
|
||||
const sceneOptions = this.scenes.get(scene);
|
||||
if (sceneOptions.getVerificators) {
|
||||
const verificators = await sceneOptions.getVerificators(ctx);
|
||||
if (!verificators.includes(verificatorName)) {
|
||||
return null;
|
||||
}
|
||||
verificator = await this.getVerificator(verificatorName);
|
||||
if (!verificator) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const verificationTypes = this.getVerificationTypesByScene(scene);
|
||||
const verificators = await this.db.getRepository('verificators').find({
|
||||
filter: {
|
||||
verificationType: verificationTypes.map((item) => item.type),
|
||||
},
|
||||
});
|
||||
verificator = verificators.find((item: { name: string }) => item.name === verificatorName);
|
||||
if (!verificator) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
verificator = await this.getVerificator(verificatorName);
|
||||
if (!verificator) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return verificator;
|
||||
}
|
||||
|
||||
// verify manually
|
||||
async verify(ctx: Context, next: Next) {
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
const key = `${resourceName}:${actionName}`;
|
||||
const action = this.actions.get(key);
|
||||
if (!action) {
|
||||
ctx.throw(400, 'Invalid action');
|
||||
}
|
||||
const { verificator: verificatorName } = ctx.action.params.values || {};
|
||||
const verificator = await this.validateAndGetVerificator(ctx, action.scene, verificatorName);
|
||||
if (!verificator) {
|
||||
ctx.throw(400, 'Invalid verificator');
|
||||
}
|
||||
const verifyParams = action.getVerifyParams ? await action.getVerifyParams(ctx) : ctx.action.params.values;
|
||||
if (!verifyParams) {
|
||||
ctx.throw(400, 'Invalid verify params');
|
||||
}
|
||||
const plugin = ctx.app.pm.get('verification') as PluginVerficationServer;
|
||||
const verificationManager = plugin.verificationManager;
|
||||
const Verification = verificationManager.getVerification(verificator.verificationType);
|
||||
const verification = new Verification({ ctx, verificator, options: verificator.options });
|
||||
const { boundInfo, userId } = await this.getAndValidateBoundInfo(ctx, action, verification);
|
||||
try {
|
||||
const verifyResult = await verification.verify({
|
||||
resource: resourceName,
|
||||
action: actionName,
|
||||
userId,
|
||||
boundInfo,
|
||||
verifyParams,
|
||||
});
|
||||
try {
|
||||
await action.onVerifySuccess?.(ctx, userId, verifyResult);
|
||||
await next();
|
||||
} catch (err) {
|
||||
ctx.log.error(err, { module: 'verification', method: 'verify' });
|
||||
throw err;
|
||||
} finally {
|
||||
await verification.onActionComplete({ userId, verifyResult });
|
||||
}
|
||||
} catch (err) {
|
||||
await action.onVerifyFail?.(ctx, err, userId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
middleware() {
|
||||
const self = this;
|
||||
return async function verificationMiddleware(ctx: Context, next: Next) {
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
const key = `${resourceName}:${actionName}`;
|
||||
const action = self.actions.get(key);
|
||||
if (!action || action.manual) {
|
||||
return next();
|
||||
}
|
||||
return self.verify(ctx, next);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { Model } from '@nocobase/database';
|
||||
|
||||
export interface IVerification {
|
||||
verify(options: {
|
||||
resource: string;
|
||||
action: string;
|
||||
userId: number;
|
||||
boundInfo: any;
|
||||
verifyParams?: any;
|
||||
}): Promise<any>;
|
||||
onActionComplete?(options: { userId: number; verifyResult: any }): Promise<any>;
|
||||
getBoundInfo?(userId: number): Promise<any>;
|
||||
getPublicBoundInfo?(userId: number): Promise<{
|
||||
bound: boolean;
|
||||
publicInfo?: any;
|
||||
}>;
|
||||
validateBoundInfo?(boundInfo: string): Promise<boolean>;
|
||||
bind?(
|
||||
userId: number,
|
||||
resource?: string,
|
||||
action?: string,
|
||||
): Promise<{
|
||||
uuid: string;
|
||||
meta?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
export abstract class Verification implements IVerification {
|
||||
verificator: Model;
|
||||
protected ctx: Context;
|
||||
protected options: Record<string, any>;
|
||||
constructor({ ctx, verificator, options }) {
|
||||
this.ctx = ctx;
|
||||
this.verificator = verificator;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
get throughRepo() {
|
||||
return this.ctx.db.getRepository('usersVerificators');
|
||||
}
|
||||
|
||||
abstract verify({ resource, action, userId, boundInfo, verifyParams }): Promise<any>;
|
||||
async onActionComplete(options: { userId: number; verifyResult: any }): Promise<any> {}
|
||||
async bind(userId: number, resource?: string, action?: string): Promise<{ uuid: string; meta?: any }> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async getBoundInfo(userId: number): Promise<any> {
|
||||
return this.throughRepo.findOne({
|
||||
filter: {
|
||||
verificator: this.verificator.name,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicBoundInfo(userId: number): Promise<{
|
||||
bound: boolean;
|
||||
publicInfo?: any;
|
||||
}> {
|
||||
const boundInfo = await this.getBoundInfo(userId);
|
||||
return {
|
||||
bound: boundInfo ? true : false,
|
||||
};
|
||||
}
|
||||
|
||||
async validateBoundInfo(boundInfo: any): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export type VerificationExtend<T extends Verification> = new ({ ctx, verificator, options }) => T;
|
||||
Reference in New Issue
Block a user