Skip to content

Commit

Permalink
feat: support password reset
Browse files Browse the repository at this point in the history
  • Loading branch information
Kerwin committed Apr 16, 2023
1 parent 77c8a32 commit f0f1cfb
Show file tree
Hide file tree
Showing 20 changed files with 360 additions and 44 deletions.
42 changes: 40 additions & 2 deletions service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ import {
updateChat,
updateConfig,
updateUserInfo,
updateUserPassword,
verifyUser,
} from './storage/mongo'
import { limiter } from './middleware/limiter'
import { isEmail, isNotEmptyString } from './utils/is'
import { sendNoticeMail, sendTestMail, sendVerifyMail, sendVerifyMailAdmin } from './utils/mail'
import { checkUserVerify, checkUserVerifyAdmin, getUserVerifyUrl, getUserVerifyUrlAdmin, md5 } from './utils/security'
import { sendNoticeMail, sendResetPasswordMail, sendTestMail, sendVerifyMail, sendVerifyMailAdmin } from './utils/mail'
import { checkUserResetPassword, checkUserVerify, checkUserVerifyAdmin, getUserResetPasswordUrl, getUserVerifyUrl, getUserVerifyUrlAdmin, md5 } from './utils/security'
import { rootAuth } from './middleware/rootAuth'

dotenv.config()
Expand Down Expand Up @@ -468,6 +469,43 @@ router.post('/user-login', async (req, res) => {
}
})

router.post('/user-send-reset-mail', async (req, res) => {
try {
const { username } = req.body as { username: string }
if (!username || !isEmail(username))
throw new Error('请输入格式正确的邮箱 | Please enter a correctly formatted email address.')

const user = await getUser(username)
if (user == null || user.status !== Status.Normal)
throw new Error('账户状态异常 | Account status abnormal.')
await sendResetPasswordMail(username, await getUserResetPasswordUrl(username))
res.send({ status: 'Success', message: '重置邮件已发送 | Reset email has been sent', data: null })
}
catch (error) {
res.send({ status: 'Fail', message: error.message, data: null })
}
})

router.post('/user-reset-password', async (req, res) => {
try {
const { username, password, sign } = req.body as { username: string; password: string; sign: string }
if (!username || !password || !isEmail(username))
throw new Error('用户名或密码为空 | Username or password is empty')
if (!sign || !checkUserResetPassword(sign, username))
throw new Error('链接失效, 请重新发送 | The link is invalid, please resend.')
const user = await getUser(username)
if (user == null || user.status !== Status.Normal)
throw new Error('账户状态异常 | Account status abnormal.')

updateUserPassword(user._id.toString(), md5(password))

res.send({ status: 'Success', message: '密码重置成功 | Password reset successful', data: null })
}
catch (error) {
res.send({ status: 'Fail', message: error.message, data: null })
}
})

