feat: 增加邮箱认证功能

feat/uniplus
moonrailgun 2 years ago
parent ba7399fc7f
commit 2e774d104f

@ -27,6 +27,7 @@
"k1cbe2507": "Confirm",
"k1d8e2fac": "View group details",
"k1ea177bd": "Group privacy control to prevent malicious access to member information through groups",
"k1ea9f5ff": "Verified",
"k206eff71": "Nickname can not be blank",
"k21ee7a1f": "Roles",
"k22856100": "Not found",
@ -53,6 +54,7 @@
"k323b5cc7": "Recall",
"k3279c602": "Add now",
"k32905632": "Unlimited invitation link",
"k335c71bf": "OTP code cannot be empty",
"k34b5e3ab": "Send Message",
"k34e357ee": "Group Summary",
"k35abe359": "Lobby",
@ -61,6 +63,7 @@
"k3662c0d4": "Please select a panel",
"k378f66fc": "Unmute",
"k393892b6": "Upload original image",
"k3a31dae3": "Verification email sent",
"k3ac17670": "An exception occurred, store create failed",
"k3b4b656d": "About",
"k3bbf3bbd": "Register Account",
@ -99,6 +102,7 @@
"k547a7a99": "This record was not found",
"k551b0348": "Password",
"k56f9469b": "No friends yet",
"k570b61fc": "Send verification email to {{email}}",
"k57ab4d97": "Please select user",
"k58a85592": "Is not a valid plugin configuration",
"k5a0084e7": "Modify group configuration",
@ -186,6 +190,7 @@
"k9b91079c": "All readed",
"k9bb01902": "Show Detail",
"k9d5a843a": "Mail Service",
"k9d80acdf": "Verify email",
"k9d901c20": "Meeting room",
"k9dfa2c97": "never expires",
"k9f3089ce": "Create",
@ -239,6 +244,7 @@
"kb76d94e0": "Refresh",
"kb7a57f24": "Plugin Registry Service",
"kb8185132": "Or",
"kb8ec7062": "Email verification passed",
"kb96b79c5": "Allow management of invitation links",
"kbc76781d": "No permission to send messages, please contact the group owner",
"kbcacf812": "Are you sure to clear the inbox?",
@ -274,6 +280,7 @@
"kd455acf4": "Sent successfully, please check your email.",
"kd4ff36fa": "Search Friends",
"kd637a30": "Group Invite Service",
"kd7096593": "Unverified",
"kd8d2b865": "Group Configuration",
"kd955767f": "This invitation code never expires",
"kd983a61a": "{{nickname}} recall a message",

@ -27,6 +27,7 @@
"k1cbe2507": "确认",
"k1d8e2fac": "查看群组详情",
"k1ea177bd": "群组隐私控制,防止通过群组恶意获取成员信息",
"k1ea9f5ff": "已认证",
"k206eff71": "昵称不能为空",
"k21ee7a1f": "身份组",
"k22856100": "未找到",
@ -53,6 +54,7 @@
"k323b5cc7": "撤回",
"k3279c602": "立即添加",
"k32905632": "不限时邀请链接",
"k335c71bf": "校验码不能为空",
"k34b5e3ab": "发送消息",
"k34e357ee": "群组概述",
"k35abe359": "大厅",
@ -61,6 +63,7 @@
"k3662c0d4": "请选择面板",
"k378f66fc": "解除禁言",
"k393892b6": "上传原图",
"k3a31dae3": "已发送认证邮件",
"k3ac17670": "出现异常, Store 创建失败",
"k3b4b656d": "关于",
"k3bbf3bbd": "注册账号",
@ -99,6 +102,7 @@
"k547a7a99": "没有找到该记录",
"k551b0348": "密码",
"k56f9469b": "暂无好友",
"k570b61fc": "向 {{email}} 发送认证邮件",
"k57ab4d97": "请选择用户",
"k58a85592": "不是一个合法的插件配置",
"k5a0084e7": "修改群组配置",
@ -186,6 +190,7 @@
"k9b91079c": "所有已读",
"k9bb01902": "显示详情",
"k9d5a843a": "邮件服务",
"k9d80acdf": "认证邮箱",
"k9d901c20": "会议室",
"k9dfa2c97": "永不过期",
"k9f3089ce": "创建",
@ -239,6 +244,7 @@
"kb76d94e0": "刷新",
"kb7a57f24": "插件中心服务",
"kb8185132": "或",
"kb8ec7062": "邮箱验证通过",
"kb96b79c5": "允许管理邀请链接",
"kbc76781d": "没有发送消息的权限, 请联系群组所有者",
"kbcacf812": "确认清空收件箱么?",
@ -274,6 +280,7 @@
"kd455acf4": "发送成功, 请检查你的邮箱。",
"kd4ff36fa": "查找好友",
"kd637a30": "群组邀请服务",
"kd7096593": "未认证",
"kd8d2b865": "群组配置",
"kd955767f": "该邀请码永不过期",
"kd983a61a": "{{nickname}} 撤回了一条消息",

