当我们的FastAPI应用还只是一个本地实验时,身份验证和授权似乎只是“以后再说”的问题,但一旦我们开始真正对外提供接口,哪怕只是给前端同事调试,谁能访问什么这种问题立刻变成了核心议题。身份验证回答的是“你是谁”,授权回答的是“你有没有资格做这件事”。如果我们只解决了前一个问题,而把后一个问题散落在各个路由里用if判断临时处理,系统很快就会变成一团难以维护的条件分支。相反,如果我们在一开始就用FastAPI的依赖注入和Pydantic模型,把认证与授权规则写成可组合、可测试的组件,那么整个安全体系就会像一张有结构的网,而不是到处打补丁的胶带。
在这一章里,我们会从最简单的“伪登录”开始,逐步推进到使用OAuth2密码模式与JWT的完整登录流程,再把这一套机制与前一章的Pydantic与依赖注入结合起来,构建出既能表达用户身份,又能表达权限的安全层。我们会让“获取当前用户”“校验权限”“区分普通用户与管理员”这些逻辑都变成依赖,而不是散落在每个端点里的手写代码。与此同时,我们也会不断提醒自己,从工程角度看安全问题,不只是“能不能过”,更是“以后能不能改”和“能不能被团队其他人正确理解”。

在深入JWT和OAuth2之前,我们先用一个极简的“伪登录”例子来建立直觉。我们暂时不关心密码如何安全存储,不关心token如何生成,而是只关注一个核心流程:客户端带着某种凭据访问受保护的端点,应用根据凭据识别用户身份,并把这个“当前用户”传递给后续业务逻辑。
我们先写一个完全不会出现在生产环境中的示例,只是用它来让“凭据提取和当前用户依赖”这种模式深入心智。
|from fastapi import FastAPI, Depends, Header, HTTPException from typing import Optional, Dict app = FastAPI() fake_users_db: Dict[str, Dict] = { "token-user-1": {"id": 1, "username": "alice", "role": "user"}, "token-admin": {"id": 2, "username": "bob", "role": "admin"}, } async def get_current_user(authorization: Optional[str] = Header(None)) -> Dict: if not authorization: raise HTTPException(status_code=401, detail="缺少认证信息") token_prefix = "Bearer " if not authorization.startswith(token_prefix): raise HTTPException(status_code=401, detail="认证头格式错误") token = authorization[len(token_prefix):] user = fake_users_db.get(token) if not user: raise HTTPException(status_code=401, detail="无效的token") return user @app.get("/me") async def read_me(current_user: Dict = Depends(get_current_user)): return {"message": "欢迎回来", "user": current_user}
这个例子虽然粗糙,却把三个关键点都表现出来了。第一,凭据是从请求头中提取的,而不是混杂在每个路由函数里。第二,“当前用户”不是某个全局变量,而是一个依赖的返回值,谁需要就声称依赖它。第三,认证失败是通过抛出HTTPException来表达的,路由函数本身得到了一个已经通过验证的用户对象。这种分层在以后引入密码校验、JWT、数据库查询时都保持不变,我们只是在填充get_current_user内部的实现。
现在我们开始认真对待“登录”这件事。就FastAPI而言,OAuth2密码模式是一个非常合适的起点。虽然在更现代的架构中我们会更倾向于授权码模式和第三方身份提供者,但对于入门课程和自建后端服务来说,密码模式足够直观,也足够贴近很多现存系统的实际做法。
在实现密码登录时,我们需要明确几个模型的角色。登录端点接收用户名与密码,返回一个token;“获取当前用户”的依赖从token中恢复用户身份;用户对象本身应该有清晰的结构,哪些字段只在内部存在,哪些字段可以出现在响应中,都应该通过Pydantic模型表达出来。我们先从这一组模型和辅助函数开始。
|from datetime import datetime, timedelta from typing import Optional from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel app = FastAPI() SECRET_KEY = "change_me_in_real_projects" ALGORITHM =
通过这一组代码,我们把最核心的密码校验与用户存储抽象了出来。我们用了UserInDB来表达“带密码的内部用户”,用了User来表达“可以返回给客户端的用户视图”,用了Token来表达“登录成功后返回的access_token结构”。最重要的是,我们把登录这件事分成了三个步骤:先从某处拿到用户,再校验密码,再在通过之后生成token。这种拆分会让后面接入真实数据库和复杂密码策略变得自然。

