feat(plugin-verification): add plugin-verification and phone for users (#722)

* feat(plugin-verification): add plugin-verification and phone for users

* feat(plugin-verification): add env example

* fix(plugin-verification): fix locales

* fix(plugin-verification): remove sending comment

* fix(plugin-verification): fix i18n

* refactor(plugin-verification): move invalid error message to action

* fix(plugin-verification): add field migration

* chore(plugin-verification): update packages version

* test(plugin-verification): temp remove new package dependency

* refactor(plugin-verification): make sms authentication configurable in system settings

* fix: smsAuthEnabled

* feat: update preset-nocobase

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2022-08-20 18:06:12 +08:00 committed by GitHub
parent 259393f626
commit 7e6a394f73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1280 additions and 91 deletions

View File

@ -53,3 +53,13 @@ AWS_SECRET_ACCESS_KEY=
AWS_S3_REGION=
AWS_S3_BUCKET=
AWS_S3_STORAGE_BASE_URL=
# ALI SMS VERIFY CODE CONFIG
INIT_ALI_SMS_ACCESS_KEY=
INIT_ALI_SMS_ACCESS_KEY_SECRET=
INIT_ALI_SMS_ENDPOINT=
INIT_ALI_SMS_VERIFY_CODE_TEMPLATE=
INIT_ALI_SMS_VERIFY_CODE_SIGN=
# use any string name (no space)
DEFAULT_SMS_VERIFY_CODE_PROVIDER=

View File

@ -1,17 +1,3 @@
import { PluginsConfigurations } from '@nocobase/server';
export default [
'@nocobase/plugin-error-handler',
'@nocobase/plugin-collection-manager',
'@nocobase/plugin-ui-schema-storage',
'@nocobase/plugin-ui-routes-storage',
'@nocobase/plugin-file-manager',
'@nocobase/plugin-system-settings',
'@nocobase/plugin-users',
'@nocobase/plugin-acl',
'@nocobase/plugin-china-region',
'@nocobase/plugin-workflow',
'@nocobase/plugin-client',
'@nocobase/plugin-export',
'@nocobase/plugin-audit-logs',
] as PluginsConfigurations;
export default ['@nocobase/preset-nocobase'] as PluginsConfigurations;

View File

@ -43,6 +43,7 @@ export default {
"Super admin": "超级管理员",
"Language": "语言设置",
"Allow sign up": "允许注册",
"Enable SMS authentication": "启用短信登录和注册",
"Sign out": "注销",
"Cancel": "取消",
"Submit": "提交",
@ -307,13 +308,18 @@ export default {
"Saved successfully": "保存成功",
"Nickname": "昵称",
"Sign in": "登录",
"Sign in via account": "账号密码登录",
"Sign in via phone": "手机号登录",
"Create an account": "注册账号",
"Sign up": "注册",
"Confirm password": "确认密码",
"Log in with an existing account": "使用已有账号登录",
"Signed up successfully. It will jump to the login page.": "注册成功,将跳转登录页。",
"Password mismatch": "确认密码不匹配",
"Password mismatch": "重复密码不匹配",
"Users": "用户",
"Verification code": "验证码",
"Send code": "发送验证码",
"Retry after {{count}} seconds": "{{count}} 秒后重试",
"Roles": "角色",
"Add role": "添加角色",

View File

@ -124,6 +124,13 @@ const schema: ISchema = {
'x-component': 'Checkbox',
'x-decorator': 'FormItem',
},
smsAuthEnabled: {
type: 'boolean',
default: false,
'x-content': '{{t("Enable SMS authentication")}}',
'x-component': 'Checkbox',
'x-decorator': 'FormItem',
},
footer1: {
type: 'void',
'x-component': 'Action.Drawer.Footer',

View File

@ -1,12 +1,14 @@
import { ISchema, useForm } from '@formily/react';
import { uid } from '@formily/shared';
import React from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { Tabs } from 'antd';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory, useLocation } from 'react-router-dom';
import { SchemaComponent, useAPIClient, useCurrentDocumentTitle, useSystemSettings } from '..';
import VerificationCode from './VerificationCode';
const schema: ISchema = {
const passwordForm: ISchema = {
type: 'object',
name: uid(),
name: 'passwordForm',
'x-component': 'FormV2',
properties: {
email: {
@ -36,51 +38,118 @@ const schema: ISchema = {
htmlType: 'submit',
block: true,
type: 'primary',
useAction: '{{ useSignin }}',
useAction: '{{ usePasswordSignIn }}',
style: { width: '100%' },
},
},
},
},
link: {
type: 'void',
'x-component': 'div',
'x-visible': '{{allowSignUp}}',
properties: {
link: {
title: '{{t("Create an account")}}',
type: 'void',
'x-component': 'Link',
'x-content': '{{t("Create an account")}}',
'x-component-props': { to: '/signup' },
},
},
},
},
};
export const useSignin = () => {
function useRedirect(next = '/admin') {
const location = useLocation<any>();
const history = useHistory();
const redirect = location?.['query']?.redirect;
return useCallback(() => {
history.push(redirect || '/admin');
}, [redirect]);
}
export const usePasswordSignIn = () => {
const form = useForm();
const api = useAPIClient();
const redirect = location?.['query']?.redirect;
const redirect = useRedirect();
return {
async run() {
await form.submit();
await api.auth.signIn(form.values);
history.push(redirect || '/admin');
redirect();
},
};
};
const phoneForm: ISchema = {
type: 'object',
name: 'phoneForm',
'x-component': 'Form',
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',
'x-component-props': {
actionType: 'users:signin',
targetFieldName: 'phone',
},
'x-decorator': 'FormItem',
},
actions: {
title: '{{t("Sign in")}}',
type: 'void',
'x-component': 'Action',
'x-component-props': {
htmlType: 'submit',
block: true,
type: 'primary',
useAction: '{{ usePhoneSignIn }}',
style: { width: '100%' },
},
},
},
};
export function usePhoneSignIn() {
const form = useForm();
const api = useAPIClient();
const redirect = useRedirect();
return {
async run() {
await form.submit();
await api.auth.signIn(form.values, 'sms');
redirect();
},
};
}
export const SigninPage = () => {
const { t } = useTranslation();
useCurrentDocumentTitle('Signin');
const ctx = useSystemSettings();
const allowSignUp = ctx?.data?.data?.allowSignUp;
const { allowSignUp, smsAuthEnabled } = ctx?.data?.data || {};
return (
<div>
<SchemaComponent scope={{ useSignin, allowSignUp }} schema={schema} />
{smsAuthEnabled ? (
<Tabs defaultActiveKey="password">
<Tabs.TabPane tab={t('Sign in via account')} key="password">
<SchemaComponent scope={{ usePasswordSignIn }} schema={passwordForm} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('Sign in via phone')} key="phone">
<SchemaComponent
schema={phoneForm}
scope={{ usePhoneSignIn }}
components={{
VerificationCode,
}}
/>
</Tabs.TabPane>
</Tabs>
) : (
<SchemaComponent scope={{ usePasswordSignIn }} schema={passwordForm} />
)}
{allowSignUp && (
<div>
<Link to="/signup">{t('Create an account')}</Link>
</div>
)}
</div>
);
};

