From 47452697ca53400d978a4eb5e06eb20898a4ceee Mon Sep 17 00:00:00 2001 From: fanqiangbiegouyao Date: Fri, 8 Nov 2024 22:42:39 +0800 Subject: [PATCH] =?UTF-8?q?1.=E6=B7=BB=E5=8A=A0=E4=BA=86=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E9=87=8D=E7=BD=AE=E5=AF=86=E7=A0=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=92=8C=E9=A1=B5=E9=9D=A2=E3=80=82=202.=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E4=BA=86=E5=88=9B=E5=BB=BA=E7=94=A8=E6=88=B7=E7=9A=84?= =?UTF-8?q?Email=E6=A0=A1=E9=AA=8C=E6=AD=A3=E5=88=99=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/base/base.py | 41 ++++++++++- app/controllers/user.py | 20 +++++- app/models/admin.py | 2 + app/schemas/users.py | 11 +++ app/settings/config.py | 9 +++ app/templates/cn/reset_password.html | 35 ++++++++++ app/templates/en/reset_password.html | 35 ++++++++++ app/utils/password.py | 38 +++++++++++ web/i18n/index.js | 9 +++ web/i18n/messages/cn.json | 15 +++- web/i18n/messages/en.json | 15 +++- web/src/api/index.js | 2 + web/src/router/guard/auth-guard.js | 17 ++++- web/src/router/routes/index.js | 20 ++++++ web/src/views/forgot-password/index.vue | 91 +++++++++++++++++++++++++ web/src/views/login/index.vue | 11 +++ web/src/views/reset-password/index.vue | 79 +++++++++++++++++++++ web/src/views/system/user/index.vue | 68 +++++++++--------- 18 files changed, 476 insertions(+), 42 deletions(-) create mode 100644 app/templates/cn/reset_password.html create mode 100644 app/templates/en/reset_password.html create mode 100644 web/src/views/forgot-password/index.vue create mode 100644 web/src/views/reset-password/index.vue diff --git a/app/api/v1/base/base.py b/app/api/v1/base/base.py index 3ba5655..78d1b6b 100644 --- a/app/api/v1/base/base.py +++ b/app/api/v1/base/base.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta, timezone -from fastapi import APIRouter +from urllib import parse +from fastapi import APIRouter, Request, HTTPException from app.controllers.user import user_controller from app.core.ctx import CTX_USER_ID @@ -8,10 +9,11 @@ from app.models.admin import Api, Menu, Role, User from app.schemas.base import Fail, Success from app.schemas.login import * -from app.schemas.users import UpdatePassword +from app.schemas.users import UpdatePassword, ForgetPasswordSchema, ResetPasswordSchema +from app.core.bgtask import BgTasks from app.settings import settings from app.utils.jwt import create_access_token -from app.utils.password import get_password_hash, verify_password +from app.utils.password import get_password_hash, verify_password, send_forgot_password_email router = APIRouter() @@ -37,6 +39,39 @@ async def login_access_token(credentials: CredentialsSchema): return Success(data=data.model_dump()) +@router.post("/forgot_password", summary="忘记密码") +async def forget_password(request: Request, forget_password_schema: ForgetPasswordSchema): + email_address = forget_password_schema.email + user_obj = await user_controller.get_by_email(email_address) + if not user_obj: + raise HTTPException(status_code=404, detail="notFound") + + if not user_obj.is_active: + raise HTTPException(status_code=403, detail="userHasDisabled") + + uuid_v4 = await user_controller.forgot_password(email_address) + referer_url = request.headers.get("referer") + if not referer_url: + raise HTTPException(status_code=400, detail="refererUrlNotFound") + + reset_url = parse.urljoin(referer_url, f"/reset-password/{uuid_v4}") + await BgTasks.add_task(send_forgot_password_email, + language=forget_password_schema.language, + reset_url=reset_url, + app_title=settings.APP_TITLE, + to_address=email_address + ) + + return Success(code=200, msg="sendEmailSuccess") + + +@router.post('/rest_password', summary="重置密码") +async def rest_password(reset_password_schema: ResetPasswordSchema): + await user_controller.reset_password_by_token(reset_password_schema.reset_token, + reset_password_schema.password) + return Success(code=200, msg="resetSuccessful") + + @router.get("/userinfo", summary="查看用户信息", dependencies=[DependAuth]) async def get_userinfo(): user_id = CTX_USER_ID.get() diff --git a/app/controllers/user.py b/app/controllers/user.py index 9059a61..485ec29 100644 --- a/app/controllers/user.py +++ b/app/controllers/user.py @@ -1,6 +1,6 @@ from datetime import datetime from typing import List, Optional - +from uuid import uuid4 from fastapi.exceptions import HTTPException from app.core.crud import CRUDBase @@ -56,5 +56,23 @@ async def reset_password(self, user_id: int): user_obj.password = get_password_hash(password="123456") await user_obj.save() + async def forgot_password(self, email: str) -> str: + user_obj = await self.get_by_email(email) + if not user_obj: + raise HTTPException(status_code=404, detail="emailNotFound") + uuid_str = str(uuid4()) + user_obj.reset_token = uuid_str + user_obj.reset_triggered = datetime.now() + await user_obj.save() + return uuid_str + + async def reset_password_by_token(self, reset_token: str, password: str): + user_obj = await self.model.filter(reset_token=reset_token).first() + if not user_obj: + raise HTTPException(status_code=404, detail="tokenNotFound") + user_obj.password = get_password_hash(password=password) + user_obj.reset_token = None + await user_obj.save() + user_controller = UserController() diff --git a/app/models/admin.py b/app/models/admin.py index 44e641a..844ce6c 100644 --- a/app/models/admin.py +++ b/app/models/admin.py @@ -15,6 +15,8 @@ class User(BaseModel, TimestampMixin): is_active = fields.BooleanField(default=True, description="是否激活", index=True) is_superuser = fields.BooleanField(default=False, description="是否为超级管理员", index=True) last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True) + reset_token = fields.CharField(max_length=128, null=True, description="重置密码的token") + reset_triggered = fields.DatetimeField(null=True, description="重置触发时间") roles = fields.ManyToManyField("models.Role", related_name="user_roles") dept_id = fields.IntField(null=True, description="部门ID", index=True) diff --git a/app/schemas/users.py b/app/schemas/users.py index 4a1e7cd..3c2d517 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -42,3 +42,14 @@ class UserUpdate(BaseModel): class UpdatePassword(BaseModel): old_password: str = Field(description="旧密码") new_password: str = Field(description="新密码") + + +class ForgetPasswordSchema(BaseModel): + email: str + language: str + + +class ResetPasswordSchema(BaseModel): + reset_token: str + password: str + diff --git a/app/settings/config.py b/app/settings/config.py index 0a82d5e..aeb400d 100644 --- a/app/settings/config.py +++ b/app/settings/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): APP_TITLE: str = "Vue FastAPI Admin" PROJECT_NAME: str = "Vue FastAPI Admin" APP_DESCRIPTION: str = "Description" + EMAIL_ADDRESS: str = "do_not_reply@vue_fastapi_admin.com" CORS_ORIGINS: typing.List = ["*"] CORS_ALLOW_CREDENTIALS: bool = True @@ -19,6 +20,7 @@ class Settings(BaseSettings): PROJECT_ROOT: str = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) BASE_DIR: str = os.path.abspath(os.path.join(PROJECT_ROOT, os.pardir)) + TEMPLATES_ROOT: str = os.path.join(BASE_DIR, "app/templates") LOGS_ROOT: str = os.path.join(BASE_DIR, "app/logs") SECRET_KEY: str = "3488a63e1765035d386f05409663f55c83bfae3b3c61a932744b20ad14244dcf" # openssl rand -hex 32 JWT_ALGORITHM: str = "HS256" @@ -87,6 +89,13 @@ class Settings(BaseSettings): }, "use_tz": False, # Whether to use timezone-aware datetimes "timezone": "Asia/Shanghai", # Timezone setting + + } + + # Email Server configuration + MAIL_AUTH: dict = { + "host": "SMTP server address", + "port": 25 } DATETIME_FORMAT: str = "%Y-%m-%d %H:%M:%S" diff --git a/app/templates/cn/reset_password.html b/app/templates/cn/reset_password.html new file mode 100644 index 0000000..1e27d8b --- /dev/null +++ b/app/templates/cn/reset_password.html @@ -0,0 +1,35 @@ + + + + + [{{ app_title }}] 密码重置请求 + + +
+

