📚 全栈开发学习系列 · 阶段二:Web 全栈
09 HTML 基础:网页骨架与语义化标签
10 CSS 基础:样式、布局与响应式
11 JavaScript 基础:变量、DOM 与事件
12 HTTP 协议基础:请求、响应与状态码
13 FastAPI 入门:构建第一个 RESTful API
14 PostgreSQL 基础:数据库设计与 SQL 操作
15 认证授权:JWT、Session 与 Cookie(当前篇)
16 综合实战:带登录的博客系统

认证授权:JWT、Session 与 Cookie

难度:基础 | 全栈开发学习系列 第 15 篇
读完本篇你将能:区分认证与授权两个核心概念,理解 Cookie/Session 有状态认证和 JWT 无状态认证的工作原理,使用 pwdlib + PyJWT 在 FastAPI 中实现完整的用户注册、登录和 API 路由保护。
📑 本文目录
01认证 vs 授权:概念辨析
02Cookie 与 Session:有状态认证
03JWT 原理:无状态认证令牌
04密码哈希:从明文到 Argon2
05FastAPI 实战:用户注册与登录
06路由保护:OAuth2 + JWT 依赖注入
07安全最佳实践

01 认证 vs 授权:概念辨析

在前几篇中,我们构建的 FastAPI 和 PostgreSQL 应用完全开放——任何人都能访问所有接口。现实中的 Web 应用需要区分"谁能进来"和"进来后能做什么",这就是认证(Authentication)和授权(Authorization)要解决的问题。

两个容易混淆的核心概念

认证(Authentication,缩写 AuthN)回答"你是谁"——验证用户的身份是否合法。你输入账号密码登录网站,服务器核对密码正确性,这个过程就是认证。

授权(Authorization,缩写 AuthZ)回答"你能做什么"——在确认身份之后,判断该用户是否有权限执行某个操作。普通用户不能访问管理后台、游客不能发帖,这些都是授权控制。

打个比方:你拿着小区门禁卡刷卡进大门,门禁系统验证卡片有效性——这是认证。进了大门后,你的卡只能开 3 号楼 502 室的门,打不开物业办公室——这是授权。认证是授权的前提,没有认证就谈不上授权。

维度 认证(AuthN) 授权(AuthZ)
核心问题 你是谁? 你能做什么?
触发时机 登录时 每次请求时
常见实现 账号密码、OAuth、生物识别 RBAC 角色控制、ACL 权限列表
失败结果 401 Unauthorized 403 Forbidden
💡 小贴士
HTTP 状态码 401 和 403 的区别经常被混淆:401 表示"未认证"(服务器不知道你是谁),403 表示"未授权"(服务器知道你是谁,但你没有权限)。记住一个口诀——401 是"没带身份证",403 是"带了身份证但级别不够"。

02 Cookie 与 Session:有状态认证

理解了认证和授权的区别后,我们来看最早的 Web 认证方案——Cookie + Session。这套机制至今仍是传统 Web 应用的主流,理解它是掌握 JWT 的前提。

Cookie:浏览器侧的钥匙

Cookie 是浏览器存储的一小段文本数据(每条不超过 4KB),由服务器通过 HTTP 响应头 Set-Cookie 下发。此后浏览器每次请求同一域名时,会自动在 Cookie 请求头中原样带回。

用 curl 观察服务器下发 Cookie 的过程:

Shell
# 模拟登录请求,-v 显示完整 HTTP 头
curl -v -X POST http://localhost:8000/login \
  -d "username=e2do&password=secret123"
# 服务器响应头中会包含:
Set-Cookie: sessionid=abc123xyz789; Path=/; HttpOnly

这个 sessionid=abc123xyz789 就是 Cookie 的键值对。浏览器收到后会自动保存,并在后续请求中携带。

Session:服务器侧的账本

Cookie 只是一个传输通道,真正存储用户登录状态的是服务器端的 Session。Session 的本质是一个以 Session ID 为键的字典,存储在服务器内存或 Redis 等外部存储中。

