feat: 增加注册账号/游客认领账号时进行邮箱校验(配置)

feat/uniplus
moonrailgun 2 years ago
parent c0ecd5e25b
commit 099a906b4a

@ -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';

@ -13,6 +13,7 @@ export interface GlobalConfig {
let globalConfig = {
uploadFileLimit: 1 * 1024 * 1024,
emailVerification: false, // 是否在注册时校验邮箱
};
export function getGlobalConfig() {

@ -98,6 +98,18 @@ export async function loginWithToken(token: string): Promise<UserLoginInfo> {
return data;
}
/**
*
* @param email
*/
export async function verifyEmail(email: string): Promise<UserLoginInfo> {
const { data } = await request.post('/api/user/verifyEmail', {
email,
});
return data;
}
/**
*
* @param email
@ -105,11 +117,13 @@ export async function loginWithToken(token: string): Promise<UserLoginInfo> {
*/
export async function registerWithEmail(
email: string,
password: string
password: string,
emailOTP?: string
): Promise<UserLoginInfo> {
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<UserLoginInfo> {
const { data } = await request.post('/api/user/claimTemporaryUser', {
userId,
email,
password,
emailOTP,
});
return data;

@ -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<Values>();
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 (
<Input.Group compact style={{ display: 'flex' }}>
<Input
size="large"
name={props.name}
value={props.value}
placeholder={t('6位校验码')}
onChange={(e) => props.onChange(e.target.value)}
/>
{!sended && (
<Button
size="large"
type="primary"
htmlType="button"
disabled={loading}
loading={loading}
onClick={(e) => {
e.preventDefault();
handleVerifyEmail();
}}
>
{t('发送校验码')}
</Button>
)}
</Input.Group>
);
},
},
{
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<ClaimTemporaryUserProps> = 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);

@ -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';

@ -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(() => {
/>
</div>
{getGlobalConfig().emailVerification && (
<>
{!sendedEmail && (
<PrimaryBtn loading={sendEmailLoading} onClick={handleSendEmail}>
{t('向邮箱发送校验码')}
</PrimaryBtn>
)}
<div className="mb-4">
<div className="mb-2">{t('邮箱校验码')}</div>
<EntryInput
name="reg-email-otp"
type="text"
placeholder="6位校验码"
value={emailOTP}
onChange={(e) => setEmailOTP(e.target.value)}
/>
</div>
</>
)}
<div className="mb-4">
<div className="mb-2">{t('密码')}</div>
<EntryInput

@ -100,6 +100,14 @@ export class User extends TimeStamps implements Base {
})
type: UserType;
/**
*
*/
@prop({
default: false,
})
emailAvailable: boolean;
/**
*
*/

@ -37,6 +37,7 @@ export const config = {
apiUrl,
staticUrl: `${apiUrl}/static/`,
enableOpenapi: true, // 是否开始openapi
emailVerification: checkEnvTrusty(process.env.EMAIL_VERIFY) || false, // 是否在注册后验证邮箱可用性
smtp: {
senderName: process.env.SMTP_SENDER, // 发邮件者显示名称
connectionUrl: process.env.SMTP_URI || '',

@ -53,6 +53,7 @@ class ConfigService extends TcService {
async client(ctx: TcPureContext) {
return {
uploadFileLimit: config.storage.limit,
emailVerification: config.emailVerification,
};
}

@ -18,6 +18,7 @@ import {
DataNotFoundError,
EntityError,
db,
t,
} from 'tailchat-server-sdk';
import {
generateRandomNumStr,
@ -64,12 +65,18 @@ class UserService extends TcService {
password: 'string',
},
});
this.registerAction('verifyEmail', this.verifyEmail, {
params: {
email: 'email',
},
});
this.registerAction('register', this.register, {
rest: 'POST /register',
params: {
username: [{ type: 'string', optional: true }],
email: [{ type: 'email', optional: true }],
password: 'string',
emailOTP: [{ type: 'string', optional: true }],
},
});
this.registerAction('modifyPassword', this.modifyPassword, {
@ -90,6 +97,7 @@ class UserService extends TcService {
username: [{ type: 'string', optional: true }],
email: 'email',
password: 'string',
emailOTP: [{ type: 'string', optional: true }],
},
});
this.registerAction('forgetPassword', this.forgetPassword, {
@ -203,7 +211,11 @@ class UserService extends TcService {
visibility: 'public',
});
this.registerAuthWhitelist(['/forgetPassword', '/resetPassword']);
this.registerAuthWhitelist([
'/verifyEmail',
'/forgetPassword',
'/resetPassword',
]);
}
/**
@ -269,12 +281,47 @@ class UserService extends TcService {
return await this.transformEntity(doc, true, ctx.meta.token);
}
/**
* , OTP
*
*/
async verifyEmail(ctx: TcPureContext<{ email: string }>) {
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 = `
<p> Tailchat, 使 OTP :</p>
<h3>OTP: <strong>${otp}</strong></h3>
<p> OTP 10 </p>
<p style="color: grey;"></p>`;
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;

@ -10,7 +10,7 @@
<div style="width: 100%; min-width: 580px; margin: 0 auto; padding: 20px 0 30px; background-color: #fafafa;">
<div style="margin: 20px auto 30px; text-align: center;">
<img width="50" height="50" src="https://github.com/msgbyte/tailchat/raw/master/web/assets/images/logo/logo%40192.png" />
<img width="50" height="50" src="https://tailchat.msgbyte.com/img/logo.svg" />
</div>
<div style="width: 580px; background-color: white; margin: auto; padding: 24px 48px; border: 1px solid #dddddd; overflow-wrap: break-word;border-radius: 3px;">

Loading…
Cancel
Save