你学会了"签名手环"(session:登录状态存在服务器)。今天升级成"数字令牌 "------JWT:身份自含、验签即信、服务器不用记任何状态。给裸奔了五篇的早报站API穿上衣服。
🎯 本篇产出:注册 / 登录 / 刷新三个接口 + 受保护接口的完整实现。含代码约130行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | 给FastAPI接口加上JWT认证 |
| 代码行数 | ~130行(含注释) |
| 依赖 | pyjwt + python-multipart |
| 核心功能 | 注册 + 登录 + 刷新 + 受保护接口 |
| 跑起来的命令 | uvicorn app.main:app --reload → /docs里Authorize |
| 核心知识点 | JWT三段结构、无状态验证、access/refresh双令牌 |
| 做完你能得到 | 一套生产级的API认证方案 |
🚨 核心认知 :JWT的"无状态"是礼物也是代价------服务器不记状态 = 无法主动吊销。解法就是双令牌:短命access + 长命refresh。
一、先复习:session手环与JWT令牌的区别
session是"签名手环 ":登录后服务器在库里/内存里记一笔,客户端每次带session_id来,服务器查表验证。
JWT(JSON Web Token)是"数字令牌 ":登录后服务器把身份信息(用户id、过期时间)签名进一段字符串,客户端每次带着它来,服务器验签即可------不用查任何表。
| 对比 | session(手环) | JWT(令牌) |
|---|---|---|
| 身份信息存哪 | 服务器(要查表) | 令牌自己带着(不用查表) |
| 验证方式 | 对表 | 验签名 |
| 水平扩展 | 会话要共享存储 | 任何服务器都能独立验证 |
| 登出/踢人 | 删服务器记录即可 | 做不到(令牌还在客户端) |
🎯 "无状态"是JWT的礼物,也是它的代价:没有服务器记录 = 无法主动吊销。
业界标准解法 就是本篇的组合拳:短命access token(15分钟)+ 长命refresh token(7天)------泄露的access很快过期,刷新能力掌握在你手里。
二、两分钟概念课:JWT拆开看
一段JWT长这样(三部分用点分隔):

