【FastAPI筑基-Day13】JWT实战:登录签发+依赖鉴权一篇打通
专栏:FastAPI零基础后端实战系列 标签:FastAPI、JWT、接口鉴权、OAuth2、python-jose、passlib 前置学习:Day12 Depends依赖注入精讲
一、前言
Day12 我们用 Depends 把公共逻辑抽出来复用,还写了一个"固定 Token"的简易鉴权。但真实项目里不可能把 Token 写死在代码里 —— 用户要登录、Token 要过期、身份要能解析。业界最主流的方案就是 JWT(JSON Web Token)。
这篇文章我们做一次完整的实战:从理解 JWT 原理开始,安装依赖,实现登录签发 token、依赖校验 token、获取当前登录用户、处理过期/非法 token,最终产出一份可直接运行的完整鉴权代码。学完本篇,你将掌握企业后端几乎每个项目都绕不开的登录鉴权能力。
Day13 目标清单:
- 搞懂 JWT 是什么、工作流程
- 安装依赖,实现登录签发 token
- 使用依赖校验 token,完成接口鉴权
- 获取当前登录用户信息
- 处理 token 过期、非法 token 异常
- 完整可运行示例代码
二、什么是 JWT
JWT 全称 JSON Web Token,是一种无状态的身份认证令牌。
打个比方:JWT 就像游乐园的"手环"。你在门口验票(登录)后,工作人员给你戴上一个防伪手环(签发 token);之后玩每个项目(调接口),只需要亮一下手环,工作人员验一下防伪标记就知道你买过票,不需要回门口查底册(服务端不存会话)。
工作流程:
sequenceDiagram
participant C as 客户端/前端
participant S as FastAPI 服务端
C->>S: POST /login 提交账号密码
S->>S: bcrypt 哈希比对密码
S-->>C: 校验通过,签发 JWT 返回
C->>S: 后续请求头携带 Authorization: Bearer token
S->>S: 解析签名、校验过期、取出 sub 用户标识
S-->>C: 确认身份,放行接口返回数据
- 用户输入账号密码调用登录接口
- 服务端校验账号密码正确,生成 JWT 令牌返回给前端
- 前端后续请求在请求头携带
Authorization: Bearer token字符串 - 服务端解析校验 token,确认用户身份,放行接口
- token 设置过期时间,过期需要重新登录
✅优点:无状态,服务端不需要保存会话数据,适合分布式、微服务 ❌缺点:一旦签发,有效期内无法主动作废(需要额外黑名单方案)
JWT 由三部分组成:头部.载荷.签名,用 . 分割,base64 编码。
⚠️ payload 只是编码不是加密,不要存放密码等敏感数据! 本地实测,把登录拿到的 token 中间段直接 base64 解码:
>>> base64 解码 token 的 payload 段
{'sub': 'zhangsan', 'exp': 1787104494}
一目了然:任何人都能解开 payload 看到内容,所以里面只放用户标识(sub)和过期时间(exp)这类非敏感字段。
三、安装依赖库
FastAPI 官方文档推荐用 python-jose 生成/解析 JWT,密码加密使用 passlib:
pip install fastapi uvicorn "python-jose[cryptography]" "passlib[bcrypt]" python-multipart
说明:
- python-jose:用来生成、解析 JWT 令牌
- passlib:做密码哈希加密,数据库绝不存明文密码
- python-multipart:
OAuth2PasswordRequestForm表单登录必需,漏装会直接启动报错,新手高频坑
踩坑实录:新版 bcrypt 5.x 和 passlib 1.7.4 不兼容,启动即报错:
(trapped) error reading bcrypt version
AttributeError: module 'bcrypt' has no attribute '__about__'
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary
解决办法:把 bcrypt 固定到 4.0.1:
pip install "bcrypt==4.0.1"
四、完整代码实现
新建 main.py(配套代码见 code/day13.py),复制全部代码直接运行,访问 http://127.0.0.1:8000/docs 调试接口。
from datetime import datetime, timedelta
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
# ===================== 配置项,生产环境务必放到环境变量! =====================
# 密钥,生产环境使用随机长字符串,千万不要硬编码写死
SECRET_KEY = "your-secret-key-keep-safe-change-in-prod-000000000"
# 加密算法
ALGORITHM = "HS256"
# token过期时间,单位分钟
ACCESS_TOKEN_EXPIRE_MINUTES = 30
app = FastAPI(title="Day13 JWT鉴权示例", version="1.0")
# 密码加密上下文 bcrypt算法
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 模式:从请求头 Authorization: Bearer {token} 获取令牌
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
# ---------------------- 模拟数据库 ----------------------
fake_users_db = {
"zhangsan": {
"username": "zhangsan",
"full_name": "张三",
"email": "zhangsan@demo.com",
# 密码明文:123456,这里存储 bcrypt 哈希之后的值
"hashed_password": "$2b$12$GdPJZjsLn1ouEA3z3ChOVOeYWXyCjIyH6imkqQNgEOTs5xWS/SL4K",
"disabled": False
}
}
# ---------------------- Pydantic模型 ----------------------
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class User(BaseModel):
username: str
email: Optional[str] = None
full_name: Optional[str] = None
disabled: Optional[bool] = None
# ---------------------- 工具函数 ----------------------
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""校验明文密码和哈希密码是否匹配"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""对明文密码生成哈希,注册用户的时候使用"""
return pwd_context.hash(password)
def get_user(db, username: str):
"""模拟从数据库查询用户"""
if username in db:
user_dict = db[username]
return User(**user_dict)
def authenticate_user(fake_db, username: str, password: str):
"""账号密码校验逻辑"""
user = get_user(fake_db, username)
if not user:
return False
if not verify_password(password, fake_db[username]["hashed_password"]):
return False
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""生成JWT访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""依赖:解析token获取当前用户,鉴权核心"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证身份,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
"""依赖:校验用户是否启用,禁用账号禁止访问"""
if current_user.disabled:
raise HTTPException(status_code=400, detail="该用户已被禁用")
return current_user
# ---------------------- 接口 ----------------------
@app.post("/login", response_model=Token, summary="登录接口,获取token")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
"""
登录接口
username: zhangsan
password: 123456
"""
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或者密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users/me", summary="获取当前登录用户信息", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
"""需要携带token才能访问"""
return current_user
@app.get("/users/me/items", summary="需要登录的业务接口")
async def read_own_items(current_user: User = Depends(get_current_active_user)):
return [{"item_id": 1, "owner": current_user.username}]
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
代码核心思路(对照 Day12 的依赖注入):
get_current_user:从请求头取 token → 解析签名 → 校验过期 → 查用户,鉴权核心依赖get_current_active_user:嵌套依赖,在鉴权之上再校验账号是否被禁用- 业务接口只需一行
Depends(get_current_active_user),即可拿到当前登录用户
五、测试流程
- 运行程序,打开
http://127.0.0.1:8000/docs
3 个接口一目了然:/login 登录签发 token,/users/me、/users/me/items 带锁图标,必须鉴权。
- 右上角点击 Authorize 按钮,填入账号密码登录
- username:
zhangsan - password:
123456
- 点击 Authorize 提交,弹窗显示 Authorized 状态,说明登录成功拿到 token;Close 之后,后续请求自动带上
Bearer token
- 展开
/users/me,点击 Try it out → Execute,成功返回当前登录用户信息
- token 过期、传错 token 会返回 401 未授权(见下一节实测结果)
前端请求头格式示例
GET /users/me HTTP/1.1
Host: 127.0.0.1:8000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9......
⚠️注意:
Bearer后面有空格,很多新手踩坑忘记空格直接报 401!
实际运行结果
本地用 curl 对全部场景实测(token 过长部分省略):
POST /login (zhangsan/123456) => 200 {"access_token":"eyJhbGciOiJIUzI1NiIs...","token_type":"bearer"}
POST /login (密码错误) => 401 {"detail":"用户名或者密码错误"}
GET /users/me (不带token) => 401 {"detail":"Not authenticated"}
GET /users/me (Bearer后漏空格) => 401 {"detail":"Not authenticated"}
GET /users/me (正确token) => 200 {"username":"zhangsan","email":"zhangsan@demo.com","full_name":"张三","disabled":false}
GET /users/me/items (正确token) => 200 [{"item_id":1,"owner":"zhangsan"}]
GET /users/me (伪造token abc.def) => 401 {"detail":"无法验证身份,请重新登录"}
GET /users/me (过期token) => 401 {"detail":"无法验证身份,请重新登录"}
可见:只有"正确密码登录 + 有效期内 + 携带格式正确"的请求才能拿到数据;密码错、token 假、token 过期、格式错,全部被 401 拦截,鉴权闭环成立。
六、重点踩坑总结
- SECRET_KEY 密钥安全 生产环境绝对不能写死代码,放到环境变量、配置文件;泄露之后攻击者可以伪造任意 JWT 令牌。
- payload 不要存放密码 JWT payload 只是 base64 编码,可以直接解码查看(第二节已实测),不能放密码、银行卡等敏感信息。
- token 过期处理
ACCESS_TOKEN_EXPIRE_MINUTES设置合理时间,前端捕获 401 状态码跳转登录页面。 - JWT 无状态的局限 用户修改密码、主动下线,旧 token 依然有效;如果需要强制下线,需要引入 Redis 维护 token 黑名单。
- OAuth2PasswordRequestForm
表单登录需要安装
python-multipart;tokenUrl="login"要和实际登录接口路径保持一致,否则 docs 文档鉴权会异常。 - bcrypt 版本兼容
passlib 1.7.4配bcrypt 5.x直接报错,固定bcrypt==4.0.1最省心。
七、拓展思考(作业)
- 实现注册接口:接收用户名密码,用
get_password_hash哈希之后存入模拟数据库 - 使用 Redis 实现 token 黑名单,实现强制用户下线功能
- 区分 access_token 短期、refresh_token 刷新令牌
八、Day13 核心知识点总结
| 知识点 | 说明 |
|---|---|
| JWT 结构 | 头部.载荷.签名,payload 是编码不是加密 |
| 登录签发 | 校验密码后 jwt.encode 生成带 exp 的令牌 |
| OAuth2PasswordBearer | 自动从 Authorization: Bearer xxx 取 token |
| 依赖鉴权 | get_current_user 解析校验 token,业务接口一行 Depends 复用 |
| 嵌套依赖 | get_current_active_user 在鉴权之上校验禁用状态 |
| 异常处理 | 非法/过期 token 统一抛 401,前端跳登录页 |
Day13 我们结合 Depends 依赖注入实现了 FastAPI 标准 JWT 登录鉴权。把 token 解析、用户校验封装成依赖函数,业务接口只需要一行 Depends 即可完成鉴权,代码复用性极强。真实项目中几乎所有后台接口都需要这套鉴权逻辑。
九、下期预告
Day14:数据库 SQLAlchemy ORM —— 实现用户表真实数据库存储,告别模拟内存数据库。