用户登录
POST /login
→
服务器验证密码
创建 Session
→
返回 Session ID
Set-Cookie
→
后续请求
自动携带 Cookie

服务器端的 Session 存储结构类似于这样的 Python 字典:

Python — 服务器内存中的 Session 存储
1
2
3
4
5
6
7
# 服务器内存中的 session 存储(简化示意)
sessions = {
    "abc123xyz789": {"user_id": 1, "username": "e2do", "role": "admin"},
    "def456uvw012": {"user_id": 2, "username": "alice", "role": "user"},
}
# 用户请求时,通过 Cookie 中的 sessionid 查找
user = sessions.get(cookie_sessionid)

这种方案称为有状态认证——服务器必须记住每个用户的 Session。当用户量增大时,Session 存储成为瓶颈。多台服务器部署时还需要解决 Session 共享问题(通常用 Redis 统一存储),架构复杂度显著上升。

特性 Session(有状态) JWT(无状态)
状态存储 服务器内存/Redis 客户端 Token 中
扩展性 需 Session 共享 天然支持分布式
主动注销 删除即可立即失效 需额外黑名单机制
适用场景 传统 Web 应用 API、移动端、微服务

03 JWT 原理:无状态认证令牌

Session 的核心痛点是"服务器要记住所有人"。JWT(JSON Web Token)换了个思路:把用户信息直接编码进一个签名字符串发给客户端,服务器不需要存储任何状态——这就是无状态认证。

JWT 的三段结构

一个 JWT 看起来像这样(三段用点号分隔):

JWT 示例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJlMmRvIiwiZXhwIjoxNzU4MDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
↑ Header(头部)    ↑ Payload(载荷)          ↑ Signature(签名)

每一段都是 Base64URL 编码的 JSON。把前两段解码后能看到清晰的内容:

JSON — 解码后的 Header 和 Payload
1
2
3
4
5
6
7
8
9
// Header — 声明算法和类型
{
  "alg": "HS256",
  "typ": "JWT"
}
// Payload — 实际数据(任何人可解码,勿放敏感信息)
{"sub": "1", "username": "e2do", "exp": 1758000000}

Payload 中的常用字段(称为 Claim)有约定俗成的缩写:sub(subject,用户标识)、exp(expiration,过期时间)、iat(issued at,签发时间)。

⚠️ 常见错误
把用户密码放进 JWT Payload — Payload 只是 Base64 编码,不是加密,任何人都能解码
✓ 正确:只放用户 ID 和用户名等非敏感信息,密码始终留在服务器数据库的哈希字段中

签名算法:HS256 vs RS256

第三段 Signature 是防篡改的关键。服务器用密钥对 Header + Payload 做哈希签名,收到 Token 时重新计算签名来验证完整性。两种最常用的算法各有适用场景:

特性 HS256(对称) RS256(非对称)
密钥类型 单个密钥(签名+验证共用) 公钥/私钥对
适用规模 单体应用、个人项目 微服务、多服务验证
性能 快(HMAC 计算) 稍慢(RSA 运算)
密钥分发 所有服务共享同一密钥 私钥签名,公钥验证,公钥可公开

本篇使用 HS256——对于单体 FastAPI 应用足够安全且性能最优。当你将来构建微服务架构时,再切换到 RS256,让认证服务持有私钥签发 Token,其他服务用公钥验证。

用 PyJWT 编码与解码

PyJWT 是 Python 生态最成熟的 JWT 库,当前最新版本 2.13.0(2026 年 5 月发布的安全更新版本,修复了 5 个安全漏洞,建议所有用户升级)。安装并试用:

Shell — 安装 PyJWT
pip install PyJWT==2.13.0
Python — PyJWT 基本用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import jwt
from datetime import datetime, timedelta, timezone
SECRET_KEY = "your-secret-key-change-in-production"
# 编码:生成 Token
payload = {
    "sub": "1",
    "username": "e2do",
    "exp": datetime.now(timezone.utc) + timedelta(hours=24),
}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
print(token)
# 输出:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...
# 解码:验证签名并提取数据
decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
print(decoded)  # {'sub': '1', 'username': 'e2do', 'exp': 1758000000}

