本文目录
Protected 路由写 if not user: raise 401 三遍之后,一定会有人问:当前 用户 到底应该在哪儿解析?Header 里 Bearer token 谁读?认证 失败是 401 还是 403?把「验 token + 查 用户 + 注入到 handler」收成一条 Depends 链,路由只声明 current_user: UserDep,公开接口则不声明——这是 FastAPI 用户 系统里最值得先建立的习惯,比纠结 JWT 库选型更重要。
认证 与业务解耦后,换 JWT 为 session、换 HS256 为 RS256,大多只动 decode_token 与 Settings 里的密钥 配置;路由签名保持稳定,前端与测试少折腾。
认证在解决什么问题
认证回答「你是谁」:客户端携带 token(或 Cookie 会话 ID),服务端校验后得到稳定 用户 标识。授权(「你能做什么」)是下一步,依赖已 认证 的身份再判角色与权限。
典型凭证:
| 类型 | 携带方式 | 服务端验证 |
|---|---|---|
| JWT | Authorization: Bearer <jwt> | 验签 + 过期 + 声明里的 sub |
| 不透明会话 ID | Cookie 或 Header | 查 Redis/DB 会话表 |
| API Key | Header / Query | 查 key 表映射 用户 |
下文以 JWT 为例讲 token 流;会话 ID 模式把「解析 JWT」换成「查 session 存储」,Depends 结构相同。
无状态与有状态
JWT 认证 服务端不必存 session 表,验签即可,适合水平扩展。代价是 token 签发后难主动作废(除非维护黑名单或极短过期)。不透明 session id 则要 Redis/DB,但登出、踢人、改权限立刻生效。选型影响 用户 表旁是否加 session 存储,不影响「用 Depends 注入当前 用户」这条主线。
密码与签发:注册登录侧
服务端不应明文存密码。注册时对密码做单向哈希(bcrypt、argon2),登录时用同一算法比对:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)bcrypt/argon2 自带随机盐:同一明文两次哈希结果不同,库表被拖走后也难用彩虹表反查。不要自己用裸 hashlib.sha256(password) 存密码——那是课件里「不加盐」的反例。
JWT 要不要「加盐」? 这是另一回事。JWT 用的是签名密钥(HMAC 的 secret 或非对称私钥),保证令牌未被篡改,不是给密码哈希加盐。密码盐在哈希算法里;JWT 密钥在签发/验签配置里——两者都别硬编码进仓库,但机制不同。 登录成功后签发 token(简化示例,生产应设 exp、iss、aud):
from datetime import UTC, datetime, timedelta
import jwt
from app.core.settings import get_settings
def create_access_token(user_id: int) -> str:
settings = get_settings()
payload = {
"sub": str(user_id),
"exp": datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")token 里放 user_id(sub),不放密码、不放可改写的权限字段——权限以 DB 为准,避免 token 被篡改后越权(签名保证完整性,但角色变更需靠短过期或 refresh 机制)。
登录路由本身通常是公开的:校验邮箱密码、返回 access token(及可选 refresh token)。不要把 UserDep 挂在 login 上——那时还没有 token 可验。
@app.post("/auth/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)) -> TokenOut:
user = user_repo.get_by_email(db, body.email)
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="invalid credentials")
access = create_access_token(user.id)
return TokenOut(access_token=access, token_type="bearer")OAuth2PasswordBearer:从 Header 取 token
FastAPI 提供 OAuth2PasswordBearer,本质是声明「此依赖从哪取 token」,并在 OpenAPI 里标记安全方案:
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")tokenUrl 指向登录接口(表单或 JSON 自定),文档生成器会画 🔒。它不验证 token,只负责提取字符串;验证放在下游依赖。
Depends 链:解析 token → 加载 User
import jwt
from fastapi import Depends
from sqlalchemy.orm import Session
from app.models import User
class TokenPayload(BaseModel):
sub: str
def get_db() -> Generator[Session, None, None]:
...
def decode_token(token: Annotated[str, Depends(oauth2_scheme)]) -> TokenPayload:
settings = get_settings()
try:
raw = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
except jwt.PyJWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
) from exc
return TokenPayload.model_validate(raw)
def get_current_user(
db: Annotated[Session, Depends(get_db)],
payload: Annotated[TokenPayload, Depends(decode_token)],
) -> User:
user = db.get(User, int(payload.sub))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="user not found or inactive")
return user
UserDep = Annotated[User, Depends(get_current_user)]
OptionalUserDep = Annotated[User | None, Depends(get_optional_user)]受保护路由:
@app.get("/me")
def read_me(current_user: UserDep) -> UserOut:
return UserOut.model_validate(current_user)公开路由省略 UserDep 即可。需要「登录可选」时另写 get_optional_user:无 token 返回 None,有则走同一套 decode。
依赖树顺序
FastAPI 先解析无 Depends 的参数,再按依赖图执行。get_current_user 依赖 decode_token 与 get_db,因此 认证 失败时在进路由体之前就会 401——不会 half 执行 业务 逻辑。这与中间件里手工解析 Header 再塞 request.state.user 等价,但类型更清晰、测试 override 更直接。
与 RBAC 的分工
用户 注入只解决「是谁」;「能否删库」由 require_admin 等下游 Depends 或 Service 内判角色。保持 get_current_user 纯粹,避免一个依赖里又验 token 又判权限又查租户,否则 override 测试时要 mock 整坨逻辑。
ContextVar:Depends 够用时还要它吗?
课件会引入 contextvars.ContextVar 存「当前用户」:在依赖里 current_user_ctx.set(user),深层工具函数 current_user_ctx.get() 无需层层传参。这在同步调用栈很深、又不想把 User 写进每个函数签名时有用(日志、审计、软删过滤)。
from contextvars import ContextVar
current_user_id: ContextVar[int | None] = ContextVar("current_user_id", default=None)
def get_current_user(...) -> User:
user = ... # 验 token、查库
current_user_id.set(user.id)
return user注意边界:
- FastAPI 主路径仍优先 Depends:类型可见、OpenAPI 可标锁、测试
dependency_overrides直接。ContextVar 是补充,不是替代。 - 异步任务 / 后台线程不会自动继承请求上下文;
BackgroundTasks里若要用用户 id,应在调度时把值当参数传入,而不是假设 ContextVar 还在。 - 测试要在用例结束
resetToken,或用 fixture 清理,避免泄漏到下一条用例。
经验法则:路由与 Service 入口用 UserDep;横切的 logging filter、ORM 事件里需要「是谁」时再用 ContextVar。两者可以并存——Depends 负责注入与鉴权,ContextVar 负责传播。
401 与 403
| 状态码 | 含义 | 典型场景 |
|---|---|---|
| 401 Unauthorized | 认证失败 | 无 token、token 过期、签名错误 |
| 403 Forbidden | 已 认证 但无权限 | 普通 用户 访问 admin 接口 |
在 get_current_user 里抛 401;角色检查单独依赖:
def require_admin(user: UserDep) -> User:
if user.role != Role.ADMIN:
raise HTTPException(status_code=403, detail="admin required")
return user
AdminDep = Annotated[User, Depends(require_admin)]测试:override 认证依赖
def test_admin_only(client: TestClient, admin_user: User) -> None:
app.dependency_overrides[get_current_user] = lambda: admin_user
try:
response = client.get("/admin/stats")
assert response.status_code == 200
finally:
app.dependency_overrides.clear()不必每测生成真实 JWT;Depends 可替换,这是注入 用户 的测试红利。
端到端测 认证 链路时,再用 client.post("/auth/login") 拿真 token 调 /me,断言 JWT 往返。单元测 Service 时仍 override 用户,不必走完整 token 路径。
OpenAPI 与文档
路由参数使用 UserDep 后,默认不会在 Swagger 里显示「需要 Bearer」——需在应用或路由级加 dependencies=[Depends(oauth2_scheme)] 或 Security(scopes=...),文档才会标 🔒。团队若靠文档联调,这一行 worth 统一规范。
前端联调时,Swagger UI 的 Authorize 按钮填入 Bearer <jwt> 即可带 token 试受保护 接口。这与生产客户端发 Header 的方式一致,减少「文档能调、前端 401」的落差。
token 过期与刷新
access token 短过期降低泄露风险;refresh token 走单独路由、单独存储与轮换策略。refresh 接口同样用 Depends,但验证的是 refresh 凭证而非 access。当前 用户 注入链只服务业务 API,不要把 refresh 与 /me 混成一个依赖。
常见误解
误解 1:把 用户 id 只放 query,不用 token。认证 不能靠客户端自报 ?user_id=1。
误解 2:JWT 永不过期省事。泄露后的 token 在过期前都有效;至少设 exp,敏感操作用 refresh + 短 access。
误解 3:每个路由 copy 一段 jwt.decode。认证 逻辑应集中在 decode_token / get_current_user,与 DI 篇的依赖树一致。
从登录到受保护 接口 的完整路径
把链路串成一步不漏的叙述,方便联调:
- 客户端
POST /auth/login,body 带邮箱密码;服务端验哈希,签发 JWT access token。 - 客户端存 token(内存、secure storage,勿长期放 localStorage 若 XSS 风险高)。
- 调
/me时在 Header 带Authorization: Bearer <jwt>。 OAuth2PasswordBearer提取 token 字符串;无 Header 时直接 403(scheme 层)或 401,取决于配置。decode_token验签、读sub;过期抛 401。get_current_user用sub查 用户 表;禁用 用户 同样 401。- 路由函数收到 ORM 用户 对象,执行业务;若需 admin,
AdminDep再判角色。
整条链上只有 login 与 register 跳过 UserDep;其余受保护 接口 签名里显式写 current_user: UserDep,读代码的人一眼知道 认证 要求。不要把 用户 id 藏在 request.state 里又不写类型——那会在 refactor 时丢失 认证 约束。
Cookie 会话与 Bearer 的取舍
浏览器传统网站常用 HttpOnly Cookie 存 session id,防 XSS 窃取;前后端分离 SPA 常选 Bearer token 放 Authorization Header,便于移动客户端与 OpenAPI 工具链。FastAPI 两种都支持:Cookie 方案用 APIKeyCookie 或自定义 Depends 读 cookie;Bearer 方案用 OAuth2PasswordBearer。无论哪种,「当前 用户」仍应通过 Depends 注入 ORM 用户,而不是在路由里 parse cookie 字符串。
token 刷新、登出作废、单点登录等属于 认证 域扩展;本篇主线是「请求进来如何得到已验证 用户 对象」。先把 get_current_user 链跑通,再叠 refresh 表或 Redis 黑名单,不会推翻 Router 签名。
密码与 用户 生命周期
注册、改密、禁用 用户 与 token 认证 正交:禁用 用户 后,已签发 JWT 在过期前仍可能有效,除非维护黑名单或在 get_current_user 查 is_active。改密不应使旧 token 无限有效——短 access 过期加 refresh 轮换是常见组合。用户 注入链每次查库或查缓存,是在「无状态 JWT」与「及时失效」之间的折中。
多租户与 用户
若 用户 属 tenant,可在 get_current_user 之后加 get_current_tenant 依赖,或 用户 模型自带 tenant_id 并在 Service 校验资源归属。认证 只保证身份;跨 tenant 访问仍要在 业务 层拒绝,通常返回 404 避免泄露 id 存在性。
API Key 与 用户 注入
机器调用常用 API Key 认证:Header 带 key,依赖查表映射 service account 或 用户,最后仍注入 User 或 ServicePrincipal 类型。Depends 链可以是 get_api_key → get_actor,与 JWT 链并行,Router 统一用 ActorDep 别名。多种 认证 方式并存时,切忌每个路由手写分支。
scopes 与 OAuth2(概念)
OAuth2 scope 可在 Security(scopes=["items:read"]) 声明,OpenAPI 展示所需 scope。用户 注入链验完身份后,scope 检查相当于细粒度授权。小项目常只用角色 enum;变大后再引入 scope,Depends 分层模式不变。
安全响应头(概念)
认证 链路与 HttpOnly Cookie、Secure、SameSite 等浏览器策略配合使用;Bearer token 在前端存储位置属于前端安全范畴,后端 认证 设计应文档化推荐做法,避免 SPA 把 access token 长期放 localStorage 却无人知晓。
团队规范可写成:受保护路由必须声明 UserDep;认证 逻辑集中在 app/deps/auth.py;login 路由不写 UserDep。三条规则足够支撑大部分 CRUD 与 导购 接口。
小结
认证 校验 token 并还原 用户 身份;Depends 把「当前 用户」注入路由,公开与受保护接口靠参数有无 UserDep 区分。密码哈希 + JWT 签发是登录侧;请求侧是 Bearer 提取、验签、查库、401/403 分层。弄清这条链,用户 系统就不会散落成满屏 Header 解析。