fastapi:管理后台:用户登录
一,要注意的环节:
要用bcrypt验证登录密码,
登录成功后要保存登录信息,要写cookie
能从cookie读取用户信息
要处理未登录拦截:如果访问需要登录才能打开的页面,则跳转到登录页面,登录成功后,还要返回之前访问的页面
二,代码
python代码:
account.py
# 验证密码是否正确
def verify_password(plain_password, hashed_password):
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
# 1. 定义 注册 WTForms 表单
class LoginForm(WTForm):
account = StringField('用户名', [validators.Length(min=4, max=25,)])
password = PasswordField('密码', validators=[
DataRequired(message="密码不能为空!"),
Length(min=6, max=20, message="用户名长度必须在 6 到 20 之间")
])
next = StringField('要跳转回的url', [])
# 2. GET 路由:渲染空表单
@router.get("/login", response_class=HTMLResponse)
async def get_login(request: Request,next: str = Query('', title="要跳转的url")):
# 得到参数next
print("url next:", next)
form = LoginForm()
return templates.TemplateResponse(
request=request,
name="account/login.html",
# context={"username": f"{name}", "title": "象牙山村委会"}
context={"request": request, "form": form,"next":next,"title": "登录"})
# 3. POST 路由:处理表单提交
@router.post("/login")
async def post_login(request: Request,db: AsyncSession = Depends(get_db)):
# 将 FastAPI 接收到的 FormData 转换为 WTForms 需要的格式
form_data = await request.form()
form = LoginForm(form_data)
if form.validate():
# 1. 查询用户是否存在
stmt = select(User).where(User.username == form.account.data)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=400,
detail="用户名或密码错误"
)
is_verify = verify_password(form.password.data, user.password)
if is_verify == False:
raise HTTPException(
status_code=400,
detail="用户名或密码错误"
)
# 登录成功,生成token
access_token_expires = datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
# 得到要跳转的页面,如果没有指定,则跳转到默认页面
next = form.next.data
# 开放重定向漏洞防御校验
if next == '':
next = "/admin/account/info"
else:
if not next.startswith("/") or next.startswith("//"):
next = "/admin/account/info"
# response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
response = JSONResponse({"status_code": 200, "msg": "用户登录成功", "data": {
"next":next
}})
# 保存cookie
response.set_cookie(
key=COOKIE_NAME,
value=access_token,
httponly=True, # 🌟 关键:防止前端 JS 读取,防 XSS
samesite="lax", # 🌟 关键:现代浏览器防 CSRF 攻击的标准配置
secure=False, # 🌟 生产环境(HTTPS)必须设为 True;本地测试(HTTP)设为 False
max_age=7200 # Cookie 有效期(秒),这里设为 2 小时
)
# 返回
return response
else:
print(form.errors)
error_msg = "; ".join([f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()])
# 验证失败,返回错误详情
return JSONResponse({"status_code":400,"msg": error_msg,"data":{}})
@router.get("/info")
async def user_info(
request: Request,
# 挂载强制登录依赖,未登录的用户根本进不来
current_user: dict = Depends(admin_login_required)
):
# 获取用户信息
username = current_user['username']
print("username:", username)
# 首次加载
return templates.TemplateResponse(
request=request,
name="account/info.html",
context={
"request": request, "user": current_user, "title": "登录"
}
)
# 得到用户的信息
@router.get("/profile")
async def user_profile(
request: Request,
# 挂载强制登录依赖,未登录的用户根本进不来
current_user: dict = Depends(admin_login_required)
):
# 获取用户信息
username = current_user['username']
print("username:", username)
# 首次加载
return templates.TemplateResponse(
request=request,
name="account/profile.html",
context={
"request": request, "user": current_user, "title": "登录"
}
)
jwt.py
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
import jwt
# from passlib.context import CryptContext
import bcrypt
from urllib.parse import quote_plus # 导入标准 URL 编码工具
# --- 配置参数 ---
SECRET_KEY = "your-super-secret-key-change-me-1234567890" # 生产环境请使用安全的随机密钥
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
COOKIE_NAME = "user_token"
# 模拟数据库
USER_DB = {
"admin": {
"username": "admin",
"hashed_password": "$2b$12$vuryEKmyQS1V7ahHk5dTf.AD91zDS0ieNJLUhNfPMhE9tEmRAceWG", # 密码:123456
}
}
# 密码哈希上下文
# pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# 规定 Token 的获取地址(FastAPI 会自动在 Swagger UI 中生成登录按钮)
# oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
oauth2_scheme_strict = OAuth2PasswordBearer(tokenUrl="token", auto_error=True)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed: str) -> bool:
"""Verify a password against its hash"""
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed.encode("utf-8"),
# hashed,
)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""生成 JWT Token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
# 注意:根据 JWT 规范,过期时间 exp 应该使用 UTC 时间戳
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
#
# --- 3. 核心鉴权依赖项(从 Cookie 中提取并验证) ---
async def get_current_user_from_cookie(request: Request) -> Optional[dict]:
"""
检查 Cookie 中是否存在有效的 Token
如果有效,返回用户信息;如果无效,不抛异常,返回 None
"""
token = request.cookies.get(COOKIE_NAME)
print("token:",token)
if not token:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
# role: str = payload.get("role")
if username is None:
return None
return {"username": username} # , "role": role
except jwt.PyJWTError:
return None
# 管理后台登录用
def admin_login_required(request: Request,current_user: Optional[dict] = Depends(get_current_user_from_cookie)):
"""
强制登录拦截器
如果未登录,直接重定向回登录页,而不是返回 401 裸页面,提升管理后台的用户体验
"""
if current_user is None:
# 1. 获取用户当前访问的相对完整路径+参数 (例如: /profile?template_id=123&mode=edit)
current_path = request.url.path
if request.url.query:
current_path += f"?{request.url.query}"
# 2. 🌟 生产核心:对整个路径进行安全 URL 编码
# /profile?template_id=123 --> %2Fprofile%3Ftemplate_id%3D123
encoded_next = quote_plus(current_path)
# 3. 拼接后跳转。此时 URL 变为:/login?next=%2Fprofile%3Ftemplate_id%3D123
login_path = "/admin/account/login"
login_url = f"{login_path}?next={encoded_next}"
raise HTTPException(
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
headers={"Location": login_url}
)
return current_user
html代码:
{% extends "layout/column.html" %}
{% block title %}{{ title }}{% endblock %}
{% block layout_css %}
<style type="text/css">
body {
background-color: #f5f5f5;
}
.auth-login {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
min-height: 780px;
}
.form-signin {
max-width: 330px;
padding: 15px;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
.b-example-divider {
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
.b-example-vr {
flex-shrink: 0;
width: 1.5rem;
height: 100vh;
}
.bi {
vertical-align: -.125em;
fill: currentColor;
}
.nav-scroller {
position: relative;
z-index: 2;
height: 2.75rem;
overflow-y: hidden;
}
.nav-scroller .nav {
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
form .error {
padding-left:60px;
font-size: 14px;
}
</style>
{% endblock %}
{% block layout_js %}
<script src="{{ url_for('static', path='js/jquery/jquery.form.min.js') }}"></script>
<script src="{{ url_for('static', path='js/jquery/jquery.validate.min.js') }}"></script>
<script type="text/javascript">
$("#login-form").validate({
//ignore: ".ignore",
//debug: true,
rules: {
account: {
required: true,
},
password: {
required: true,
}
},
messages: {
account: {
required: "请输入账号",
},
password: {
required: "请输入密码",
}
},
submitHandler: function(form) {
$(form).ajaxSubmit({
dataType: 'json',
beforeSubmit: function(){
},
success: function(rs){
console.log('success')
if(rs.status_code == 200){
// 设置cookie
url_next = rs.data.next
window.location.href = url_next
}else{
phenix.show_error_note(rs.msg)
}
},
error: function(xhr, status, err){
console.log('err')
console.log(xhr.responseJSON.msg)
phenix.show_error_note(xhr.responseJSON.msg)
}
});
}
});
</script>
{% endblock %}
{% block content %}
<div class="auth-login text-center">
<main class="form-signin w-100 m-auto text-align">
<form id="login-form" action="{{ url_for('post_login') }}" method="POST">
<img class="mb-4 rounded-circle" src="{{ url_for('static', path='image/logo/logo_300.jpg') }}" alt="" width="100">
<h1 class="h3 mb-3 fw-normal">登录</h1>
<div class="form-floating">
<input type="text" name="account" class="form-control" placeholder="">
<label for="account">账号</label>
</div>
<div class="form-floating mt-2">
<input type="password" name="password" class="form-control" placeholder="Password">
<label for="password">密码</label>
</div>
<div class="checkbox mb-3 mt-2" style="text-align:left;">
<label>
<input type="checkbox" value="remember-me"> 记住我
</label>
</div>
<input type="hidden" name="next" id="next" value="{{next}}" />
<button class="w-100 btn btn-lg btn-primary" type="submit">登录</button>
</form>
</main>
</div>
{% endblock %}
三,测试效果:

浙公网安备 33010602011771号