jwt.decode() 会自动检查 exp 字段,Token 过期后抛出 jwt.ExpiredSignatureError 异常。签名不匹配则抛出 jwt.InvalidSignatureError。这些异常在 FastAPI 中会被捕获并转换为 401 响应。

04 密码哈希:从明文到 Argon2

在实现登录之前,必须先解决密码存储问题。数据库里绝不能存明文密码——一旦数据库泄露,所有用户密码直接暴露。

哈希:单向不可逆变换

密码哈希(Hash)是一种单向函数:把任意长度的密码映射为固定长度的字符串,且无法从哈希值反推出原始密码。注册时存储哈希值,登录时对用户输入的密码做同样的哈希,比对两个哈希值是否一致。

注意哈希和加密的区别:加密是双向的(可以解密),哈希是单向的(不可逆)。密码必须用哈希而非加密——即使服务器被攻破,攻击者也无法还原原始密码。

passlib 已死,pwdlib 接班

过去 Python 生态的密码哈希标准库是 passlib,但它自 2020 年起停止维护,且在 Python 3.13+ 上因 crypt 模块被移除而直接崩溃。FastAPI 官方文档现已将推荐方案替换为 pwdlib,使用 Argon2 算法。

💡 小贴士
Argon2 是 2015 年密码哈希竞赛(PHC)的冠军算法,专为抵抗 GPU/ASIC 暴力破解设计。它通过"内存硬度"机制让攻击者无法用显卡并行加速破解,是目前密码哈希的金标准。
Shell — 安装 pwdlib
pip install "pwdlib[argon2]"
Python — pwdlib 密码哈希与验证
1
2
3
4
5
6
7
8
9
10
11
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
password_hash = PasswordHash((Argon2Hasher(),))
# 注册时:哈希用户密码
hashed = password_hash.hash("mypassword123")
print(hashed)
# 输出:$argon2id$v=19$m=65536,t=3,p=4$abc...$xyz...
# 登录时:验证密码
is_valid = password_hash.verify("mypassword123", hashed)  # True

注意哈希值中的 $argon2id$ 前缀——Argon2id 是 Argon2 的推荐变体,结合了抗侧信道攻击和抗 GPU 破解两种特性。pwdlib 每次哈希会自动生成随机盐值(salt),所以同一个密码两次哈希的结果不同,防止彩虹表攻击。

05 FastAPI 实战:用户注册与登录

掌握了 JWT 和密码哈希两块基石后,现在把所有零件组装起来。我们将在 FastAPI 中实现完整的用户注册、登录和 Token 签发流程,为下一篇的博客系统打好认证基础。

项目结构与依赖

Shell — 项目初始化
1
2
3
4
5
6
7
8
9
10
mkdir auth_demo && cd auth_demo
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" PyJWT==2.13.0 \
  "pwdlib[argon2]" sqlalchemy aiosqlite
# 项目结构
auth_demo/
├── main.py       # FastAPI 应用 + 路由
└── auth.py       # 认证逻辑(密码哈希 + JWT)

这里用 aiosqlite 做演示数据库(SQLite 的异步驱动),省去 PostgreSQL 安装步骤。生产环境替换连接字符串即可无缝切换到 PostgreSQL。

auth.py:认证核心模块

auth.py — 密码哈希 + JWT 签发/验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import jwt
from datetime import datetime, timedelta, timezone
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
SECRET_KEY = "dev-secret-change-in-production-use-env-var"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
pwd_hasher = PasswordHash((Argon2Hasher(),))
def hash_password(password: str) -> str:
    """注册时调用:把明文密码转为 Argon2 哈希"""
    return pwd_hasher.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
    """登录时调用:验证明文密码与哈希是否匹配"""
    return pwd_hasher.verify(plain, hashed)
