Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

功能添加 #56

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions app/api/v1/base/base.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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
from app.core.dependency import DependAuth
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()

Expand All @@ -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()
Expand Down
20 changes: 19 additions & 1 deletion app/controllers/user.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
2 changes: 2 additions & 0 deletions app/models/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions app/schemas/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

9 changes: 9 additions & 0 deletions app/settings/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"

Expand Down
35 changes: 35 additions & 0 deletions app/templates/cn/reset_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>[{{ app_title }}] 密码重置请求</title>
</head>
<body>
<div style="text-align: center">
<p>点击下面的按钮重置您的 [{{ app_title }}] 帐户的密码</p>
<a class="reset_password" href="{{ reset_url }}">重置密码</a>
<p style="padding-top:2em; font-size:small">未请求重置此密码?忽略它即可。</p>
</div>
</body>
</html>

<style>
.reset_password {
display: inline-block;
box-sizing: border-box;
font-size: 1.063rem;
padding: 0.5rem 1.375rem;
background-image: initial;
background-position: initial;
background-size: initial;
background-repeat: initial;
background-attachment: initial;
background-origin: initial;
background-clip: initial;
border: 1px solid #1060c9;
text-decoration: none;
border-radius: 4px;
background-color: #1060c9 !important;
color: rgb(255, 255, 255) !important;
}
</style>
35 changes: 35 additions & 0 deletions app/templates/en/reset_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>[{{ app_title }}] Password Reset Request</title>
</head>
<body>
<div style="text-align: center">
<p>Click the button below to reset the password for your [{{ app_title }}] account</p>
<a class="reset_password" href="{{ reset_url }}">Reset Password</a>
<p style="padding-top:2em; font-size:small">Didn't request this password reset? It's safe to ignore it.</p>
</div>
</body>
</html>

<style>
.reset_password {
display: inline-block;
box-sizing: border-box;
font-size: 1.063rem;
padding: 0.5rem 1.375rem;
background-image: initial;
background-position: initial;
background-size: initial;
background-repeat: initial;
background-attachment: initial;
background-origin: initial;
background-clip: initial;
border: 1px solid #1060c9;
text-decoration: none;
border-radius: 4px;
background-color: #1060c9 !important;
color: rgb(255, 255, 255) !important;
}
</style>
38 changes: 38 additions & 0 deletions app/utils/password.py
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -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)
9 changes: 9 additions & 0 deletions web/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,13 @@ const i18n = createI18n({
messages: messages,
})

/**
* 如果从来没有切换过i18的语言 那么存储里面会缺少*locale*这个变量
* 我也不晓得为啥子上面 createI18n 不设置这个初始变量
* 所以用下面的代码来凑合解决一下
**/
if (!lStorage.get('locale')) {
lStorage.set('locale', currentLocale || 'cn')
}

export default i18n
15 changes: 14 additions & 1 deletion web/i18n/messages/cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"text_login": "登录",
"message_input_username_password": "请输入用户名和密码",
"message_verifying": "正在验证...",
"message_login_success": "登录成功"
"message_login_success": "登录成功",
"forget_password": "忘记密码"
},
"workbench": {
"label_workbench": "工作台",
Expand Down Expand Up @@ -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": {
Expand Down
15 changes: 14 additions & 1 deletion web/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions web/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
17 changes: 14 additions & 3 deletions web/src/router/guard/auth-guard.js
Original file line number Diff line number Diff line change
@@ -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
})
}
Loading