| 部分 | 内容 | 作用 |
|---|---|---|
| header | {"alg":"HS256"} |
签名算法声明 |
| payload | sub(用户id)、type(access/refresh)、iat(签发时间)、exp(过期时间) |
身份声明 |
| signature | 用服务器密钥对前两部分签名 | 任何篡改都会让验签失败 |
🚨 核心认知
📌 payload是公开可读的 (只是base64编码,不是加密)------所以令牌里绝不能放密码等敏感信息,只能放"身份标识+时间"。
安全靠的是签名,不是隐藏内容。
三、第0步:依赖与配置
bash
pip install pyjwt python-multipart
| 包 | 作用 |
|---|---|
| pyjwt | 签发与验签(不用自己实现HMAC) |
| python-multipart | 登录接口要兼容Swagger的Authorize表单(FastAPI的OAuth2PasswordRequestForm需要它) |
Settings加两个配置项(app/config.py)
python
class Settings(BaseSettings):
...
jwt_secret: str = "dev-only-change-me" # 生产环境务必用环境变量覆盖!
jwt_expire_minutes: int = 15 # access token短命
refresh_expire_minutes: int = 60 * 24 * 7 # refresh token 7天
User模型加密码列(alembic装的git今天第一次兑现)
python
class User(Base):
...
password_hash: Mapped[str] = mapped_column(server_default="")
bash
alembic revision --autogenerate -m "add user password_hash"
alembic upgrade head
🎯 这就是Alembic的全部意义:加字段不用删库、不用重造数据。
四、第1步:签发------注册与登录
app/routers/auth.py:
python
"""app/routers/auth.py ------ 注册 / 登录 / 刷新 / 当前用户"""
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.security import check_password_hash, generate_password_hash
from app.config import settings
from app.deps import get_session
from core.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterBody(BaseModel):
username: str
password: str
def create_token(user: User, expires_minutes: int, token_type: str) -> str:
"""签发令牌:payload只放身份标识和时间,不放敏感信息"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user.id),
"type": token_type,
"iat": now,
"exp": now + timedelta(minutes=expires_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
@router.post("/register")
def register(body: RegisterBody, session: Session = Depends(get_session)):
"""注册:密码哈希后入库(第18篇的碎纸机)"""
exists = session.scalar(select(User).where(User.username == body.username))
if exists:
raise HTTPException(status_code=400, detail="用户名已被占用")
user = User(username=body.username)
user.password_hash = generate_password_hash(body.password)
session.add(user)
session.commit()
return {"id": user.id, "username": user.username}
@router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(get_session)):
"""登录:校验密码,签发access + refresh"""
user = session.scalar(select(User).where(User.username == form.username))
if not user or not check_password_hash(user.password_hash, form.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
return {
"access_token": create_token(user, settings.jwt_expire_minutes, "access"),
"refresh_token": create_token(user, settings.refresh_expire_minutes, "refresh"),
"token_type": "bearer",
}
💡 哈希加盐原样复用(碎纸机还是那把 );
OAuth2PasswordRequestForm让登录兼容Swagger的Authorize表单。
五、第2步:校验------依赖注入实现get_current_user
第05篇的Depends模式现在派上大用场------写一个"谁在调用我"的依赖,挂在需要登录的接口上即可。
app/deps.py 补充:
python
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
import jwt
from app.config import settings
from core.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session),
) -> User:
"""从令牌解析出当前用户;无效或过期一律401"""
credentials_error = HTTPException(
status_code=401, detail="登录状态无效或已过期")
try:
payload = jwt.decode(token, settings.jwt_secret,
algorithms=["HS256"])
except jwt.PyJWTError:
raise credentials_error
user = session.get(User, int(payload["sub"]))
if user is None:
raise credentials_error
return user
🎁 oauth2_scheme的额外红利
📌 FastAPI发现接口依赖它后,
/docs右上角会自动出现Authorize按钮------在Swagger里登录一次,所有受保护接口都能直接在线调试(本篇验收的重头戏)。
⚠️ 注意 :oauth2_scheme只在deps.py里定义一次 。auth.py如果也需要它,从deps导入即可------不要重复定义,否则tokenUrl会对不上。
六、第3步:受保护接口与刷新
🚪 早报站第一个受保护动作------登录后才能提交RSS源
app/routers/sources.py 改造:
python
from pydantic import BaseModel
from app.deps import get_current_user
from core.models import Source, User
class SourceBody(BaseModel):
url: str
@router.post("")
def create_source(
body: SourceBody,
session: Session = Depends(get_session),
user: User = Depends(get_current_user), # 门禁挂上
):
"""登录用户提交自己的订阅源"""
source = Source(url=body.url, user_id=user.id)
session.add(source)
session.commit()
return {"id": source.id, "url": source.url}
🙋 /auth/me------"我是谁"接口
前端登录态判断的标准工具:
app/auth.py
python
@router.get("/me")
def me(user: User = Depends(get_current_user)):
return {"id": user.id, "username": user.username}
🔄 刷新接口------access过期后用refresh换新的
app/auth.py
python
class RefreshBody(BaseModel):
refresh_token: str
@router.post("/refresh")
def refresh(body: RefreshBody, session: Session = Depends(get_session)):
"""用refresh token换新access token"""
credentials_error = HTTPException(status_code=401, detail="刷新令牌无效或已过期")
try:
payload = jwt.decode(body.refresh_token, settings.jwt_secret,
algorithms=["HS256"])
except jwt.PyJWTError:
raise credentials_error
if payload.get("type") != "refresh":
raise credentials_error
user = session.get(User, int(payload["sub"]))
if user is None:
raise credentials_error
return {
"access_token": create_token(user, settings.jwt_expire_minutes, "access"),
"token_type": "bearer",
}
🎯 token的
typeclaim是"用途隔离":refresh令牌只能换新access,不能直接当access用------令牌之间的权限边界要靠自己画。
💡 修正说明 :原文refresh_token: str是查询参数(会暴露在URL里),已改为用Pydantic模型接收JSON请求体,更安全也更符合规范。
七、验收清单
bash
1. curl POST /auth/register → 注册成功,psql看password_hash是天书
2. curl POST /auth/login → 拿到access_token + refresh_token
3. curl /auth/me(不带令牌)→ 401 Not authenticated
4. curl /auth/me -H "Authorization: Bearer <token>" → 返回用户信息
5. /docs里点Authorize登录 → POST /sources可直接调试(受保护生效)
6. 伪造一个token(随便改一位)→ 401
7. 全程无报错后提交Git
bash
git add .
git commit -m "JWT认证:注册登录 + 受保护接口 + 刷新"
八、常见报错:这6个,JWT的标配(重点!)
① ImportError: python-multipart is not installed
🔍 原因 :OAuth2PasswordRequestForm解析表单需要python-multipart。
✅ 解法:
bash
pip install python-multipart
💡 FastAPI的报错提示会直接告诉你装它。
② ModuleNotFoundError: No module named 'jwt'
🔍 原因 :包名陷阱 ------装的是pyjwt,导入的是jwt(第13篇bs4的亲戚)。
✅ 解法:
bash
pip install pyjwt
③ jwt.exceptions.InvalidSignatureError / 所有令牌突然失效
🔍 原因 :换了JWT_SECRET------签名密钥变了,旧令牌全废。
✅ 解法 :生产环境的JWT_SECRET一旦定了就永远不要改(改了 = 全员重新登录)。
🚨 万一泄露,改完必须接受"全员下线"。
④ 接口明明写了user依赖,不登录也能访问
🔍 原因 :函数签名里写了user参数但忘了= Depends(get_current_user)。
✅ 解法 :检查Depends;受保护接口必须显式声明依赖,否则就是裸奔。
⑤ ExpiredSignatureError:刚登录怎么就过期
🔍 原因 :access token 15分钟到期(设计如此)。
✅ 解法 :走/auth/refresh换新;前端在401时自动刷新重试(后续联调实现)。
⑥ TypeError: 'NoneType' object is not subscriptable 或查不到用户
🔍 原因 :payload的sub是字符串"1",你却session.get(User, "1")------类型不匹配查不到。
✅ 解法 :int(payload["sub"])转回整数(第5节写法)。
📌 签发时
str(),解析时int(),两头要对齐。
九、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | 登出思考题:无状态令牌怎么"踢人"?研究两个方案------令牌黑名单(服务器记下作废的jti)或用户表存token_version(签发时带上,校验时比对) | ⭐⭐⭐ | 各自代价是什么? |
| 2 | refresh轮换:给User加token_version列,refresh一次+1,旧refresh令牌立即失效 | ⭐⭐⭐ | payload里带version claim |
| 3 | 角色区分:token里加role claim(user/admin),管理员专属接口校验角色 | ⭐⭐ | get_current_admin依赖 |
| 4(选做) | 完整流程截图:用/docs的Authorize走通"登录 → 提交订阅源 → 查看列表"三步 | ⭐⭐ | 发评论区 |
📦 配套代码
完整认证模块已上传GitHub(python_daily/):【gitee仓库地址】