View File

@ -4,6 +4,7 @@ import { message } from 'antd';
import React from 'react';
import { Redirect, useHistory } from 'react-router-dom';
import { SchemaComponent, useAPIClient, useCurrentDocumentTitle, useSystemSettings } from '..';
import VerificationCode from './VerificationCode';
const schema: ISchema = {
type: 'object',
@ -18,6 +19,26 @@ const schema: ISchema = {
'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Email")}}', style: {} },
},
phone: {
type: 'string',
required: true,
'x-component': 'Input',
'x-validator': 'phone',
'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Phone")}}', style: {} },
'x-visible': '{{smsAuthEnabled}}'
},
code: {
type: 'string',
required: true,
'x-component': 'VerificationCode',
'x-component-props': {
actionType: 'users:signup',
targetFieldName: 'phone',
},
'x-decorator': 'FormItem',
'x-visible': '{{smsAuthEnabled}}'
},
password: {
type: 'string',
required: true,
@ -40,7 +61,7 @@ const schema: ISchema = {
required: true,
'x-component': 'Password',
'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Confirm password")}}', checkStrength: true, style: {} },
'x-component-props': { placeholder: '{{t("Confirm password")}}', style: {} },
'x-reactions': [
{
dependencies: ['.password'],
@ -106,9 +127,17 @@ export const useSignup = () => {
export const SignupPage = () => {
useCurrentDocumentTitle('Signup');
const ctx = useSystemSettings();
const allowSignUp = ctx?.data?.data?.allowSignUp;
const { allowSignUp, smsAuthEnabled } = ctx?.data?.data || {};
if (!allowSignUp) {
return <Redirect to={'/signin'} />;
}
return <SchemaComponent schema={schema} scope={{ useSignup }} />;
return (
<SchemaComponent
schema={schema}
components={{
VerificationCode
}}
scope={{ useSignup, smsAuthEnabled }}
/>
);
};

View File

@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { css } from '@emotion/css';
import { useAPIClient } from "../api-client";
import { useForm } from "@formily/react";
import { useEffect, useRef, useState } from "react";
import { Button, Input, message } from "antd";
import React from "react";
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: .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>
);
}

View File

@ -90,3 +90,29 @@ export class MiddlewareManager {
this.middlewares.splice(this.middlewares.indexOf(middleware), 1);
}
}
export function branch(
map: {
[key: string]: HandlerType;
} = {},
reducer: (ctx) => string,
options: {
keyNotFound?(ctx, next): void;
handlerNotSet?(ctx, next): void;
} = {}
): HandlerType {
return (ctx, next) => {
const key = reducer(ctx);
if (!key) {
return options.keyNotFound ? options.keyNotFound(ctx, next) : ctx.throw(404);
}
const handler = map[key];
if (!handler) {
return options.handlerNotSet ? options.handlerNotSet(ctx, next) : ctx.throw(404);
}
return handler(ctx, next);
};
}

View File

@ -94,7 +94,7 @@ export class Auth {
this.api.storage.setItem('NOCOBASE_TOKEN', token || '');
if (!token) {
this.setRole(null);
this.setLocale(null);
// this.setLocale(null);
}
}
@ -107,11 +107,14 @@ export class Auth {
this.api.storage.setItem('NOCOBASE_ROLE', role || '');
}
async signIn(values): Promise<AxiosResponse<any>> {
async signIn(values, authenticator: string = 'password'): Promise<AxiosResponse<any>> {
const response = await this.api.request({
method: 'post',
url: 'users:signin',
data: values,
params: {
authenticator
}
});
const data = response?.data?.data;
this.setToken(data?.token);

View File

@ -16,6 +16,11 @@ export default defineCollection({
name: 'allowSignUp',
defaultValue: true,
},
{
type: 'boolean',
name: 'smsAuthEnabled',
defaultValue: false
},
{
type: 'belongsTo',
name: 'logo',

View File

@ -10,12 +10,17 @@
}
],
"dependencies": {
"jsonwebtoken": "^8.5.1"
"@nocobase/actions": "0.7.4-alpha.7",
"@nocobase/database": "0.7.4-alpha.7",
"@nocobase/resourcer": "0.7.4-alpha.7",
"@nocobase/server": "0.7.4-alpha.7",
"@nocobase/utils": "0.7.4-alpha.7",
"jsonwebtoken": "^8.5.1",
"json-templates": "^4.2.0"
},
"devDependencies": {
"@nocobase/test": "0.7.4-alpha.7",
"@types/jsonwebtoken": "^8.5.8",
"json-templates": "^4.2.0"
"@types/jsonwebtoken": "^8.5.8"
},
"gitHead": "b17d1ecae5dd0b9d4e7170dcf9663bb225598990"
}

View File

@ -1,5 +1,6 @@
import { Context, Next } from '@nocobase/actions';
import { PasswordField } from '@nocobase/database';
import { branch } from '@nocobase/resourcer';
import crypto from 'crypto';
import { namespace } from '../';
@ -14,35 +15,22 @@ export async function check(ctx: Context, next: Next) {
}
export async function signin(ctx: Context, next: Next) {
const { uniqueField = 'email', values } = ctx.action.params;
if (!values[uniqueField]) {
ctx.throw(401, ctx.t('Please fill in your email address', { ns: namespace }));
}
const User = ctx.db.getCollection('users');
const user = await User.model.findOne<any>({
where: {
[uniqueField]: values[uniqueField],
},
});
if (!user) {
ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace }));
}
const pwd = User.getField<PasswordField>('password');
const isValid = await pwd.verify(values.password, user.password);
if (!isValid) {
ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace }));
const { authenticators, jwtService } = ctx.app.getPlugin('@nocobase/plugin-users');
const branches = {};
for (const [name, authenticator] of authenticators.getEntities()) {
branches[name] = authenticator;
}
const pluginUser = ctx.app.getPlugin('@nocobase/plugin-users');
return branch(branches, context => context.action.params.authenticator ?? 'password')(ctx, () => {
const user = ctx.state.currentUser.toJSON();
const token = jwtService.sign({ userId: user.id });
ctx.body = {
...user.toJSON(),
token: pluginUser.jwtService.sign({
userId: user.get('id'),
}),
user,
token,
};
await next();
return next();
});
}
export async function signout(ctx: Context, next: Next) {
@ -63,7 +51,7 @@ export async function lostpassword(ctx: Context, next: Next) {
values: { email },
} = ctx.action.params;
if (!email) {
ctx.throw(401, ctx.t('Please fill in your email address', { ns: namespace }));
ctx.throw(400, { code: 'InvalidUserData', message: ctx.t('Please fill in your email address', { ns: namespace }) });
}
const User = ctx.db.getCollection('users');
const user = await User.model.findOne<any>({
@ -72,7 +60,7 @@ export async function lostpassword(ctx: Context, next: Next) {
},
});
if (!user) {
ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace }));
ctx.throw(404, { code: 'InvalidUserData', message: ctx.t('The email is incorrect, please re-enter', { ns: namespace }) });
}
user.resetToken = crypto.randomBytes(20).toString('hex');
await user.save();
@ -92,7 +80,7 @@ export async function resetpassword(ctx: Context, next: Next) {
},
});
if (!user) {
ctx.throw(401, 'Unauthorized');
ctx.throw(404);
}
user.token = null;
user.resetToken = null;
@ -111,7 +99,7 @@ export async function getUserByResetToken(ctx: Context, next: Next) {
},
});
if (!user) {
ctx.throw(401, 'Unauthorized');
ctx.throw(401);
}
ctx.body = user;
await next();
@ -120,7 +108,7 @@ export async function getUserByResetToken(ctx: Context, next: Next) {
export async function updateProfile(ctx: Context, next: Next) {
const { values } = ctx.action.params;
if (!ctx.state.currentUser) {
ctx.throw(401, 'Unauthorized');
ctx.throw(401);
}
await ctx.state.currentUser.update(values);
ctx.body = ctx.state.currentUser;
@ -132,7 +120,7 @@ export async function changePassword(ctx: Context, next: Next) {
values: { oldPassword, newPassword },
} = ctx.action.params;
if (!ctx.state.currentUser) {
ctx.throw(401, 'Unauthorized');
ctx.throw(401);
}
const User = ctx.db.getCollection('users');
const user = await User.model.findOne<any>({

View File

@ -0,0 +1,24 @@
import path from 'path';
import { requireModule } from '@nocobase/utils';
import { HandlerType } from '@nocobase/resourcer';
import Plugin from '..';
interface Authenticators {
[key: string]: HandlerType
};
export default function(plugin: Plugin, more: Authenticators = {}) {
const { authenticators } = plugin;
const natives = [
'password'
].reduce((result, key) => Object.assign(result, {
[key]: requireModule(path.isAbsolute(key) ? key : path.join(__dirname, key)) as HandlerType
}), {});
for (const [name, authenticator] of Object.entries(<Authenticators>{ ...more, ...natives })) {
authenticators.register(name, authenticator);
}
}

View File

@ -0,0 +1,31 @@
import { PasswordField } from '@nocobase/database';
import { Context, Next } from '@nocobase/actions';
import { namespace } from '..';
export default async function(ctx: Context, next: Next) {
const { uniqueField = 'email', values } = ctx.action.params;
if (!values[uniqueField]) {
return ctx.throw(400, { code: 'InvalidUserData', message: ctx.t('Please fill in your email address', { ns: namespace }) });
}
const User = ctx.db.getCollection('users');
const user = await User.model.findOne<any>({
where: {
[uniqueField]: values[uniqueField],
},
});
if (!user) {
return ctx.throw(404, ctx.t('The email is incorrect, please re-enter', { ns: namespace }));
}
const field = User.getField<PasswordField>('password');
const valid = await field.verify(values.password, user.password);
if (!valid) {
return ctx.throw(404, ctx.t('The password is incorrect, please re-enter', { ns: namespace }));
}
ctx.state.currentUser = user;
return next();
}

View File

@ -41,6 +41,19 @@ export default {
require: true,
},
},
{
interface: 'phone',
type: 'string',
name: 'phone',
unique: true,
uiSchema: {
type: 'string',
title: '{{t("Phone")}}',
'x-component': 'Input',
'x-validator': 'phone',
require: true,
},
},
{
interface: 'password',
type: 'password',

View File

@ -1,5 +1,8 @@
export default {
'The email is incorrect, please re-enter': '邮箱错误,请重新输入',
'Please fill in your email address': '请填写密码',
'The password is incorrect, please re-enter': '密码错误,请重新输入',
'The email is incorrect, please re-enter': '邮箱有误,请重新输入',
'Please fill in your email address': '请填写邮箱',
'The password is incorrect, please re-enter': '密码有误,请重新输入',
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
'The phone number is not registered, please register first': '手机号未注册,请先注册',
};

View File

@ -0,0 +1,41 @@
import { Migration } from '@nocobase/server';
export default class AlertSubTableMigration extends Migration {
async up() {
const match = await this.app.version.satisfies('<=0.7.4-alpha.8');
if (!match) {
return;
}
const Field = this.context.db.getRepository('fields');
const existed = await Field.count({
filter: {
name: 'phone',
collectionName: 'users'
}
});
if (!existed) {
await Field.create({
values: {
name: 'phone',
collectionName: 'users',
type: 'string',
unique: true,
interface: 'phone',
uiSchema: {
type: 'string',
title: '{{t("Phone")}}',
'x-component': 'Input',
'x-validator': 'phone',
require: true,
},
},
// NOTE: to trigger hook
context: {}
});
}
}
async down() {
}
}

View File

@ -1,12 +1,17 @@
import { resolve } from 'path';
import parse from 'json-templates';
import { Collection, Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import parse from 'json-templates';
import { resolve } from 'path';
import { Registry } from '@nocobase/utils';
import { HandlerType, MiddlewareManager } from '@nocobase/resourcer';
import { namespace } from './';
import * as actions from './actions/users';
import { JwtOptions, JwtService } from './jwt-service';
import { enUS, zhCN } from './locale';
import * as middlewares from './middlewares';
import initAuthenticators from './authenticators';
export interface UserPluginConfig {
jwt: JwtOptions;
@ -15,7 +20,9 @@ export interface UserPluginConfig {
export default class UsersPlugin extends Plugin<UserPluginConfig> {
public jwtService: JwtService;
public tokenMiddleware;
public tokenMiddleware: MiddlewareManager;
public authenticators: Registry<HandlerType> = new Registry();
constructor(app, options) {
super(app, options);
@ -30,7 +37,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
if (cmd) {
cmd.requiredOption('-e, --root-email <rootEmail>', '', process.env.INIT_ROOT_EMAIL);
cmd.requiredOption('-p, --root-password <rootPassword>', '', process.env.INIT_ROOT_PASSWORD);
cmd.option('-n, --root-nickname [rootNickname]');
cmd.option('-n, --root-nickname <rootNickname>');
}
this.db.registerOperators({
$isCurrentUser(_, ctx) {
@ -98,6 +105,87 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
await this.db.import({
directory: resolve(__dirname, 'collections'),
});
this.db.addMigrations({
namespace: 'users',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
initAuthenticators(this);
// TODO(module): should move to preset
const verificationPlugin = this.app.getPlugin('@nocobase/plugin-verification') as any;
if (verificationPlugin && process.env.DEFAULT_SMS_VERIFY_CODE_PROVIDER) {
verificationPlugin.interceptors.register('users:signin', {
manual: true,
provider: process.env.DEFAULT_SMS_VERIFY_CODE_PROVIDER,
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'));
}
const User = this.db.getCollection('users');
const exists = await User.model.count({
where: {
phone,
},
});
if (!exists) {
throw new Error(ctx.t('The phone number is not registered, please register first', { ns: namespace }));
}
return true;
}
});
verificationPlugin.interceptors.register('users:signup', {
provider: process.env.DEFAULT_SMS_VERIFY_CODE_PROVIDER,
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', { ns: namespace }));
}
const User = this.db.getCollection('users');
const exists = await User.model.count({
where: {
phone,
},
});
if (exists) {
throw new Error(ctx.t('The phone number has been registered, please login directly', { ns: namespace }));
}
return true;
}
});
this.authenticators.register('sms', (ctx, next) => verificationPlugin.intercept(ctx, async () => {
const { values } = ctx.action.params;
const User = ctx.db.getCollection('users');
const user = await User.model.findOne({
where: {
phone: values.phone,
},
});
if (!user) {
return ctx.throw(404, ctx.t('The phone number is incorrect, please re-enter', { ns: namespace }));
}
ctx.state.currentUser = user;
return next();
}));
}
}
getInstallingData(options: any = {}) {

View File

@ -0,0 +1,25 @@
{
"name": "@nocobase/plugin-verification",
"version": "0.7.4-alpha.7",
"main": "lib/index.js",
"license": "Apache-2.0",
"licenses": [
{
"type": "Apache-2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0"
}
],
"dependencies": {
"@alicloud/dysmsapi20170525": "2.0.17",
"@alicloud/openapi-client": "0.4.1",
"@alicloud/tea-util": "1.4.4",
"@nocobase/actions": "0.7.4-alpha.7",
"@nocobase/resourcer": "0.7.4-alpha.7",
"@nocobase/server": "0.7.4-alpha.7",
"@nocobase/utils": "0.7.4-alpha.7"
},
"devDependencies": {
"@nocobase/test": "0.7.4-alpha.7"
},
"gitHead": "7e9556e489007577fc0ed89063b3a9ce2f9aae53"
}

View File

@ -0,0 +1,141 @@
import path from 'path';
import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils';
import { Op } from '@nocobase/database';
import { HandlerType } from '@nocobase/resourcer';
import { Context } from '@nocobase/actions';
import initProviders, { Provider } from './providers';
import initActions from './actions';
import { CODE_STATUS_UNUSED, CODE_STATUS_USED, PROVIDER_TYPE_SMS_ALIYUN } from './constants';
import { namespace } from '.';
import { zhCN } from './locale';
export interface Interceptor {
manual?: boolean;
provider: string;
expiresIn?: number;
getReceiver(ctx): string;
getCode?(ctx): string;
validate?(ctx: Context, receiver: string): boolean | Promise<boolean>;
};
export default class VerificationPlugin extends Plugin {
providers: Registry<typeof Provider> = new Registry();
interceptors: Registry<Interceptor> = new Registry();
intercept: HandlerType = async (context, next) => {
const { resourceName, actionName, values } = context.action.params;
const key = `${resourceName}:${actionName}`;
const interceptor = this.interceptors.get(key);
if (!interceptor) {
return context.throw(400);
}
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
}
});
if (!item) {
return context.throw(400, { code: 'InvalidSMSCode', message: 'verify by sms code failed' });
}
// TODO: code should be removed if exists in values
// context.action.mergeParams({
// values: {
// }
// });
await next();
// or delete
await item.update({
status: CODE_STATUS_USED
});
}
getName(): string {
return this.getPackageName(__dirname);
}
async install() {
const {
DEFAULT_SMS_VERIFY_CODE_PROVIDER,
INIT_ALI_SMS_ACCESS_KEY,
INIT_ALI_SMS_ACCESS_KEY_SECRET,
INIT_ALI_SMS_ENDPOINT = 'dysmsapi.aliyuncs.com',
INIT_ALI_SMS_VERIFY_CODE_TEMPLATE,
INIT_ALI_SMS_VERIFY_CODE_SIGN
} = process.env;
if (INIT_ALI_SMS_ACCESS_KEY
&& INIT_ALI_SMS_ACCESS_KEY_SECRET
&& INIT_ALI_SMS_VERIFY_CODE_TEMPLATE
&& INIT_ALI_SMS_VERIFY_CODE_SIGN
) {
const ProviderRepo = this.db.getRepository('verifications_providers');
await ProviderRepo.create({
values: {
id: DEFAULT_SMS_VERIFY_CODE_PROVIDER,
type: PROVIDER_TYPE_SMS_ALIYUN,
title: 'Default SMS sender',
options: {
accessKeyId: INIT_ALI_SMS_ACCESS_KEY,
accessKeySecret: INIT_ALI_SMS_ACCESS_KEY_SECRET,
endpoint: INIT_ALI_SMS_ENDPOINT,
sign: INIT_ALI_SMS_VERIFY_CODE_SIGN,
template: INIT_ALI_SMS_VERIFY_CODE_TEMPLATE
}
}
});
}
}
async load() {
const { app, db, options } = this;
app.i18n.addResources('zh-CN', namespace, zhCN);
await db.import({
directory: path.resolve(__dirname, 'collections'),
});
initProviders(this);
initActions(this);
// add middleware to action
app.resourcer.use(async (context, next) => {
const { resourceName, actionName, values } = context.action.params;
const key = `${resourceName}:${actionName}`;
const interceptor = this.interceptors.get(key);
if (!interceptor || interceptor.manual) {
return next();
}
return this.intercept(context, next);
});
app.acl.allow('verifications', 'create');
}
}

View File

@ -0,0 +1,162 @@
import { MockServer } from '@nocobase/test';
import Database from '@nocobase/database';
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.getPlugin('@nocobase/plugin-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',
});
});
afterEach(() => app.destroy());
describe('auto intercept', () => {
beforeEach(async () => {
plugin.interceptors.register('authors:create', {
provider: 'fake1',
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,
provider: 'fake1',
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', {
provider: 'fake1',
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);
});
});
});

View File

@ -0,0 +1,15 @@
import { CollectionOptions } from '@nocobase/database';
export default {
name: 'authors',
fields: [
{
type: 'string',
name: 'title',
},
{
type: 'string',
name: 'phone'
}
]
} as CollectionOptions;

View File

@ -0,0 +1,39 @@
import path from 'path';
import { MockServer, mockServer } from '@nocobase/test';
import Plugin from '..';
import { ApplicationOptions } from '@nocobase/server';
export function sleep(ms: number) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
interface MockAppOptions extends ApplicationOptions {
manual?: boolean;
}
export async function getApp({ manual, ...options }: MockAppOptions = {}): Promise<MockServer> {
const app = mockServer(options);
app.plugin(Plugin);
await app.load();
await app.db.import({
directory: path.resolve(__dirname, './collections')
});
try {
await app.db.sync();
} catch (error) {
console.error(error);
}
if (!manual) {
await app.start();
}
return app;
}

View File

@ -0,0 +1,14 @@
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)
});
}

