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:
parent
259393f626
commit
7e6a394f73
10
.env.example
10
.env.example
@ -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=
|
||||
|
@ -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;
|
||||
|
@ -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": "添加角色",
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
64
packages/core/client/src/user/VerificationCode.tsx
Normal file
64
packages/core/client/src/user/VerificationCode.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -16,6 +16,11 @@ export default defineCollection({
|
||||
name: 'allowSignUp',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'smsAuthEnabled',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'logo',
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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>({
|
||||
|
24
packages/plugins/users/src/authenticators/index.ts
Normal file
24
packages/plugins/users/src/authenticators/index.ts
Normal 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);
|
||||
}
|
||||
}
|
31
packages/plugins/users/src/authenticators/password.ts
Normal file
31
packages/plugins/users/src/authenticators/password.ts
Normal 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();
|
||||
}
|
@ -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',
|
||||
|
@ -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': '手机号未注册,请先注册',
|
||||
};
|
||||
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
@ -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 = {}) {
|
||||
|
25
packages/plugins/verification/package.json
Normal file
25
packages/plugins/verification/package.json
Normal 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"
|
||||
}
|
141
packages/plugins/verification/src/Plugin.ts
Normal file
141
packages/plugins/verification/src/Plugin.ts
Normal 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');
|
||||
}
|
||||
}
|
162
packages/plugins/verification/src/__tests__/Plugin.test.ts
Normal file
162
packages/plugins/verification/src/__tests__/Plugin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'authors',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'phone'
|
||||
}
|
||||
]
|
||||
} as CollectionOptions;
|
39
packages/plugins/verification/src/__tests__/index.ts
Normal file
39
packages/plugins/verification/src/__tests__/index.ts
Normal 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;
|
||||
}
|
14
packages/plugins/verification/src/actions/index.ts
Normal file
14
packages/plugins/verification/src/actions/index.ts
Normal 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)
|
||||
});
|
||||
}
|
103
packages/plugins/verification/src/actions/verifications.ts
Normal file
103
packages/plugins/verification/src/actions/verifications.ts
Normal 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();
|
||||
});
|
||||
}
|
@ -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',
|
||||
}
|
||||
]
|
||||
};
|
@ -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'
|
||||
}
|
||||
]
|
||||
};
|
4
packages/plugins/verification/src/constants.ts
Normal file
4
packages/plugins/verification/src/constants.ts
Normal 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;
|
5
packages/plugins/verification/src/index.ts
Normal file
5
packages/plugins/verification/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './constants';
|
||||
export { Provider } from './providers';
|
||||
export { Interceptor, default } from './Plugin';
|
||||
|
||||
export const namespace = require('../package.json').name;
|
1
packages/plugins/verification/src/locale/index.ts
Normal file
1
packages/plugins/verification/src/locale/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as zhCN } from './zh-CN';
|
5
packages/plugins/verification/src/locale/zh-CN.ts
Normal file
5
packages/plugins/verification/src/locale/zh-CN.ts
Normal 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}} 秒后再试'
|
||||
};
|
32
packages/plugins/verification/src/providers/index.ts
Normal file
32
packages/plugins/verification/src/providers/index.ts
Normal 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);
|
||||
}
|
||||
}
|
60
packages/plugins/verification/src/providers/sms-aliyun.ts
Normal file
60
packages/plugins/verification/src/providers/sms-aliyun.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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
104
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user