【Web全栈进阶】JWT无状态认证:签发、校验、刷新

你学会了"签名手环"(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的type claim是"用途隔离":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仓库地址】

相关推荐
沐言人生1 小时前
82.4k 星!把十几万行代码变成知识图谱,新人终于不用硬啃了
前端·后端·github
小此方1 小时前
「插曲:Git」企业规范篇:DevOps开发模型、Git Flow五类分支设计与测试/预发布/生产环境Bug修复及Hotfix紧急发布流程
git·bug·devops
miofly1 小时前
GitHub 今日推荐|fsearch:Rust 打造 macOS 毫秒级全盘文件名与内容搜索工具
rust·开源·github
reasonsummer1 小时前
【办公类-112-08】20261009园园通信息合并配学号拆班(数据更新,用年月日期区分版本)
python
在繁华处1 小时前
2.1 上下文:决定 Agent 能力上限的关键
前端·人工智能·microsoft
ndsc_d1 小时前
2026年有哪些好用的AI UI设计工具?主流工具功能和适用场景对比
前端·人工智能·ui·ai·设计师·ai ui·ai ui工具
阿狗童鞋1 小时前
Python爬虫进阶实战指南
开发语言·爬虫·python
u1301301 小时前
GitHub 热榜项目:日榜(2026-10-09)
github
中原第一高手1 小时前
fofatoto 1.8.0 发布:启动即知新版本、中文进度面板与更干净的 Web 日志
python·网络安全·开源·资产测绘·fofa