View File

@ -0,0 +1,103 @@
import { promisify } from 'util';
import { randomInt, randomUUID } from 'crypto';
import { Op } from '@nocobase/database';
import actions, { Context, Next } from '@nocobase/actions';
import Plugin, { namespace } from '..';
import { CODE_STATUS_UNUSED } from '../constants';
import moment from 'moment';
const asyncRandomInt = promisify(randomInt);
export async function create(context: Context, next: Next) {
const plugin = context.app.getPlugin('@nocobase/plugin-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 ProviderRepo = context.db.getRepository('verifications_providers');
const providerItem = await ProviderRepo.findOne({
filterByTk: interceptor.provider
});
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: 'Invalid receiver' });
}
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 = moment(record.get('expiresAt')).diff(moment(), '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, context.t('Not a valid cellphone number, please re-enter', {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();
});
}

View File

@ -0,0 +1,36 @@
export default {
name: 'verifications',
fields: [
{
type: 'uuid',
name: 'id',
primaryKey: true
},
{
type: 'string',
name: 'type'
},
{
type: 'string',
name: 'receiver'
},
{
type: 'integer',
name: 'status',
defaultValue: 0
},
{
type: 'date',
name: 'expiresAt'
},
{
type: 'string',
name: 'content'
},
{
type: 'belongsTo',
name: 'provider',
target: 'verifications_providers',
}
]
};