点击下面的按钮重置您的 [{{ app_title }}] 帐户的密码

+ 重置密码 +

未请求重置此密码?忽略它即可。

+
+ + + + \ No newline at end of file diff --git a/app/templates/en/reset_password.html b/app/templates/en/reset_password.html new file mode 100644 index 0000000..5ee8435 --- /dev/null +++ b/app/templates/en/reset_password.html @@ -0,0 +1,35 @@ + + + + + [{{ app_title }}] Password Reset Request + + +
+

Click the button below to reset the password for your [{{ app_title }}] account

+ Reset Password +

Didn't request this password reset? It's safe to ignore it.

+
+ + + + \ No newline at end of file diff --git a/app/utils/password.py b/app/utils/password.py index 48f5dc8..f7d6e8d 100644 --- a/app/utils/password.py +++ b/app/utils/password.py @@ -1,5 +1,15 @@ +import os +from jinja2 import Template from passlib import pwd from passlib.context import CryptContext +from smtplib import SMTP +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from loguru import logger + +from app.settings import settings + pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") @@ -14,3 +24,31 @@ def get_password_hash(password: str) -> str: def generate_password() -> str: return pwd.genword() + + +async def send_forgot_password_email(language: str, reset_url: str, app_title: str, to_address: str): + if language == 'cn': + template_path = os.path.join(settings.TEMPLATES_ROOT, 'cn', 'reset_password.html') + else: + template_path = os.path.join(settings.TEMPLATES_ROOT, 'en', 'reset_password.html') + + with open(template_path, 'r', encoding='utf-8') as f: + template = Template(f.read()) + + render = template.render(reset_url=reset_url, app_title=app_title) + msg = MIMEMultipart() + msg['From'] = settings.EMAIL_ADDRESS + msg['To'] = to_address + msg['Subject'] = f'[{app_title}] Password Reset Request' + + # 添加邮件内容 + msg.attach(MIMEText(render, 'html')) + + try: + with SMTP(**settings.MAIL_AUTH) as smtp: + smtp.send_message( + msg + ) + logger.debug(f"send email success({to_address})") + except Exception as e: + logger.error(f"send email error({to_address}): %s", e) \ No newline at end of file diff --git a/web/i18n/index.js b/web/i18n/index.js index b593e7c..e43dcf7 100644 --- a/web/i18n/index.js +++ b/web/i18n/index.js @@ -13,4 +13,13 @@ const i18n = createI18n({ messages: messages, }) +/** + * 如果从来没有切换过i18的语言 那么存储里面会缺少*locale*这个变量 + * 我也不晓得为啥子上面 createI18n 不设置这个初始变量 + * 所以用下面的代码来凑合解决一下 + **/ +if (!lStorage.get('locale')) { + lStorage.set('locale', currentLocale || 'cn') +} + export default i18n diff --git a/web/i18n/messages/cn.json b/web/i18n/messages/cn.json index 86061d4..1cb313c 100644 --- a/web/i18n/messages/cn.json +++ b/web/i18n/messages/cn.json @@ -13,7 +13,8 @@ "text_login": "登录", "message_input_username_password": "请输入用户名和密码", "message_verifying": "正在验证...", - "message_login_success": "登录成功" + "message_login_success": "登录成功", + "forget_password": "忘记密码" }, "workbench": { "label_workbench": "工作台", @@ -49,6 +50,18 @@ "errors": { "label_error": "错误页", "text_back_to_home": "返回首页" + }, + "forget_password": { + "forget_password": "忘记密码", + "send_password_reset_email": "发送密码重置邮件", + "message_forget_password_success": "密码重置邮件已发送", + "sendEmailSuccess": "检查您的电子邮件以获取有关如何重置密码的说明", + "notFound": "邮件地址不存在" + }, + "resetPassword": { + "resetPassword": "重置密码", + "resetSuccessful": "密码重置成功", + "tokenNotFound": "链接已失效" } }, "common": { diff --git a/web/i18n/messages/en.json b/web/i18n/messages/en.json index 0e2075c..90bd41e 100644 --- a/web/i18n/messages/en.json +++ b/web/i18n/messages/en.json @@ -13,7 +13,8 @@ "text_login": "Login", "message_input_username_password": "Please enter username and password", "message_verifying": "Verifying...", - "message_login_success": "Login successful" + "message_login_success": "Login successful", + "forget_password": "Forget password" }, "workbench": { "label_workbench": "Workbench", @@ -49,6 +50,18 @@ "errors": { "label_error": "Error", "text_back_to_home": "Back to home" + }, + "forget_password": { + "forget_password": "Forgot Password", + "send_password_reset_email": "Send password reset email.", + "message_forget_password_success": "Password reset email has been sent.", + "sendEmailSuccess": "Check your email for instructions on how to reset your password.", + "notFound": "Email not found" + }, + "resetPassword": { + "resetPassword": "Reset Password", + "resetSuccessful": "Password reset successful", + "tokenNotFound": "The link has expired." } }, "common": { diff --git a/web/src/api/index.js b/web/src/api/index.js index 440a7e2..5decb9e 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -2,6 +2,8 @@ import { request } from '@/utils' export default { login: (data) => request.post('/base/access_token', data, { noNeedToken: true }), + forgotPassword: (data) => request.post('/base/forgot_password', data, { noNeedToken: true }), + emailResetPassword: (data) => request.post('/base/rest_password/', data), getUserInfo: () => request.get('/base/userinfo'), getUserMenu: () => request.get('/base/usermenu'), getUserApi: () => request.get('/base/userapi'), diff --git a/web/src/router/guard/auth-guard.js b/web/src/router/guard/auth-guard.js index 230e75a..073aa1b 100644 --- a/web/src/router/guard/auth-guard.js +++ b/web/src/router/guard/auth-guard.js @@ -1,18 +1,29 @@ import { getToken, isNullOrWhitespace } from '@/utils' -const WHITE_LIST = ['/login', '/404'] +const WHITE_LIST = ['/login', '/404', '/forgot-password', /^\/reset-password\/.*/] export function createAuthGuard(router) { router.beforeEach(async (to) => { const token = getToken() - /** 没有token的情况 */ + /** 添加了正则路由 **/ if (isNullOrWhitespace(token)) { - if (WHITE_LIST.includes(to.path)) return true + // 检查目标路径是否在白名单中 + const isInWhiteList = WHITE_LIST.some((pattern) => { + if (typeof pattern === 'string') { + return pattern === to.path // 匹配普通路径 + } + return pattern.test(to.path) // 匹配正则表达式 + }) + if (isInWhiteList) return true return { path: 'login', query: { ...to.query, redirect: to.path } } } /** 有token的情况 */ if (to.path === '/login') return { path: '/' } + /** 所有拼接的没有权限的非法路由都会跳转到 404, 而不是一个白板*/ + if (router.getRoutes().some((route) => route.path === to.path)) { + return true + } return true }) } diff --git a/web/src/router/routes/index.js b/web/src/router/routes/index.js index cd07ee3..d2902b6 100644 --- a/web/src/router/routes/index.js +++ b/web/src/router/routes/index.js @@ -116,6 +116,26 @@ export const basicRoutes = [ title: '登录页', }, }, + /** 添加了忘记密码和重置密码的路由 **/ + { + name: 'ForGotPassword', + path: '/forgot-password', + component: () => import('@/views/forgot-password/index.vue'), + isHidden: true, + meta: { + title: t('views.forget_password.forget_password'), + }, + }, + { + name: 'RestPassword', + path: '/reset-password/:resetToken', + component: () => import('@/views/reset-password/index.vue'), + isHidden: true, + props: true, + meta: { + title: t('views.resetPassword.resetPassword'), + }, + }, ] export const NOT_FOUND_ROUTE = { diff --git a/web/src/views/forgot-password/index.vue b/web/src/views/forgot-password/index.vue new file mode 100644 index 0000000..1648ff7 --- /dev/null +++ b/web/src/views/forgot-password/index.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/web/src/views/login/index.vue b/web/src/views/login/index.vue index 0fc52ea..0742c56 100644 --- a/web/src/views/login/index.vue +++ b/web/src/views/login/index.vue @@ -5,6 +5,10 @@ class="m-auto max-w-1500 min-w-345 f-c-c rounded-10 bg-white bg-opacity-60 p-15 card-shadow" dark:bg-dark > + +
+ +
@@ -47,6 +51,12 @@ {{ $t('views.login.text_login') }} + +
+ + {{ $t('views.login.forget_password') }} + +
@@ -58,6 +68,7 @@ import bgImg from '@/assets/images/login_bg.webp' import api from '@/api' import { addDynamicRoutes } from '@/router' import { useI18n } from 'vue-i18n' +import Languages from '@/layout/components/header/components/Languages.vue' const router = useRouter() const { query } = useRoute() diff --git a/web/src/views/reset-password/index.vue b/web/src/views/reset-password/index.vue new file mode 100644 index 0000000..478b3d0 --- /dev/null +++ b/web/src/views/reset-password/index.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/web/src/views/system/user/index.vue b/web/src/views/system/user/index.vue index 45ec328..a108e4e 100644 --- a/web/src/views/system/user/index.vue +++ b/web/src/views/system/user/index.vue @@ -204,40 +204,41 @@ const columns = [ default: () => h('div', {}, '确定删除该用户吗?'), } ), - !row.is_superuser && h( - NPopconfirm, - { - onPositiveClick: async () => { - try { - await api.resetPassword({ user_id: row.id }); - $message.success('密码已成功重置为123456'); - await $table.value?.handleSearch(); - } catch (error) { - $message.error('重置密码失败: ' + error.message); - } + !row.is_superuser && + h( + NPopconfirm, + { + onPositiveClick: async () => { + try { + await api.resetPassword({ user_id: row.id }) + $message.success('密码已成功重置为123456') + await $table.value?.handleSearch() + } catch (error) { + $message.error('重置密码失败: ' + error.message) + } + }, + onNegativeClick: () => {}, }, - onNegativeClick: () => {}, - }, - { - trigger: () => - withDirectives( - h( - NButton, - { - size: 'small', - type: 'warning', - style: 'margin-right: 8px;', - }, - { - default: () => '重置密码', - icon: renderIcon('material-symbols:lock-reset', { size: 16 }), - } + { + trigger: () => + withDirectives( + h( + NButton, + { + size: 'small', + type: 'warning', + style: 'margin-right: 8px;', + }, + { + default: () => '重置密码', + icon: renderIcon('material-symbols:lock-reset', { size: 16 }), + } + ), + [[vPermission, 'post/api/v1/user/reset_password']] ), - [[vPermission, 'post/api/v1/user/reset_password']] - ), - default: () => h('div', {}, '确定重置用户密码为123456吗?'), - } - ), + default: () => h('div', {}, '确定重置用户密码为123456吗?'), + } + ), ] }, }, @@ -307,7 +308,8 @@ const validateAddUser = { { trigger: ['blur'], validator: (rule, value, callback) => { - const re = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/ + /**很多公司邮箱地址长这样 xxx.xxx@xx.com 原来正则会判断为错误格式**/ + const re = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ if (!re.test(modalForm.value.email)) { callback('邮箱格式错误') return