K 的一隅

Python FastAPI 后端实战

当前用户从哪来:认证与用户注入

Bearer token 校验、密码哈希与 JWT 签发直觉;用 Depends 注入当前用户;公开路由与受保护路由的分层写法。

10 分钟阅读 更新于 2026-07-28
本文目录

Protected 路由写 if not user: raise 401 三遍之后,一定会有人问:当前 用户 到底应该在哪儿解析?Header 里 Bearer token 谁读?认证 失败是 401 还是 403?把「验 token + 查 用户 + 注入到 handler」收成一条 Depends 链,路由只声明 current_user: UserDep,公开接口则不声明——这是 FastAPI 用户 系统里最值得先建立的习惯,比纠结 JWT 库选型更重要。

认证 与业务解耦后,换 JWT 为 session、换 HS256 为 RS256,大多只动 decode_tokenSettings 里的密钥 配置;路由签名保持稳定,前端与测试少折腾。

认证在解决什么问题

认证回答「你是谁」:客户端携带 token(或 Cookie 会话 ID),服务端校验后得到稳定 用户 标识。授权(「你能做什么」)是下一步,依赖已 认证 的身份再判角色与权限。

典型凭证:

类型携带方式服务端验证
JWTAuthorization: Bearer <jwt>验签 + 过期 + 声明里的 sub
不透明会话 IDCookie 或 Header查 Redis/DB 会话表
API KeyHeader / Query查 key 表映射 用户

下文以 JWT 为例讲 token 流;会话 ID 模式把「解析 JWT」换成「查 session 存储」,Depends 结构相同。

无状态与有状态

JWT 认证 服务端不必存 session 表,验签即可,适合水平扩展。代价是 token 签发后难主动作废(除非维护黑名单或极短过期)。不透明 session id 则要 Redis/DB,但登出、踢人、改权限立刻生效。选型影响 用户 表旁是否加 session 存储,不影响「用 Depends 注入当前 用户」这条主线。

密码与签发:注册登录侧

服务端不应明文存密码。注册时对密码做单向哈希(bcrypt、argon2),登录时用同一算法比对:

python
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):

python
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_idsub),不放密码、不放可改写的权限字段——权限以 DB 为准,避免 token 被篡改后越权(签名保证完整性,但角色变更需靠短过期或 refresh 机制)。

登录路由本身通常是公开的:校验邮箱密码、返回 access token(及可选 refresh token)。不要把 UserDep 挂在 login 上——那时还没有 token 可验。

python
@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 里标记安全方案:

python
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

python
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)]

受保护路由:

python
@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_tokenget_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 写进每个函数签名时有用(日志、审计、软删过滤)。

python
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

注意边界:

  1. FastAPI 主路径仍优先 Depends:类型可见、OpenAPI 可标锁、测试 dependency_overrides 直接。ContextVar 是补充,不是替代。
  2. 异步任务 / 后台线程不会自动继承请求上下文;BackgroundTasks 里若要用用户 id,应在调度时把值当参数传入,而不是假设 ContextVar 还在。
  3. 测试要在用例结束 reset Token,或用 fixture 清理,避免泄漏到下一条用例。

经验法则:路由与 Service 入口用 UserDep;横切的 logging filter、ORM 事件里需要「是谁」时再用 ContextVar。两者可以并存——Depends 负责注入与鉴权,ContextVar 负责传播。

401 与 403

状态码含义典型场景
401 Unauthorized认证失败tokentoken 过期、签名错误
403 Forbidden认证 但无权限普通 用户 访问 admin 接口

get_current_user 里抛 401;角色检查单独依赖:

python
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 认证依赖

python
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 篇的依赖树一致。

从登录到受保护 接口 的完整路径

把链路串成一步不漏的叙述,方便联调:

  1. 客户端 POST /auth/login,body 带邮箱密码;服务端验哈希,签发 JWT access token
  2. 客户端存 token(内存、secure storage,勿长期放 localStorage 若 XSS 风险高)。
  3. /me 时在 Header 带 Authorization: Bearer <jwt>
  4. OAuth2PasswordBearer 提取 token 字符串;无 Header 时直接 403(scheme 层)或 401,取决于配置。
  5. decode_token 验签、读 sub;过期抛 401。
  6. get_current_usersub用户 表;禁用 用户 同样 401。
  7. 路由函数收到 ORM 用户 对象,执行业务;若需 admin,AdminDep 再判角色。

整条链上只有 login 与 register 跳过 UserDep;其余受保护 接口 签名里显式写 current_user: UserDep,读代码的人一眼知道 认证 要求。不要把 用户 id 藏在 request.state 里又不写类型——那会在 refactor 时丢失 认证 约束。

浏览器传统网站常用 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_useris_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 或 用户,最后仍注入 UserServicePrincipal 类型。Depends 链可以是 get_api_keyget_actor,与 JWT 链并行,Router 统一用 ActorDep 别名。多种 认证 方式并存时,切忌每个路由手写分支。

scopes 与 OAuth2(概念)

OAuth2 scope 可在 Security(scopes=["items:read"]) 声明,OpenAPI 展示所需 scope。用户 注入链验完身份后,scope 检查相当于细粒度授权。小项目常只用角色 enum;变大后再引入 scope,Depends 分层模式不变。

安全响应头(概念)

认证 链路与 HttpOnly Cookie、SecureSameSite 等浏览器策略配合使用;Bearer token 在前端存储位置属于前端安全范畴,后端 认证 设计应文档化推荐做法,避免 SPA 把 access token 长期放 localStorage 却无人知晓。

团队规范可写成:受保护路由必须声明 UserDep认证 逻辑集中在 app/deps/auth.py;login 路由不写 UserDep。三条规则足够支撑大部分 CRUD 与 导购 接口

小结

认证 校验 token 并还原 用户 身份;Depends 把「当前 用户」注入路由,公开与受保护接口靠参数有无 UserDep 区分。密码哈希 + JWT 签发是登录侧;请求侧是 Bearer 提取、验签、查库、401/403 分层。弄清这条链,用户 系统就不会散落成满屏 Header 解析。