View File

@ -0,0 +1,22 @@
export default {
name: 'verifications_providers',
fields: [
{
type: 'string',
name: 'id',
primaryKey: true
},
{
type: 'string',
name: 'title',
},
{
type: 'string',
name: 'type'
},
{
type: 'jsonb',
name: 'options'
}
]
};

View File

@ -0,0 +1,4 @@
export const PROVIDER_TYPE_SMS_ALIYUN = 'sms-aliyun';
export const CODE_STATUS_UNUSED = 0;
export const CODE_STATUS_USED = 1;

View File

@ -0,0 +1,5 @@
export * from './constants';
export { Provider } from './providers';
export { Interceptor, default } from './Plugin';
export const namespace = require('../package.json').name;

View File

@ -0,0 +1 @@
export { default as zhCN } from './zh-CN';

View File

@ -0,0 +1,5 @@
export default {
'Verification send failed, please try later or contact to administrator': '验证码发送失败,请稍后重试或联系管理员',
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
"Please don't retry in {{time}} seconds": '请 {{time}} 秒后再试'
};

View File

@ -0,0 +1,32 @@
import path from 'path';
import { requireModule } from '@nocobase/utils';
import Plugin from '../Plugin';
import { PROVIDER_TYPE_SMS_ALIYUN } from '../constants';
export class Provider {
constructor(protected plugin: Plugin, protected options) {}
async send(receiver: string, data: { [key: string]: any }): Promise<any>{}
}
interface Providers {
[key: string]: typeof Provider
}
export default function(plugin: Plugin, more: Providers = {}) {
const { providers } = plugin;
const natives = [
PROVIDER_TYPE_SMS_ALIYUN
].reduce((result, key) => Object.assign(result, {
[key]: requireModule(path.isAbsolute(key) ? key : path.join(__dirname, key)) as typeof Provider
}), {} as Providers);
for (const [name, provider] of Object.entries({ ...more, ...natives })) {
providers.register(name, provider);
}
}