FastAPI通过OAuth2PasswordBearer这个工具,把“通过Authorization头携带Bearer token进行认证”这种模式标准化了。我们不需要自己解析这个头部,只要告诉它token获取端点在哪里,就可以在后续的依赖里专注于“如何从token推导出当前用户”。
我们先把登录端点写出来,它接受OAuth2PasswordRequestForm,这在HTTP层面意味着前端会以application/x-www-form-urlencoded的方式提交username与password两个字段。
|@app.post("/token", response_model=Token) async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): user = authenticate_user(form_data.username, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误", headers={"WWW-Authenticate": "Bearer"}, )
登录之后,我们需要一个“从token中恢复当前用户”的依赖。这个依赖最终会被大多数受保护端点使用,因此我们要把它写得尽量清晰。我们会从oauth2_scheme中获得原始token字符串,然后用jwt.decode解析,再用get_user_from_db回到内部用户模型。这里面任何一步失败都应该被视为401错误。
|async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
在这个流程中,我们把认证失败统一映射成401,把用户被禁用映射成400,并且通过get_current_active_user这种二次依赖,把“认证通过”和“账户状态正常”这两件事拆开。以后如果我们想在某些端点只要求“登录但可以是禁用用户”,也完全可以复用get_current_user而跳过get_current_active_user。
当我们能稳定获得current_user之后,第二个问题自然就是“这个用户能不能访问这个资源”。授权可以很粗糙,也可以很优雅。粗糙的方式是在每个端点里写if current_user.role != "admin": raise HTTPException(...),优雅的方式是在依赖层构建可组合的权限检查,让路由只需要表达“我需要 admin 权限”这样的高层意图。
我们先写一个最简单的基于角色的授权依赖。这个依赖本身依赖get_current_active_user,再在此基础上做一次判断。如果当前用户不满足要求,就抛403错误。
|from typing import Callable def require_role(required_role: str) -> Callable: async def role_checker(current_user: User = Depends(get_current_active_user)) -> User: if current_user.role != required_role: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"需要{required_role}权限", ) return
这个模式的关键在于,我们不再在路由函数内部写任何权限判断逻辑,而是通过依赖的组合把“谁能访问什么”表达得非常清晰。以后如果我们想增加“超级管理员”“讲师”等角色,只需要在依赖工厂内部扩展,而不必去翻遍所有端点。
当授权逻辑变复杂,比如需要支持多角色、资源级别的访问控制,甚至基于属性的授权时,我们可以把require_role升级成“策略检查器”,它接受一个策略对象或回调函数,而不是一个简单字符串。这里我们不急着一次性走到最复杂,而是先把“授权应该以依赖形式存在”这个设计习惯打牢。