@ -14,6 +14,7 @@ export interface UserBaseInfo {
discriminator: string;
avatar: string | null;
temporary: boolean;
emailVerified: boolean;
extra?: Record<string, unknown>;
}
@ -110,6 +111,20 @@ export async function verifyEmail(email: string): Promise<UserLoginInfo> {
return data;
}
/**
*
* @param email
*/
export async function verifyEmailWithOTP(
emailOTP: string
): Promise<UserLoginInfo> {
const { data } = await request.post('/api/user/verifyEmailWithOTP', {
emailOTP,
});
return data;
}
/**
*
* @param email

@ -0,0 +1,120 @@
import { setUserJWT } from '@/utils/jwt-helper';
import { setGlobalUserLoginInfo } from '@/utils/user-helper';
import React, { useMemo, useState } from 'react';
import {
model,
showErrorToasts,
showSuccessToasts,
t,
useAppDispatch,
useAsyncRequest,
userActions,
useUserInfo,
} from 'tailchat-shared';
import {
createMetaFormSchema,
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';
import { Problem } from '../Problem';
interface Values {
emailOTP: string;
[key: string]: unknown;
}
const fields: MetaFormFieldMeta[] = [
{
type: 'text',
name: 'emailOTP',
placeholder: t('6位校验码'),
label: t('邮箱校验码'),
},
];
const schema = createMetaFormSchema({
emailOTP: metaFormFieldSchema
.string()
.length(6, t('校验码为6位'))
.required(t('校验码不能为空')),
});
export const EmailVerify: React.FC<{
onSuccess?: () => void;
}> = React.memo((props) => {
const dispatch = useAppDispatch();
const [sended, setSended] = useState(false);
const userInfo = useUserInfo();
const [{ loading }, handleSendEmail] = useAsyncRequest(async () => {
if (!userInfo) {
return;
}
await model.user.verifyEmail(userInfo.email);
setSended(true);
}, [userInfo?.email]);
const [, handleVerifyEmail] = useAsyncRequest(
async (values: Values) => {
const data = await model.user.verifyEmailWithOTP(values.emailOTP);
setGlobalUserLoginInfo(data);
dispatch(userActions.setUserInfo(data));
showSuccessToasts(t('邮箱验证通过'));
if (typeof props.onSuccess === 'function') {
props.onSuccess();
}
},
[userInfo?.email, props.onSuccess]
);
if (!userInfo) {
return <Problem />;
}
return (
<ModalWrapper title={t('认证邮箱')}>
{!sended ? (
<>
<Button
className="mb-2"
type="primary"
block={true}
size="large"
loading={loading}
onClick={handleSendEmail}
>
{t('向 {{email}} 发送认证邮件', {
email: userInfo.email,
})}
</Button>
<Button
type="text"
block={true}
size="large"
onClick={() => setSended(true)}
>
{t('已发送认证邮件')}
</Button>
</>
) : (
<WebMetaForm
schema={schema}
fields={fields}
onSubmit={handleVerifyEmail}
/>
)}
</ModalWrapper>
);
});
EmailVerify.displayName = 'EmailVerify';

@ -8,7 +8,7 @@ import { closeModal, pluginUserExtraInfo } from '@/plugin/common';
import { getGlobalSocket } from '@/utils/global-state-helper';
import { setUserJWT } from '@/utils/jwt-helper';
import { setGlobalUserLoginInfo } from '@/utils/user-helper';
import { Button, Divider, Typography } from 'antd';
import { Button, Divider, Tag, Typography } from 'antd';
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Avatar } from 'tailchat-design';
@ -24,6 +24,7 @@ import {
userActions,
useUserInfo,
} from 'tailchat-shared';
import { EmailVerify } from '../EmailVerify';
import { ModifyPassword } from '../ModifyPassword';
export const SettingsAccount: React.FC = React.memo(() => {
@ -111,6 +112,36 @@ export const SettingsAccount: React.FC = React.memo(() => {
onSave={handleUpdateNickName}
/>
<FullModalField
title={t('邮箱')}
content={
<div>
<span className="mr-1">{userInfo.email}</span>
{userInfo.emailVerified ? (
<Tag color="success" className="select-none">
{t('已认证')}
</Tag>
) : (
<Tag
color="warning"
className="cursor-pointer"
onClick={() => {
const key = openModal(
<EmailVerify
onSuccess={() => {
closeModal(key);
}}
/>
);
}}
>
{t('未认证')}
</Tag>
)}
</div>
}
/>
{pluginUserExtraInfo.map((item, i) => {
if (item.component && item.component.editor) {
const Component = item.component.editor;

@ -11,6 +11,7 @@
border-color: #434343;
background: transparent;
&:hover,
&:focus {
color: var(--antd-primary-border);
@ -58,6 +59,10 @@
}
}
}
&.ant-btn-text{
border-color: transparent;
}
}
.ant-switch {

@ -4,7 +4,9 @@
"k17f8532": "No message found",
"k1b3d8c72": "Group not found",
"k1bdc50f": "Please enter a username with a unique identifier such as: Nickname#0000",
"k206592b2": "Email has been verified",
"k21e507de": "Unable to delete private message",
"k236bb718": "Email sending failed",
"k2bb4fb6d": "OTP incorrect",
"k313eb9b3": "User does not exist, please check your username",
"k3b35c0b0": "No permission to create invitation codes",
@ -33,6 +35,7 @@
"kb5971793": "Username or email is empty",
"kb8be9969": "Recall failed, no permission",
"kba207c17": "No permission to view",
"kbb1ef795": "Verification failed, OTP has expired",
"kbb96754b": "Group OP not allowed to be kicked out",
"kc1e668f5": "Not allowed to kick yourself out",
"kc4b77045": "{{nickname}} join this group with invite code from {{creator}}",

@ -4,7 +4,9 @@
"k17f8532": "没有找到消息",
"k1b3d8c72": "群组未找到",
"k1bdc50f": "请输入带唯一标识的用户名 如: Nickname#0000",
"k206592b2": "邮箱已认证",
"k21e507de": "无法删除私人信息",
"k236bb718": "邮件发送失败",
"k2bb4fb6d": "OTP 不正确",
"k313eb9b3": "用户不存在, 请检查您的用户名",
"k3b35c0b0": "没有创建邀请码权限",
@ -33,6 +35,7 @@
"kb5971793": "用户名或邮箱为空",
"kb8be9969": "撤回失败, 没有权限",
"kba207c17": "没有查看权限",
"kbb1ef795": "校验失败, OTP已过期",
"kbb96754b": "不允许踢出群组OP",
"kc1e668f5": "不允许踢出自己",
"kc4b77045": "{{nickname}} 通过 {{creator}} 的邀请码加入群组",

@ -40,4 +40,6 @@ export interface UserStruct {
avatar?: string;
type: UserType[];
emailVerified: boolean;
}

@ -51,19 +51,24 @@ class MailService extends TcService {
}
const { to, subject, html } = ctx.params;
const { t } = ctx.meta;
const info = await this.adapter.model.sendMail({
to,
subject,
html: await ejs.renderFile(
path.resolve(__dirname, '../../../views/mail.ejs'),
{
body: html,
}
),
});
try {
const info = await this.adapter.model.sendMail({
to,
subject,
html: await ejs.renderFile(
path.resolve(__dirname, '../../../views/mail.ejs'),
{
body: html,
}
),
});
this.logger.info('sendMailSuccess:', info);
this.logger.info('sendMailSuccess:', info);
} catch (err) {
throw new Error(t('邮件发送失败'));
}
}
}

@ -18,7 +18,7 @@ import {
DataNotFoundError,
EntityError,
db,
t,
call,
} from 'tailchat-server-sdk';
import {
generateRandomNumStr,
@ -53,6 +53,7 @@ class UserService extends TcService {
'temporary',
'avatar',
'type',
'emailVerified',
'extra',
'createdAt',
]);
@ -70,6 +71,11 @@ class UserService extends TcService {
email: 'email',
},
});
this.registerAction('verifyEmailWithOTP', this.verifyEmailWithOTP, {
params: {
emailOTP: 'string',
},
});
this.registerAction('register', this.register, {
rest: 'POST /register',
params: {
@ -287,6 +293,7 @@ class UserService extends TcService {
*/
async verifyEmail(ctx: TcPureContext<{ email: string }>) {
const email = ctx.params.email;
const t = ctx.meta.t;
const cacheKey = this.buildVerifyEmailKey(email);
const c = await this.broker.cacher.get(cacheKey);
@ -296,19 +303,63 @@ class UserService extends TcService {
}
const otp = generateRandomNumStr(6); // 产生一次性6位数字密码
await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟
const html = `
<p> Tailchat, 使 OTP :</p>
<p> Tailchat , 使 OTP :</p>
<h3>OTP: <strong>${otp}</strong></h3>
<p> OTP 10 </p>
<p style="color: grey;"></p>`;
<p style="color: grey;"></p>`;
await ctx.call('mail.sendMail', {
to: email,
subject: 'Tailchat 邮箱验证',
subject: `Tailchat 邮箱验证: ${otp}`,
html,
});
await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟
return true;
}
/**
* OTP,
*/
async verifyEmailWithOTP(ctx: TcContext<{ emailOTP: string }>) {
const emailOTP = ctx.params.emailOTP;
const userId = ctx.meta.userId;
const t = ctx.meta.t;
const userInfo = await call(ctx).getUserInfo(userId);
if (userInfo.emailVerified === true) {
throw new Error(t('邮箱已认证'));
}
// 检查
const cacheKey = this.buildVerifyEmailKey(userInfo.email);
const cachedOTP = await this.broker.cacher.get(cacheKey);
if (!cachedOTP) {
throw new Error(t('校验失败, OTP已过期'));
}
if (String(cachedOTP) !== emailOTP) {
throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP'));
}
// 验证通过
const user = await this.adapter.model.findOneAndUpdate(
{
_id: new Types.ObjectId(userId),
},
{
emailVerified: true,
},
{
new: true,
}
);
await this.cleanCurrentUserCache(ctx);
return this.transformDocuments(ctx, {}, user);
}
/**
@ -340,9 +391,13 @@ class UserService extends TcService {
if (config.emailVerification === true) {
// 检查OTP
const cacheKey = this.buildVerifyEmailKey(params.email);
const cachedOtp = await this.broker.cacher.get(cacheKey);
const cachedOTP = await this.broker.cacher.get(cacheKey);
if (String(cachedOtp) !== params.emailOTP) {
if (!cachedOTP) {
throw new Error(t('校验失败, OTP已过期'));
}
if (String(cachedOTP) !== params.emailOTP) {
throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP'));
}
@ -448,9 +503,13 @@ class UserService extends TcService {
if (config.emailVerification === true) {
// 检查OTP
const cacheKey = this.buildVerifyEmailKey(params.email);
const cachedOtp = await this.broker.cacher.get(cacheKey);
const cachedOTP = await this.broker.cacher.get(cacheKey);
if (!cachedOTP) {
throw new Error(t('校验失败, OTP已过期'));
}
if (String(cachedOtp) !== params.emailOTP) {
if (String(cachedOTP) !== params.emailOTP) {
throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP'));
}
@ -492,7 +551,6 @@ class UserService extends TcService {
}
const otp = generateRandomNumStr(6); // 产生一次性6位数字密码
await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟
const html = `
<p> 使 OTP :</p>
@ -502,9 +560,13 @@ class UserService extends TcService {
await ctx.call('mail.sendMail', {
to: email,
subject: 'Tailchat 忘记密码',
subject: `Tailchat 忘记密码: ${otp}`,
html,
});
await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟
return true;
}
/**
@ -521,8 +583,13 @@ class UserService extends TcService {
const { t } = ctx.meta;
const cacheKey = `forget-password:${email}`;
const cachedOtp = await this.broker.cacher.get(cacheKey);
if (String(cachedOtp) !== otp) {
const cachedOTP = await this.broker.cacher.get(cacheKey);
if (!cachedOTP) {
throw new Error(t('校验失败, OTP已过期'));
}
if (String(cachedOTP) !== otp) {
throw new Error(t('OTP 不正确'));
}
@ -988,6 +1055,9 @@ class UserService extends TcService {
return `${botId}@tailchat-openapi.com`;
}
/**
* key
*/
private buildVerifyEmailKey(email: string) {
return `verify-email:${email}`;
}

@ -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://tailchat.msgbyte.com/img/logo.svg" />
<img width="50" height="50" src="https://tailchat.msgbyte.com/img/logo@192.png" />
</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