View File

@ -0,0 +1,60 @@
import DysmsApi, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
import * as OpenApi from '@alicloud/openapi-client';
import { RuntimeOptions } from '@alicloud/tea-util';
import { Provider } from '.';
export default class extends Provider {
client: DysmsApi;
constructor(plugin, options) {
super(plugin, options);
const { accessKeyId, accessKeySecret, endpoint } = this.options;
let config = new OpenApi.Config({
// 您的 AccessKey ID
accessKeyId: accessKeyId,
// 您的 AccessKey Secret
accessKeySecret: accessKeySecret,
});
// 访问的域名
config.endpoint = endpoint;
this.client = new DysmsApi(config);
}
async send(phoneNumbers, data = {}) {
const request = new SendSmsRequest({
phoneNumbers,
signName: this.options.sign,
templateCode: this.options.template,
templateParam: JSON.stringify(data)
});
const { i18n } = this.plugin.app;
try {
const { body } = await this.client.sendSmsWithOptions(request, new RuntimeOptions({}));
let err = new Error();
switch (body.code) {
case 'OK':
break;
case 'isv.MOBILE_NUMBER_ILLEGAL':
err.name = 'InvalidReceiver';
return Promise.reject(err);
case 'isv.BUSINESS_LIMIT_CONTROL':
// should not let user to know
default:
console.error(body);
err.name = 'SendSMSFailed';
return Promise.reject(err);
}
} catch (error) {
return Promise.reject(error);
}
}
}