在实际项目中,我们往往会同时存在三类端点。第一类是完全公开的,比如公开课程列表或健康检查,这些不需要任何认证。第二类是“登录即可访问”的端点,比如查看自己的资料或上传头像。第三类是“仅管理员或特定角色可访问”的端点,比如删除课程、查看所有用户的报名数据。
有了前面的认证和授权依赖,我们现在可以很自然地表达这三种保护等级。完全公开的端点不声明任何依赖;登录即可访问的端点依赖get_current_active_user;管理员端点则依赖require_role("admin")。
|@app.get("/public/courses") async def list_public_courses(): return {"courses": ["FastAPI 入门", "异步编程实践"]} @app.get("/profile", response_model=User) async def read_own_profile(current_user: User = Depends(get_current_active_user)): return current_user @app.delete("/admin/courses/{course_id}") async
这个例子看上去很直接,但它把一种非常重要的“架构性划分”用代码体现了出来。我们可以在文档、代码审查以及团队约定中都强调:任何需要权限控制的端点都必须通过依赖表达出来,而不是在函数体里临时写判断。
当我们设计JWT载荷时,很容易想要把所有信息都塞进去,比如用户名、邮箱、角色、甚至权限列表。这样做的好处是解耦了认证服务与业务服务,业务服务只要解析token就能做权限判断;代价是当角色或权限发生变更时,已经发出去的token仍然携带旧的信息,这在安全敏感的场景下是个问题。
我们可以把这个问题拆成两个层次看。对于权限变化不那么频繁、业务对“延迟生效”容忍度较高的系统,可以把基础角色信息放在token里,把精细权限放在数据库里。对于极端敏感的系统,则可以把token的有效期缩得非常短,或者引入集中式会话与黑名单机制来随时吊销token。无论采取哪种策略,FastAPI这边的实现模式其实是相似的:解析token获取一个基础身份标识,然后在依赖中根据需要访问数据库获取最新的权限信息。
从代码层面看,我们可以把get_current_user限制为“只从token拿sub和基本role”,然后在require_role里按需访问数据库或缓存,检查更细粒度的权限。这样一来,我们既不会过度依赖token里的信息,也不会在每个端点里四处访问数据库。
到目前为止,我们一直使用的是典型的“前后端分离+Bearer token”的模式,适合SPA应用或移动端调用。在某些场景下,基于Cookie的会话认证还是非常常见,例如传统SSR应用或需要借助浏览器自动带Cookie的后端管理后台。
FastAPI并不强制使用某一种方式,我们完全可以通过依赖注入把Cookie里的会话ID取出来,再通过会话存储获取当前用户。这个模式下,认证依赖的内部实现不同了,但路由层的依赖声明可以保持不变。也就是说,如果我们未来想把某个接口从JWT模式迁移回会话模式,只要修改依赖实现,不必动所有使用该依赖的路由。
|from fastapi import Cookie async def get_current_user_from_session(session_id: Optional[str] = Cookie(None)) -> User: if not session_id: raise HTTPException(status_code=401, detail="缺少会话信息") user_data = {"username": "session_user", "role": "user"} return User(
在课程的这个阶段,我们不会深入实现完整的会话退出、CSRF 防护和同站策略,但我们需要意识到:FastAPI的安全故事始终是“依赖可替换”的,而不是绑定某一个具体的认证机制。

安全相关的代码一旦不被测试覆盖,就很容易在重构中悄然失效。好在我们已经把登录、获取当前用户和权限检查都写成了依赖,而FastAPI本身也提供了依赖覆盖机制,这让我们在测试时可以用“假用户”“假权限”快速构造情境,不必真的去算JWT或连真实数据库。
在测试里,我们可以用app.dependency_overrides把get_current_user或get_current_active_user替换成一个返回固定用户的函数,然后直接调用受保护端点,断言返回值和状态码。这种测试方式的好处是,我们专注于“授权逻辑是否正确”,而不是陷入“怎么在测试里造一个合法token”的细节。
|from fastapi.testclient import TestClient client = TestClient(app) async def override_get_current_active_user(): return User(username="test-user", role="admin", disabled=False) app.dependency_overrides[get_current_active_user] = override_get_current_active_user def test_admin_dashboard_access(): response = client.get("/admin/dashboard") assert
通过这种方式,我们可以为每一类角色、每一种权限组合写出稳定的回归测试,让后续对依赖内部实现的调整不会悄悄打破安全边界。
这一部分里,我们把“你是谁”和“你能不能做这件事”拆成了两个层次来思考。我们先用一个非常简单的伪登录例子建立起“凭据提取和当前用户依赖”的直觉,然后用OAuth2密码模式与JWT实现了一个更接近生产环境的登录流程,让token成为客户端与后端之间的通行证。在此基础上,我们用依赖注入把角色控制与权限检查抽象成可组合的组件,让“只要是管理员才能访问”这类规则不再是分散的if判断,而是清晰的依赖声明。
同时,我们也从工程角度讨论了几个真实系统常见的问题。我们看到了把角色信息放进JWT的利与弊,也看到了会话型认证在某些场景下仍然有价值,更重要的是,我们意识到FastAPI真正给我们的,不是一种具体的安全方案,而是一套可以替换实现的依赖骨架。
在接下来的学习中,当我们开始处理文件上传、数据库访问以及更复杂的业务流程时,我们会持续复用这一部分构建出来的安全层。无论是上传的文件,还是对数据库的写操作,只要关系到用户身份与权限,我们都会让这一层先帮我们“看一眼”,确认眼前这个调用者到底是谁,又能做多少事。