def create_access_token(data: dict) -> str:
    """签发 JWT:把用户信息编码为带签名的 Token"""
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict:
    """验证 JWT:检查签名和过期时间,返回 Payload"""
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

main.py:注册与登录路由

main.py — 用户注册与登录接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from fastapi import FastAPI, HTTPException, status, Depends
from pydantic import BaseModel, Field
from auth import hash_password, verify_password, create_access_token
app = FastAPI(title="认证授权 Demo")
# 内存数据库(演示用,生产环境换 PostgreSQL)
fake_db: dict[str, dict] = {}
class UserRegister(BaseModel):
    username: str = Field(..., min_length=3, max_length=20)
    password: str = Field(..., min_length=8, max_length=128)
class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"
@app.post("/register", status_code=201)
async def register(user: UserRegister):
    # 检查用户名是否已存在
    if user.username in fake_db:
        raise HTTPException(
            status_code=409,
            detail="用户名已被注册"
        )
    # 存储哈希密码(绝不存明文)
    fake_db[user.username] = {
        "username": user.username,
        "hashed_password": hash_password(user.password),
    }
    return {"message": f"用户 {user.username} 注册成功"}
@app.post("/token")
async def login(user: UserRegister):
    # 1. 查找用户
    db_user = fake_db.get(user.username)
    if not db_user:
        raise HTTPException(
            status_code=401,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    # 2. 验证密码
    if not verify_password(user.password, db_user["hashed_password"]):
        raise HTTPException(
            status_code=401,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    # 3. 签发 JWT
    token = create_access_token(
        data={"sub": user.username}
    )
    return TokenResponse(access_token=token)

注意登录接口的安全细节:无论用户名不存在还是密码错误,都返回同样的"用户名或密码错误"——不告诉攻击者具体是哪个错了,防止用户名枚举攻击。

启动服务并测试注册和登录:

Shell — 测试注册与登录
1
2
3
4
5
6
7
8
9
10
11
fastapi dev main.py
# 注册新用户
curl -X POST http://localhost:8000/register \
  -H "Content-Type: application/json" \
  -d '{"username":"e2do","password":"mypassword123"}'
# {"message":"用户 e2do 注册成功"}
# 登录获取 Token
curl -X POST http://localhost:8000/token \
  -H "Content-Type: application/json" \
  -d '{"username":"e2do","password":"mypassword123"}'

登录成功后返回 JSON:

JSON — 登录响应
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...",
  "token_type": "bearer"
}

06 路由保护:OAuth2 + JWT 依赖注入

用户注册和登录已经跑通,但现在所有接口仍然是对外开放的。我们需要一种机制让特定接口"只允许已登录用户访问"。FastAPI 通过 OAuth2PasswordBearer + 依赖注入优雅地解决了这个问题。

OAuth2PasswordBearer 工作原理

OAuth2PasswordBearer 是 FastAPI 提供的安全工具类。它的核心作用是从请求头 Authorization: Bearer <token> 中自动提取 JWT Token。tokenUrl 参数告诉 Swagger UI 登录接口的地址,这样在文档页面就能直接测试完整流程。

客户端访问受保护接口时,必须在请求头中携带 Token:

HTTP — 受保护接口的请求头
GET /users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

实现 get_current_user 依赖

在 main.py 中添加 OAuth2 认证依赖和受保护接口:

main.py(续)— OAuth2 依赖 + 受保护路由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from fastapi.security import OAuth2PasswordBearer
from auth import decode_access_token
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
    """从 Token 中提取当前用户——所有受保护接口的依赖"""
    credentials_exception = HTTPException(
        status_code=401,
        detail="无法验证凭据",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_access_token(token)
        username: str | None = payload.get("sub")
        if username is None:
            raise credentials_exception
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Token 已过期,请重新登录")
    except jwt.InvalidTokenError:
        raise credentials_exception
    # 从数据库查找用户
    user = fake_db.get(username)
    if user is None:
        raise credentials_exception
    return user
@app.get("/users/me")
async def read_users_me(current_user: dict = Depends(get_current_user)):
    # 只有携带有效 Token 的请求才能到达这里
    return {"username": current_user["username"]}

整个保护流程的关键在于 Depends(get_current_user) 这一行。FastAPI 的依赖注入系统会自动执行 get_current_user 函数——它先用 oauth2_scheme 从请求头提取 Token,再调用 decode_access_token 验证签名和过期时间。任何步骤失败都会抛出 401 异常,请求不会到达路由函数。

用 Token 访问受保护接口:

Shell — 携带 Token 访问受保护接口
1
2
3
4
5
6
7
8
9
# 不带 Token → 401
curl http://localhost:8000/users/me
# {"detail":"Not authenticated"}
# 带 Token → 200
curl http://localhost:8000/users/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
# {"username":"e2do"}

打开 http://localhost:8000/docs 的 Swagger UI 页面,你会看到右上角出现了 "Authorize" 按钮。点击后输入用户名密码,Swagger 自动完成登录并在后续请求中携带 Token——这就是 tokenUrl="token" 参数的魔力。

07 安全最佳实践

认证系统的安全性取决于最薄弱的环节。以下是生产环境中必须遵守的安全准则。

1. 密钥管理
SECRET_KEY 绝不能硬编码在源码中。使用环境变量或 .env 文件加载,密钥长度至少 32 字符随机字符串。生产环境可用 openssl rand -hex 32 生成。
2. Token 过期时间
Access Token 有效期建议 15 分钟到 24 小时。高安全场景用短时效(15-30 分钟)配合 Refresh Token 机制:Access Token 过期后用 Refresh Token(有效期 7-30 天)获取新的 Access Token,避免用户频繁登录。
3. HTTPS 强制传输
HTTP 下 Token 和密码以明文传输,中间人可截获。生产环境必须使用 HTTPS。Nginx 配置 SSL 证书,或用 Caddy 自动获取 Let's Encrypt 免费证书。
4. CORS 跨域配置
前后端分离时,用 CORSMiddleware 限制允许的来源域名。禁止 allow_origins=["*"] 搭配 allow_credentials=True——这等于完全放开认证。
⚠️ 常见错误
SECRET_KEY = "secret" 写在源码里提交到 Git — 密钥泄露后任何人都能伪造 Token
✓ 正确:用 os.environ.get("SECRET_KEY") 从环境变量读取,.env 文件加入 .gitignore
📖 知识回顾
认证 vs 授权 Cookie/Session 有状态 JWT 三段结构 HS256 对称签名 pwdlib + Argon2 PyJWT encode/decode OAuth2PasswordBearer Depends 依赖注入 401 vs 403
✏️ 动手练习
🟢 基础验证
在本地运行本篇的 auth_demo 项目,完成用户注册和登录流程。用 curl 携带 Token 访问 /users/me 接口,确认返回正确的用户名。再尝试不带 Token 访问,确认返回 401。
🟡 组合应用
在项目中新增一个 PUT /users/me/password 接口,要求用户先登录(携带 Token),然后验证旧密码正确后更新为新密码。提示:需要同时使用 Depends(get_current_user) 和请求体中的旧密码字段。
🔴 开放挑战
实现 Refresh Token 机制:登录时同时返回 access_token(有效期 30 分钟)和 refresh_token(有效期 7 天)。新增 POST /refresh 接口,客户端用 refresh_token 换取新的 access_token。提示:两个 Token 的 Payload 中加入 "type": "access" 或 "type": "refresh" 区分用途,验证时检查类型是否匹配。
下篇预告
16 综合实战:带登录的博客系统
将整合 FastAPI + PostgreSQL + JWT 认证,从零构建一个完整的博客系统:用户注册登录、文章 CRUD、权限控制、部署上线,完成阶段二的收官项目
关注公众号获取更多全栈开发教程