From 7e6a394f737a24463aeed465bda7d072a5d61f55 Mon Sep 17 00:00:00 2001 From: Junyi Date: Sat, 20 Aug 2022 18:06:12 +0800 Subject: [PATCH] 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 --- .env.example | 10 ++ packages/app/server/src/config/plugins.ts | 16 +- packages/core/client/src/locale/zh_CN.ts | 8 +- .../SystemSettingsShortcut.tsx | 7 + packages/core/client/src/user/SigninPage.tsx | 119 ++++++++++--- packages/core/client/src/user/SignupPage.tsx | 35 +++- .../core/client/src/user/VerificationCode.tsx | 64 +++++++ packages/core/resourcer/src/middleware.ts | 26 +++ packages/core/sdk/src/APIClient.ts | 7 +- .../src/collections/systemSettings.ts | 5 + packages/plugins/users/package.json | 11 +- packages/plugins/users/src/actions/users.ts | 54 +++--- .../plugins/users/src/authenticators/index.ts | 24 +++ .../users/src/authenticators/password.ts | 31 ++++ .../plugins/users/src/collections/users.ts | 13 ++ packages/plugins/users/src/locale/zh-CN.ts | 9 +- .../20220818072639-add-users-phone.ts | 41 +++++ packages/plugins/users/src/server.ts | 96 ++++++++++- packages/plugins/verification/package.json | 25 +++ packages/plugins/verification/src/Plugin.ts | 141 +++++++++++++++ .../verification/src/__tests__/Plugin.test.ts | 162 ++++++++++++++++++ .../src/__tests__/collections/authors.ts | 15 ++ .../verification/src/__tests__/index.ts | 39 +++++ .../plugins/verification/src/actions/index.ts | 14 ++ .../verification/src/actions/verifications.ts | 103 +++++++++++ .../src/collections/verifications.ts | 36 ++++ .../collections/verifications_providers.ts | 22 +++ .../plugins/verification/src/constants.ts | 4 + packages/plugins/verification/src/index.ts | 5 + .../plugins/verification/src/locale/index.ts | 1 + .../plugins/verification/src/locale/zh-CN.ts | 5 + .../verification/src/providers/index.ts | 32 ++++ .../verification/src/providers/sms-aliyun.ts | 60 +++++++ packages/presets/nocobase/package.json | 1 + packages/presets/nocobase/src/index.ts | 23 ++- tsconfig.json | 3 + yarn.lock | 104 +++++++++++ 37 files changed, 1280 insertions(+), 91 deletions(-) create mode 100644 packages/core/client/src/user/VerificationCode.tsx create mode 100644 packages/plugins/users/src/authenticators/index.ts create mode 100644 packages/plugins/users/src/authenticators/password.ts create mode 100644 packages/plugins/users/src/migrations/20220818072639-add-users-phone.ts create mode 100644 packages/plugins/verification/package.json create mode 100644 packages/plugins/verification/src/Plugin.ts create mode 100644 packages/plugins/verification/src/__tests__/Plugin.test.ts create mode 100644 packages/plugins/verification/src/__tests__/collections/authors.ts create mode 100644 packages/plugins/verification/src/__tests__/index.ts create mode 100644 packages/plugins/verification/src/actions/index.ts create mode 100644 packages/plugins/verification/src/actions/verifications.ts create mode 100644 packages/plugins/verification/src/collections/verifications.ts create mode 100644 packages/plugins/verification/src/collections/verifications_providers.ts create mode 100644 packages/plugins/verification/src/constants.ts create mode 100644 packages/plugins/verification/src/index.ts create mode 100644 packages/plugins/verification/src/locale/index.ts create mode 100644 packages/plugins/verification/src/locale/zh-CN.ts create mode 100644 packages/plugins/verification/src/providers/index.ts create mode 100644 packages/plugins/verification/src/providers/sms-aliyun.ts diff --git a/.env.example b/.env.example index 2cf38ae16..2e559a3ce 100644 --- a/.env.example +++ b/.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= diff --git a/packages/app/server/src/config/plugins.ts b/packages/app/server/src/config/plugins.ts index 15cfec3de..6c6b41324 100644 --- a/packages/app/server/src/config/plugins.ts +++ b/packages/app/server/src/config/plugins.ts @@ -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; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index f8e8eacf0..62920fe93 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -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": "添加角色", diff --git a/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx b/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx index 7d2f97f4f..c5bca9e12 100644 --- a/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx +++ b/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx @@ -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', diff --git a/packages/core/client/src/user/SigninPage.tsx b/packages/core/client/src/user/SigninPage.tsx index 0b4f1c32c..dd741b587 100644 --- a/packages/core/client/src/user/SigninPage.tsx +++ b/packages/core/client/src/user/SigninPage.tsx @@ -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(); 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 (
- + {smsAuthEnabled ? ( + + + + + + + + + ) : ( + + )} + {allowSignUp && ( +
+ {t('Create an account')} +
+ )}
); }; diff --git a/packages/core/client/src/user/SignupPage.tsx b/packages/core/client/src/user/SignupPage.tsx index cffdd51d4..031288995 100644 --- a/packages/core/client/src/user/SignupPage.tsx +++ b/packages/core/client/src/user/SignupPage.tsx @@ -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 ; } - return ; + return ( + + ); }; diff --git a/packages/core/client/src/user/VerificationCode.tsx b/packages/core/client/src/user/VerificationCode.tsx new file mode 100644 index 000000000..fd02c7390 --- /dev/null +++ b/packages/core/client/src/user/VerificationCode.tsx @@ -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(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 ( +
+ + +
+ ); +} diff --git a/packages/core/resourcer/src/middleware.ts b/packages/core/resourcer/src/middleware.ts index 56281978c..1fe8d2fa2 100644 --- a/packages/core/resourcer/src/middleware.ts +++ b/packages/core/resourcer/src/middleware.ts @@ -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); + }; +} diff --git a/packages/core/sdk/src/APIClient.ts b/packages/core/sdk/src/APIClient.ts index 5c1ffb1e5..33bd8ab21 100644 --- a/packages/core/sdk/src/APIClient.ts +++ b/packages/core/sdk/src/APIClient.ts @@ -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> { + async signIn(values, authenticator: string = 'password'): Promise> { const response = await this.api.request({ method: 'post', url: 'users:signin', data: values, + params: { + authenticator + } }); const data = response?.data?.data; this.setToken(data?.token); diff --git a/packages/plugins/system-settings/src/collections/systemSettings.ts b/packages/plugins/system-settings/src/collections/systemSettings.ts index 61ca30948..828728fff 100644 --- a/packages/plugins/system-settings/src/collections/systemSettings.ts +++ b/packages/plugins/system-settings/src/collections/systemSettings.ts @@ -16,6 +16,11 @@ export default defineCollection({ name: 'allowSignUp', defaultValue: true, }, + { + type: 'boolean', + name: 'smsAuthEnabled', + defaultValue: false + }, { type: 'belongsTo', name: 'logo', diff --git a/packages/plugins/users/package.json b/packages/plugins/users/package.json index 1e93b8666..d69b25f4a 100644 --- a/packages/plugins/users/package.json +++ b/packages/plugins/users/package.json @@ -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" } diff --git a/packages/plugins/users/src/actions/users.ts b/packages/plugins/users/src/actions/users.ts index e514e59ab..481d19848 100644 --- a/packages/plugins/users/src/actions/users.ts +++ b/packages/plugins/users/src/actions/users.ts @@ -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 { authenticators, jwtService } = ctx.app.getPlugin('@nocobase/plugin-users'); + const branches = {}; + for (const [name, authenticator] of authenticators.getEntities()) { + branches[name] = authenticator; } - const User = ctx.db.getCollection('users'); - const user = await User.model.findOne({ - where: { - [uniqueField]: values[uniqueField], - }, + + 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, + token, + }; + + return next(); }); - if (!user) { - ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace })); - } - const pwd = User.getField('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 pluginUser = ctx.app.getPlugin('@nocobase/plugin-users'); - - ctx.body = { - ...user.toJSON(), - token: pluginUser.jwtService.sign({ - userId: user.get('id'), - }), - }; - await 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({ @@ -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({ diff --git a/packages/plugins/users/src/authenticators/index.ts b/packages/plugins/users/src/authenticators/index.ts new file mode 100644 index 000000000..4a5a9d34c --- /dev/null +++ b/packages/plugins/users/src/authenticators/index.ts @@ -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({ ...more, ...natives })) { + authenticators.register(name, authenticator); + } +} diff --git a/packages/plugins/users/src/authenticators/password.ts b/packages/plugins/users/src/authenticators/password.ts new file mode 100644 index 000000000..a8d3a599b --- /dev/null +++ b/packages/plugins/users/src/authenticators/password.ts @@ -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({ + 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('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(); +} diff --git a/packages/plugins/users/src/collections/users.ts b/packages/plugins/users/src/collections/users.ts index 24c96d7f0..2e276eb58 100644 --- a/packages/plugins/users/src/collections/users.ts +++ b/packages/plugins/users/src/collections/users.ts @@ -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', diff --git a/packages/plugins/users/src/locale/zh-CN.ts b/packages/plugins/users/src/locale/zh-CN.ts index fef37fa26..a8650e64b 100644 --- a/packages/plugins/users/src/locale/zh-CN.ts +++ b/packages/plugins/users/src/locale/zh-CN.ts @@ -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': '手机号未注册,请先注册', }; diff --git a/packages/plugins/users/src/migrations/20220818072639-add-users-phone.ts b/packages/plugins/users/src/migrations/20220818072639-add-users-phone.ts new file mode 100644 index 000000000..5b6540ca1 --- /dev/null +++ b/packages/plugins/users/src/migrations/20220818072639-add-users-phone.ts @@ -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() { + + } +} diff --git a/packages/plugins/users/src/server.ts b/packages/plugins/users/src/server.ts index 0524dda5f..d55699242 100644 --- a/packages/plugins/users/src/server.ts +++ b/packages/plugins/users/src/server.ts @@ -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 { public jwtService: JwtService; - public tokenMiddleware; + public tokenMiddleware: MiddlewareManager; + + public authenticators: Registry = new Registry(); constructor(app, options) { super(app, options); @@ -30,7 +37,7 @@ export default class UsersPlugin extends Plugin { if (cmd) { cmd.requiredOption('-e, --root-email ', '', process.env.INIT_ROOT_EMAIL); cmd.requiredOption('-p, --root-password ', '', process.env.INIT_ROOT_PASSWORD); - cmd.option('-n, --root-nickname [rootNickname]'); + cmd.option('-n, --root-nickname '); } this.db.registerOperators({ $isCurrentUser(_, ctx) { @@ -98,6 +105,87 @@ export default class UsersPlugin extends Plugin { 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 = {}) { diff --git a/packages/plugins/verification/package.json b/packages/plugins/verification/package.json new file mode 100644 index 000000000..8895e3bab --- /dev/null +++ b/packages/plugins/verification/package.json @@ -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" +} diff --git a/packages/plugins/verification/src/Plugin.ts b/packages/plugins/verification/src/Plugin.ts new file mode 100644 index 000000000..d09623b5a --- /dev/null +++ b/packages/plugins/verification/src/Plugin.ts @@ -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; +}; + +export default class VerificationPlugin extends Plugin { + providers: Registry = new Registry(); + interceptors: Registry = 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'); + } +} diff --git a/packages/plugins/verification/src/__tests__/Plugin.test.ts b/packages/plugins/verification/src/__tests__/Plugin.test.ts new file mode 100644 index 000000000..5d65555ad --- /dev/null +++ b/packages/plugins/verification/src/__tests__/Plugin.test.ts @@ -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 = 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); + }); + }); +}); diff --git a/packages/plugins/verification/src/__tests__/collections/authors.ts b/packages/plugins/verification/src/__tests__/collections/authors.ts new file mode 100644 index 000000000..9f8f94ca4 --- /dev/null +++ b/packages/plugins/verification/src/__tests__/collections/authors.ts @@ -0,0 +1,15 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'authors', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'string', + name: 'phone' + } + ] +} as CollectionOptions; diff --git a/packages/plugins/verification/src/__tests__/index.ts b/packages/plugins/verification/src/__tests__/index.ts new file mode 100644 index 000000000..add8e51bb --- /dev/null +++ b/packages/plugins/verification/src/__tests__/index.ts @@ -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 { + 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; +} diff --git a/packages/plugins/verification/src/actions/index.ts b/packages/plugins/verification/src/actions/index.ts new file mode 100644 index 000000000..37a3e4a70 --- /dev/null +++ b/packages/plugins/verification/src/actions/index.ts @@ -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) + }); +} diff --git a/packages/plugins/verification/src/actions/verifications.ts b/packages/plugins/verification/src/actions/verifications.ts new file mode 100644 index 000000000..05fbc61c0 --- /dev/null +++ b/packages/plugins/verification/src/actions/verifications.ts @@ -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 = ((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(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(); + }); +} diff --git a/packages/plugins/verification/src/collections/verifications.ts b/packages/plugins/verification/src/collections/verifications.ts new file mode 100644 index 000000000..ef23c8afe --- /dev/null +++ b/packages/plugins/verification/src/collections/verifications.ts @@ -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', + } + ] +}; diff --git a/packages/plugins/verification/src/collections/verifications_providers.ts b/packages/plugins/verification/src/collections/verifications_providers.ts new file mode 100644 index 000000000..6e37f643d --- /dev/null +++ b/packages/plugins/verification/src/collections/verifications_providers.ts @@ -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' + } + ] +}; diff --git a/packages/plugins/verification/src/constants.ts b/packages/plugins/verification/src/constants.ts new file mode 100644 index 000000000..25cfdb8a6 --- /dev/null +++ b/packages/plugins/verification/src/constants.ts @@ -0,0 +1,4 @@ +export const PROVIDER_TYPE_SMS_ALIYUN = 'sms-aliyun'; + +export const CODE_STATUS_UNUSED = 0; +export const CODE_STATUS_USED = 1; diff --git a/packages/plugins/verification/src/index.ts b/packages/plugins/verification/src/index.ts new file mode 100644 index 000000000..0f8a6e2f7 --- /dev/null +++ b/packages/plugins/verification/src/index.ts @@ -0,0 +1,5 @@ +export * from './constants'; +export { Provider } from './providers'; +export { Interceptor, default } from './Plugin'; + +export const namespace = require('../package.json').name; diff --git a/packages/plugins/verification/src/locale/index.ts b/packages/plugins/verification/src/locale/index.ts new file mode 100644 index 000000000..e854ecb6b --- /dev/null +++ b/packages/plugins/verification/src/locale/index.ts @@ -0,0 +1 @@ +export { default as zhCN } from './zh-CN'; diff --git a/packages/plugins/verification/src/locale/zh-CN.ts b/packages/plugins/verification/src/locale/zh-CN.ts new file mode 100644 index 000000000..9d58ccfa6 --- /dev/null +++ b/packages/plugins/verification/src/locale/zh-CN.ts @@ -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}} 秒后再试' +}; diff --git a/packages/plugins/verification/src/providers/index.ts b/packages/plugins/verification/src/providers/index.ts new file mode 100644 index 000000000..42676be69 --- /dev/null +++ b/packages/plugins/verification/src/providers/index.ts @@ -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{} +} + +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); + } +} diff --git a/packages/plugins/verification/src/providers/sms-aliyun.ts b/packages/plugins/verification/src/providers/sms-aliyun.ts new file mode 100644 index 000000000..3ec2d5efe --- /dev/null +++ b/packages/plugins/verification/src/providers/sms-aliyun.ts @@ -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); + } + } +} diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 50988ea64..feb3dd8f9 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -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" }, diff --git a/packages/presets/nocobase/src/index.ts b/packages/presets/nocobase/src/index.ts index e294aad5f..08c4c654e 100644 --- a/packages/presets/nocobase/src/index.ts +++ b/packages/presets/nocobase/src/index.ts @@ -1,9 +1,28 @@ import { Plugin } from '@nocobase/server'; -export class PresetNocobase extends Plugin { +export class PresetNocoBase 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; diff --git a/tsconfig.json b/tsconfig.json index 9ab0840ae..6f0e29ad0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,9 @@ "@nocobase/plugin-*": [ "packages/plugins/*/src" ], + "@nocobase/preset-*": [ + "packages/presets/*/src" + ], "@nocobase/utils/client": [ "packages/core/utils/src/client" ], diff --git a/yarn.lock b/yarn.lock index aee8b6034..de4651cee 100644 --- a/yarn.lock +++ b/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"