router.post('/user-info', auth, async (req, res) => {
try {
const { name, avatar, description } = req.body as UserInfo
Expand Down
2 changes: 2 additions & 0 deletions service/src/storage/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ export class UserInfo {
verifyTime?: string
avatar?: string
description?: string
updateTime?: string
constructor(email: string, password: string) {
this.name = email
this.email = email
this.password = password
this.status = Status.PreVerify
this.createTime = new Date().toLocaleString()
this.verifyTime = null
this.updateTime = new Date().toLocaleString()
}
}

Expand Down
6 changes: 6 additions & 0 deletions service/src/storage/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export async function updateUserInfo(userId: string, user: UserInfo) {
return result
}

export async function updateUserPassword(userId: string, password: string) {
const result = userCol.updateOne({ _id: new ObjectId(userId) }
, { $set: { password, updateTime: new Date().toLocaleString() } })
return result
}

export async function getUser(email: string): Promise<UserInfo> {
email = email.toLowerCase()
return await userCol.findOne({ email }) as UserInfo
Expand Down
10 changes: 10 additions & 0 deletions service/src/utils/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export async function sendVerifyMailAdmin(toMail: string, verifyName: string, ve
sendMail(toMail, `${config.siteConfig.siteTitle} 账号申请`, mailHtml, config.mailConfig)
}

export async function sendResetPasswordMail(toMail: string, verifyUrl: string) {
const config = (await getCacheConfig())
const templatesPath = path.join(__dirname, 'templates')
const mailTemplatePath = path.join(templatesPath, 'mail.resetpassword.template.html')
let mailHtml = fs.readFileSync(mailTemplatePath, 'utf8')
mailHtml = mailHtml.replace(/\${VERIFY_URL}/g, verifyUrl)
mailHtml = mailHtml.replace(/\${SITE_TITLE}/g, config.siteConfig.siteTitle)
sendMail(toMail, `${config.siteConfig.siteTitle} 密码重置`, mailHtml, config.mailConfig)
}

export async function sendNoticeMail(toMail: string) {
const config = (await getCacheConfig())

Expand Down
46 changes: 30 additions & 16 deletions service/src/utils/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,30 @@ export async function getUserVerifyUrl(username: string) {
}

function getUserVerify(username: string) {
return getVerify(username, '')
}
function getVerify(username: string, key: string) {
const expired = new Date().getTime() + (12 * 60 * 60 * 1000)
const sign = `${username}-${expired}`
const sign = `${username}${key}-${expired}`
return `${sign}-${md5(sign)}`
}

export function checkUserVerify(verify: string) {
function checkVerify(verify: string) {
const vs = verify.split('-')
const sign = vs[vs.length - 1]
const expired = vs[vs.length - 2]
vs.splice(vs.length - 2, 2)
const username = vs.join('-')
const prefix = vs.join('-')
// 简单点没校验有效期
if (sign === md5(`${username}-${expired}`))
return username
if (sign === md5(`${prefix}-${expired}`))
return prefix.split('|')[0]
throw new Error('Verify failed')
}

export function checkUserVerify(verify: string) {
return checkVerify(verify)
}

// 可以换 aes 等方式
export async function getUserVerifyUrlAdmin(username: string) {
const sign = getUserVerifyAdmin(username)
Expand All @@ -44,19 +51,26 @@ export async function getUserVerifyUrlAdmin(username: string) {
}

function getUserVerifyAdmin(username: string) {
const expired = new Date().getTime() + (12 * 60 * 60 * 1000)
const sign = `${username}|${process.env.ROOT_USER}-${expired}`
return `${sign}-${md5(sign)}`
return getVerify(username, `|${process.env.ROOT_USER}`)
}

export function checkUserVerifyAdmin(verify: string) {
const vs = verify.split('-')
const sign = vs[vs.length - 1]
const expired = vs[vs.length - 2]
vs.splice(vs.length - 2, 2)
const username = vs.join('-')
// 简单点没校验有效期
if (sign === md5(`${username}-${expired}`))
return username.split('|')[0]
return checkVerify(verify)
}

export async function getUserResetPasswordUrl(username: string) {
const sign = getUserResetPassword(username)
const config = await getCacheConfig()
return `${config.siteConfig.siteDomain}/#/chat/?verifyresetpassword=${sign}`
}

function getUserResetPassword(username: string) {
return getVerify(username, '|rp')
}

export function checkUserResetPassword(verify: string, username: string) {
const name = checkVerify(verify)
if (name === username)
return name
throw new Error('Verify failed')
}
3 changes: 1 addition & 2 deletions service/src/utils/templates/mail.admin.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@
margin-bottom: 16px;
">
<hr>
<span class="text_3" style="
<div style=" font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<span class="text_3" style=" font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<h1 style="color: #0088cc;">
账号申请邮箱:${TO_MAIL},账号开通链接为(12小时内有效):
</span>
Expand Down
3 changes: 1 addition & 2 deletions service/src/utils/templates/mail.notice.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@
margin-bottom: 16px;
">
<hr>
<span class="text_3" style="
<div style=" font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<span class="text_3" style=" font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<h1 style="color: #0088cc;">
感谢您使用
<a target="_blank" style="text-decoration: none; color: #0088cc;">${SITE_TITLE}</a>
Expand Down
144 changes: 144 additions & 0 deletions service/src/utils/templates/mail.resetpassword.template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<html>

<head> </head>

<body>
<div class="page flex-col">
<div class="box_3 flex-col" style="
display: flex;
position: relative;
width: 100%;
height: 206px;
background: #ef859d2e;
top: 0;
left: 0;
justify-content: center;
">
<div class="section_1 flex-col" style="
background-image: url(&quot;https://ghproxy.com/https://raw.githubusercontent.com/Chanzhaoyu/chatgpt-web/main/src/assets/avatar.jpg&quot;);
position: absolute;
width: 152px;
height: 152px;
display: flex;
top: 130px;
background-size: cover;
border-radius: 50%;
margin: 10px;
"></div>
</div>
<div class="box_4 flex-col" style="
margin-top: 92px;
display: flex;
flex-direction: column;
align-items: center;
">
<div class="text-group_5 flex-col justify-between" style="
display: flex;
flex-direction: column;
align-items: center;
margin: 0 20px;
">
<span class="text_1" style="
font-size: 26px;
font-family: PingFang-SC-Bold, PingFang-SC;
font-weight: bold;
color: #000000;
line-height: 37px;
text-align: center;
">
<target="_blank" style="text-decoration: none; color: #0088cc;">${SITE_TITLE}</a> 重置密码
</span>

<div class="box_2 flex-row" style="
margin: 0 20px;
min-height: 128px;
background: #F7F7F7;
border-radius: 12px;
margin-top: 34px;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 32px 16px;
width: calc(100% - 40px);
">

<div class="text-wrapper_4 flex-col justify-between" style="
display: flex;
flex-direction: column;
margin-left: 30px;
margin-bottom: 16px;
">
<hr>
<span class="text_3" style=" font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<h1 style="color: #0088cc;">
感谢您使用
<a target="_blank" style="text-decoration: none; color: #0088cc;">${SITE_TITLE}</a>
您的重置密码链接为(12小时内有效):
</span>
</div>
<hr style="
display: flex;
position: relative;
border: 1px dashed #ef859d2e;
box-sizing: content-box;
height: 0px;
overflow: visible;
width: 100%;
">
<div class="text-wrapper_4 flex-col justify-between" style="
display: flex;
flex-direction: column;
margin-left: 30px;
">
<hr>
</h1>
<p style="margin-top: 20px;">
请点击以下按钮进行重置密码:
<span class="text_4" style="
margin-top: 6px;
margin-right: 22px;
font-size: 16px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #000000;
line-height: 22px;
"></span>
</div>

<a target="_blank" class="text-wrapper_2 flex-col" style="
min-width: 106px;
height: 38px;
background: #ef859d38;
border-radius: 32px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
margin: auto;
margin-top: 32px;
" href="${VERIFY_URL}">
<span class="text_5" style="
color: #DB214B;
">重置密码</span>
</a>
</div>
<div class="text-group_6 flex-col justify-between" style="
display: flex;
flex-direction: column;
align-items: center;
margin-top: 34px;
">
<span class="text_6" style="
height: 17px;
font-size: 12px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #00000045;
line-height: 17px;
">此邮件由服务器自动发出,直接回复无效。</span>
</div>
</div>
</div>
</body>

</html>
3 changes: 1 addition & 2 deletions service/src/utils/templates/mail.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@
margin-bottom: 16px;
">
<hr>
<span class="text_3" style="
<div style=" font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<span class="text_3" style="font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<h1 style="color: #0088cc;">
感谢您使用
<a target="_blank" style="text-decoration: none; color: #0088cc;">${SITE_TITLE}</a>
Expand Down
14 changes: 14 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ export function fetchLogin<T = any>(username: string, password: string) {
})
}

export function fetchSendResetMail<T = any>(username: string) {
return post<T>({
url: '/user-send-reset-mail',
data: { username },
})
}

export function fetchResetPassword<T = any>(username: string, password: string, sign: string) {
return post<T>({
url: '/user-reset-password',
data: { username, password, sign },
})
}

export function fetchRegister<T = any>(username: string, password: string) {
return post<T>({
url: '/user-register',
Expand Down

0 comments on commit f0f1cfb

Please sign in to comment.