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:
YANG QIA
2025-03-01 22:25:56 +08:00
committed by GitHub
parent 09559f429e
commit c53b613517
63 changed files with 3934 additions and 795 deletions
+19 -14
View File
@@ -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' });
}
@@ -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',
},
);
@@ -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',
},
},
}}
/>
);
};
@@ -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 }} />;
};
@@ -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 }}
/>
);
};
@@ -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,
},
};
@@ -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);
}
}
@@ -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} />;
};
@@ -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?')}}",
},
},
},
},
},
},
},
},
},
},
},
},
};
@@ -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);
}
}
@@ -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));
@@ -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>
);
};
@@ -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);
});
});
});
@@ -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;
}
@@ -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,
},
});
});
});
@@ -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');
});
});
@@ -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();
});
});
@@ -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',
},
],
});
@@ -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: {},
},
],
});
@@ -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;
@@ -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,
});
}
}
@@ -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;
}
}
@@ -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> {}
}
@@ -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;
@@ -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;
@@ -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();
},
},
};
@@ -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;