From 099a906b4a55a9abbb1cb44fdc72002c35b78e12 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sat, 21 Jan 2023 17:17:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7/=E6=B8=B8=E5=AE=A2=E8=AE=A4=E9=A2=86?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=97=B6=E8=BF=9B=E8=A1=8C=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C(=E9=85=8D=E7=BD=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/packages/design/components/index.ts | 6 +- client/shared/model/config.ts | 1 + client/shared/model/user.ts | 20 ++++- .../components/modals/ClaimTemporaryUser.tsx | 83 ++++++++++++++++--- .../src/routes/Entry/ForgetPasswordView.tsx | 1 - client/web/src/routes/Entry/RegisterView.tsx | 41 ++++++++- server/models/user/user.ts | 8 ++ .../packages/sdk/src/services/lib/settings.ts | 1 + server/services/core/config.service.ts | 1 + server/services/core/user/user.service.ts | 78 ++++++++++++++++- server/views/mail.ejs | 2 +- 11 files changed, 220 insertions(+), 22 deletions(-) diff --git a/client/packages/design/components/index.ts b/client/packages/design/components/index.ts index d852e73a..71061365 100644 --- a/client/packages/design/components/index.ts +++ b/client/packages/design/components/index.ts @@ -15,5 +15,9 @@ export { createFastifyFormSchema as createMetaFormSchema, fieldSchema as metaFormFieldSchema, useFastifyFormContext as useMetaFormContext, + useFastifyFormContext, +} from 'react-fastify-form'; +export type { + FastifyFormFieldMeta as MetaFormFieldMeta, + FastifyFormFieldProps, } from 'react-fastify-form'; -export type { FastifyFormFieldMeta as MetaFormFieldMeta } from 'react-fastify-form'; diff --git a/client/shared/model/config.ts b/client/shared/model/config.ts index 56726c44..1374245f 100644 --- a/client/shared/model/config.ts +++ b/client/shared/model/config.ts @@ -13,6 +13,7 @@ export interface GlobalConfig { let globalConfig = { uploadFileLimit: 1 * 1024 * 1024, + emailVerification: false, // 是否在注册时校验邮箱 }; export function getGlobalConfig() { diff --git a/client/shared/model/user.ts b/client/shared/model/user.ts index a6eb4a9a..ac34cf90 100644 --- a/client/shared/model/user.ts +++ b/client/shared/model/user.ts @@ -98,6 +98,18 @@ export async function loginWithToken(token: string): Promise { return data; } +/** + * 发送邮箱校验码 + * @param email 邮箱 + */ +export async function verifyEmail(email: string): Promise { + const { data } = await request.post('/api/user/verifyEmail', { + email, + }); + + return data; +} + /** * 邮箱注册账号 * @param email 邮箱 @@ -105,11 +117,13 @@ export async function loginWithToken(token: string): Promise { */ export async function registerWithEmail( email: string, - password: string + password: string, + emailOTP?: string ): Promise { const { data } = await request.post('/api/user/register', { email, password, + emailOTP, }); return data; @@ -174,12 +188,14 @@ export async function createTemporaryUser( export async function claimTemporaryUser( userId: string, email: string, - password: string + password: string, + emailOTP?: string ): Promise { const { data } = await request.post('/api/user/claimTemporaryUser', { userId, email, password, + emailOTP, }); return data; diff --git a/client/web/src/components/modals/ClaimTemporaryUser.tsx b/client/web/src/components/modals/ClaimTemporaryUser.tsx index 9ae5d201..308568b0 100644 --- a/client/web/src/components/modals/ClaimTemporaryUser.tsx +++ b/client/web/src/components/modals/ClaimTemporaryUser.tsx @@ -1,8 +1,10 @@ import { setUserJWT } from '@/utils/jwt-helper'; import { setGlobalUserLoginInfo } from '@/utils/user-helper'; -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { claimTemporaryUser, + model, + showErrorToasts, t, useAppDispatch, useAsyncRequest, @@ -13,33 +15,88 @@ import { MetaFormFieldMeta, metaFormFieldSchema, WebMetaForm, + FastifyFormFieldProps, + useFastifyFormContext, } from 'tailchat-design'; import { ModalWrapper } from '../Modal'; +import { Button, Input } from 'antd'; +import _compact from 'lodash/compact'; +import { getGlobalConfig } from 'tailchat-shared/model/config'; interface Values { email: string; password: string; + emailOTP?: string; [key: string]: unknown; } -const fields: MetaFormFieldMeta[] = [ - { type: 'text', name: 'email', label: t('邮箱') }, - { - type: 'password', - name: 'password', - label: t('密码'), - }, -]; +const getFields = (): MetaFormFieldMeta[] => + _compact([ + { type: 'text', name: 'email', label: t('邮箱') }, + getGlobalConfig().emailVerification && { + type: 'custom', + name: 'emailOTP', + label: t('邮箱校验码'), + render: (props: FastifyFormFieldProps) => { + const context = useFastifyFormContext(); + const email = context?.values?.['email']; + const [sended, setSended] = useState(false); + + const [{ loading }, handleVerifyEmail] = useAsyncRequest(async () => { + if (!email) { + showErrorToasts(t('邮箱不能为空')); + return; + } + await model.user.verifyEmail(email); + setSended(true); + }, [email]); + + return ( + + props.onChange(e.target.value)} + /> + + {!sended && ( + + )} + + ); + }, + }, + { + type: 'password', + name: 'password', + label: t('密码'), + }, + ]); const schema = createMetaFormSchema({ email: metaFormFieldSchema .string() - .required(t('邮箱不能为空')) - .email(t('邮箱格式不正确')), + .email(t('邮箱格式不正确')) + .required(t('邮箱不能为空')), password: metaFormFieldSchema .string() .min(6, t('密码不能低于6位')) .required(t('密码不能为空')), + emailOTP: metaFormFieldSchema.string().length(6, t('校验码为6位')), }); interface ClaimTemporaryUserProps { @@ -50,13 +107,15 @@ export const ClaimTemporaryUser: React.FC = React.memo( (props) => { const userId = props.userId; const dispatch = useAppDispatch(); + const fields = useMemo(() => getFields(), []); const [{}, handleClaim] = useAsyncRequest( async (values: Values) => { const data = await claimTemporaryUser( userId, values.email, - values.password + values.password, + values.emailOTP ); setGlobalUserLoginInfo(data); diff --git a/client/web/src/routes/Entry/ForgetPasswordView.tsx b/client/web/src/routes/Entry/ForgetPasswordView.tsx index 108f527d..fee0b3f5 100644 --- a/client/web/src/routes/Entry/ForgetPasswordView.tsx +++ b/client/web/src/routes/Entry/ForgetPasswordView.tsx @@ -7,7 +7,6 @@ import { useAsyncRequest, } from 'tailchat-shared'; import React, { useState } from 'react'; -import { Spinner } from '../../components/Spinner'; import { string } from 'yup'; import { useNavToView } from './utils'; import { EntryInput } from './components/Input'; diff --git a/client/web/src/routes/Entry/RegisterView.tsx b/client/web/src/routes/Entry/RegisterView.tsx index 855dc2eb..69297b24 100644 --- a/client/web/src/routes/Entry/RegisterView.tsx +++ b/client/web/src/routes/Entry/RegisterView.tsx @@ -1,6 +1,13 @@ -import { isValidStr, registerWithEmail, t, useAsyncFn } from 'tailchat-shared'; +import { + isValidStr, + model, + registerWithEmail, + showSuccessToasts, + t, + useAsyncFn, + useAsyncRequest, +} from 'tailchat-shared'; import React, { useState } from 'react'; -import { Spinner } from '../../components/Spinner'; import { string } from 'yup'; import { Icon } from 'tailchat-design'; import { useNavigate } from 'react-router'; @@ -11,6 +18,7 @@ import { useNavToView } from './utils'; import { EntryInput } from './components/Input'; import { SecondaryBtn } from './components/SecondaryBtn'; import { PrimaryBtn } from './components/PrimaryBtn'; +import { getGlobalConfig } from 'tailchat-shared/model/config'; /** * 注册视图 @@ -19,6 +27,7 @@ export const RegisterView: React.FC = React.memo(() => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [emailOTP, setEmailOTP] = useState(''); + const [sendedEmail, setSendedEmail] = useState(false); const navigate = useNavigate(); const navRedirect = useSearchParam('redirect'); @@ -45,6 +54,13 @@ export const RegisterView: React.FC = React.memo(() => { } }, [email, password, emailOTP, navRedirect]); + const [{ loading: sendEmailLoading }, handleSendEmail] = + useAsyncRequest(async () => { + await model.user.verifyEmail(email); + showSuccessToasts(t('发送成功, 请检查你的邮箱。')); + setSendedEmail(true); + }, [email]); + const navToView = useNavToView(); return ( @@ -63,6 +79,27 @@ export const RegisterView: React.FC = React.memo(() => { /> + {getGlobalConfig().emailVerification && ( + <> + {!sendedEmail && ( + + {t('向邮箱发送校验码')} + + )} + +
+
{t('邮箱校验码')}
+ setEmailOTP(e.target.value)} + /> +
+ + )} +
{t('密码')}
) { + const email = ctx.params.email; + const cacheKey = this.buildVerifyEmailKey(email); + + const c = await this.broker.cacher.get(cacheKey); + if (!!c) { + // 如果有一个忘记密码请求未到期 + throw new Error(t('过于频繁的请求,10 分钟内可以共用同一OTP')); + } + + const otp = generateRandomNumStr(6); // 产生一次性6位数字密码 + await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟 + + const html = ` +

您正在尝试注册 Tailchat, 请使用以下 OTP 作为邮箱验证凭证:

+

OTP: ${otp}

+

该 OTP 将会在 10分钟 后过期

+

如果并不是您触发的注册操作,请忽略此电子邮件。

`; + + await ctx.call('mail.sendMail', { + to: email, + subject: 'Tailchat 邮箱验证', + html, + }); + } + /** * 用户注册 */ async register( ctx: TcPureContext< - { username?: string; email?: string; password: string }, + { + username?: string; + email?: string; + password: string; + emailOTP?: string; + }, any > ) { @@ -289,6 +336,16 @@ class UserService extends TcService { nickname ); + if (config.emailVerification === true) { + // 检查OTP + const cacheKey = this.buildVerifyEmailKey(params.email); + const cachedOtp = await this.broker.cacher.get(cacheKey); + + if (String(cachedOtp) !== params.emailOTP) { + throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); + } + } + const password = await this.hashPassword(params.password); const doc = await this.adapter.insert({ ...params, @@ -370,6 +427,7 @@ class UserService extends TcService { username?: string; email: string; password: string; + emailOTP?: string; }> ) { const params = ctx.params; @@ -383,6 +441,16 @@ class UserService extends TcService { throw new Error(t('该用户不是临时用户')); } + if (config.emailVerification === true) { + // 检查OTP + const cacheKey = this.buildVerifyEmailKey(params.email); + const cachedOtp = await this.broker.cacher.get(cacheKey); + + if (String(cachedOtp) !== params.emailOTP) { + throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); + } + } + await this.validateRegisterParams(params, t); const password = await this.hashPassword(params.password); @@ -414,7 +482,7 @@ class UserService extends TcService { const c = await this.broker.cacher.get(cacheKey); if (!!c) { // 如果有一个忘记密码请求未到期 - throw new Error(t('过于频繁的请求,请 10 分钟以后再试')); + throw new Error(t('过于频繁的请求,10 分钟内可以共用同一OTP')); } const otp = generateRandomNumStr(6); // 产生一次性6位数字密码 @@ -913,6 +981,10 @@ class UserService extends TcService { private buildOpenapiBotEmail(botId: string) { return `${botId}@tailchat-openapi.com`; } + + private buildVerifyEmailKey(email: string) { + return `verify-email:${email}`; + } } export default UserService; diff --git a/server/views/mail.ejs b/server/views/mail.ejs index 55e52000..9f51d9f1 100644 --- a/server/views/mail.ejs +++ b/server/views/mail.ejs @@ -10,7 +10,7 @@
- +