View File

@ -23,6 +23,7 @@
"@nocobase/plugin-ui-routes-storage": "0.7.4-alpha.7",
"@nocobase/plugin-ui-schema-storage": "0.7.4-alpha.7",
"@nocobase/plugin-users": "0.7.4-alpha.7",
"@nocobase/plugin-verification": "0.7.4-alpha.7",
"@nocobase/plugin-workflow": "0.7.4-alpha.7",
"@nocobase/server": "0.7.4-alpha.7"
},

View File

@ -1,9 +1,28 @@
import { Plugin } from '@nocobase/server';
export class PresetNocobase extends Plugin {
export class PresetNocoBase<O = any> extends Plugin {
getName(): string {
return this.getPackageName(__dirname);
}
beforeLoad(): void {
this.app.loadPluginConfig([
'@nocobase/plugin-error-handler',
'@nocobase/plugin-collection-manager',
'@nocobase/plugin-ui-schema-storage',
'@nocobase/plugin-ui-routes-storage',
'@nocobase/plugin-file-manager',
'@nocobase/plugin-system-settings',
'@nocobase/plugin-verification',
'@nocobase/plugin-users',
'@nocobase/plugin-acl',
'@nocobase/plugin-china-region',
'@nocobase/plugin-workflow',
'@nocobase/plugin-client',
'@nocobase/plugin-export',
'@nocobase/plugin-audit-logs',
]);
}
}
export default PresetNocobase;
export default PresetNocoBase;

View File

@ -23,6 +23,9 @@
"@nocobase/plugin-*": [
"packages/plugins/*/src"
],
"@nocobase/preset-*": [
"packages/presets/*/src"
],
"@nocobase/utils/client": [
"packages/core/utils/src/client"
],

104
yarn.lock
View File

@ -2,6 +2,80 @@
# yarn lockfile v1
"@alicloud/credentials@^2":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@alicloud/credentials/-/credentials-2.2.3.tgz#6c479082e3f627311e2537c0552e3e87a8ecd671"
integrity sha512-h98BZimKCQ5xKiFCdWa2OMYaenEP6g5Fndm/l0zp8iuYWOShnvEwBPFmHyHmwpb60KwEJp0LILl1WiBTS+5a1w==
dependencies:
"@alicloud/tea-typescript" "^1.5.3"
httpx "^2.2.0"
ini "^1.3.5"
kitx "^2.0.0"
"@alicloud/dysmsapi20170525@2.0.17":
version "2.0.17"
resolved "https://registry.yarnpkg.com/@alicloud/dysmsapi20170525/-/dysmsapi20170525-2.0.17.tgz#a350a443f52456b823772345dd57cc5fe2e6c8da"
integrity sha512-bgA4eIYJdZrKFSkCh/DGiMVnrO/O4/izpgecWiR+Cn0pxZkA3T0olYvE32+SLxJH/oBhdX6JTfuxtjnzvLKSug==
dependencies:
"@alicloud/endpoint-util" "^0.0.1"
"@alicloud/openapi-client" "^0.4.1"
"@alicloud/openapi-util" "^0.2.9"
"@alicloud/tea-typescript" "^1.7.1"
"@alicloud/tea-util" "^1.4.4"
"@alicloud/endpoint-util@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz#b237f5e04e373abb54c42119377b30bd6afb1a7c"
integrity sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==
dependencies:
"@alicloud/tea-typescript" "^1.5.1"
kitx "^2.0.0"
"@alicloud/gateway-spi@^0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz#1d251986ed40d8b98690dcac8128fec0c56f0f53"
integrity sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==
dependencies:
"@alicloud/credentials" "^2"
"@alicloud/tea-typescript" "^1.7.1"
"@alicloud/openapi-client@0.4.1", "@alicloud/openapi-client@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@alicloud/openapi-client/-/openapi-client-0.4.1.tgz#8e55a3bdfee17a00c2ec3d10feaf7bfd0eff7502"
integrity sha512-FNGQ9kmGi0AuNFX9yj7DDbMn8CB4TvihIIQ3ONNhLZF8Hj2asau7NiqVoftJX3F/JFq9qzJWeVbvboSM9Zq5kA==
dependencies:
"@alicloud/credentials" "^2"
"@alicloud/gateway-spi" "^0.0.8"
"@alicloud/openapi-util" "^0.2.7"
"@alicloud/tea-typescript" "^1.7.1"
"@alicloud/tea-util" "^1.4.0"
"@alicloud/openapi-util@^0.2.7", "@alicloud/openapi-util@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@alicloud/openapi-util/-/openapi-util-0.2.9.tgz#2379cd81f993dcab32066a2b892ddcbdd266d51c"
integrity sha512-GUEYtX3lDv+WaZoDFCb0h9aZ8+IlajnSAxSHjiITbNtjCpZbA/vfd7Z/ST9YaPoT34nGqDNKiQTjqpLhaKtYBw==
dependencies:
"@alicloud/tea-typescript" "^1.7.1"
"@alicloud/tea-util" "^1.3.0"
kitx "^2.1.0"
sm3 "^1.0.3"
"@alicloud/tea-typescript@^1.5.1", "@alicloud/tea-typescript@^1.5.3", "@alicloud/tea-typescript@^1.7.1":
version "1.7.5"
resolved "https://registry.yarnpkg.com/@alicloud/tea-typescript/-/tea-typescript-1.7.5.tgz#d8afa092f79e545ebe2aa89fd70acb0efad2aed1"
integrity sha512-YyRMQaR+zURKBYkA+ckzS/m/5FH6x5P2oihCXLNZ6y0zmeRZPBfl8Rmr+mfPdCQA8ujkRDUn0zvj8M5kd7T0MQ==
dependencies:
"@types/node" "^12.0.2"
httpx "^2.2.6"
"@alicloud/tea-util@1.4.4", "@alicloud/tea-util@^1.3.0", "@alicloud/tea-util@^1.4.0", "@alicloud/tea-util@^1.4.4":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@alicloud/tea-util/-/tea-util-1.4.4.tgz#d70efe2d401bdd6e2fc2c5b69bbfff3db733e844"
integrity sha512-uD2lMmVMSdcmv2rHTzfp2eW4OkyFig/qwBClz3Vh65qd+j6d+bjwGI1M6AnqNg9gL0yhF1Kb2Um8ljA14w1SRw==
dependencies:
"@alicloud/tea-typescript" "^1.5.1"
kitx "^2.0.0"
"@ampproject/remapping@^2.1.0":
version "2.2.0"
resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
@ -5171,6 +5245,16 @@
resolved "https://registry.npmjs.org/@types/node/-/node-16.11.7.tgz#36820945061326978c42a01e56b61cd223dfdc42"
integrity sha512-QB5D2sqfSjCmTuWcBWyJ+/44bcjO7VbjSbOE0ucoVbAsSNQc4Lt6QkgkVXkTDwkL4z/beecZNDvVX15D4P8Jbw==
"@types/node@^12.0.2":
version "12.20.55"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240"
integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==
"@types/node@^14":
version "14.18.23"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.23.tgz#70f5f20b0b1b38f696848c1d3647bb95694e615e"
integrity sha512-MhbCWN18R4GhO8ewQWAFK4TGQdBpXWByukz7cWyJmXhvRuCIaM/oWytGPqVmDzgEnnaIc9ss6HbU5mUi+vyZPA==
"@types/node@^14.14.28":
version "14.17.34"
resolved "https://registry.npmjs.org/@types/node/-/node-14.17.34.tgz#fe4b38b3f07617c0fa31ae923fca9249641038f0"
@ -11865,6 +11949,14 @@ https-proxy-agent@5, https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
httpx@^2.2.0, httpx@^2.2.6:
version "2.2.7"
resolved "https://registry.yarnpkg.com/httpx/-/httpx-2.2.7.tgz#1e34198146e32ca3305a66c11209559e1cbeba09"
integrity sha512-Wjh2JOAah0pdczfqL8NC5378G7jMt0Zcpn8U+yyxAiejjlagzSTQgJHuVvka2VNPQlKfoGehYRc79WKq9E4gDw==
dependencies:
"@types/node" "^14"
debug "^4.1.1"
human-signals@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
@ -14069,6 +14161,13 @@ kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
kitx@^2.0.0, kitx@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/kitx/-/kitx-2.1.0.tgz#fc7fbf78eb6ed7a5a3fd2d7afb3011e29d0e44c8"
integrity sha512-C/5v9MtIX7aHGOjwn5BmrrbNkJSf7i0R5mRzmh13GSAdRqQ7bYQo/Su2pTYNylFicqKNTVX3HML9k1u8k51+pQ==
dependencies:
"@types/node" "^12.0.2"
kleur@^3.0.0, kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
@ -20394,6 +20493,11 @@ slide@^1.1.6:
resolved "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
sm3@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sm3/-/sm3-1.0.3.tgz#0051f0cc948c983944843136e7baa244eec5cd49"
integrity sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==
smart-buffer@^4.1.0:
version "4.2.0"
resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"