去年年底我给一个内部项目做了一次安全巡检结果让我后背发凉。这个项目的 API 全部裸奔只要知道接口地址任何人都可以调任何接口。更要命的是某条合作方数据查询接口只要把请求里的数据 ID 改一改就能拉到别人的数据。这已经不是不规范的问题而是彻头彻尾的越权漏洞。我花了整整 8 个小时从梳理接口、设计 JWT 认证、套上 FastAPI 依赖注入到最后把权限逻辑全部收敛总算把这只裸奔的 API 穿上了衣服。这篇文章就把这套方案完整地记录下来包括为什么用 JWT、依赖注入是怎么工作的、核心代码怎么写、我在 8 小时里踩过哪些坑以及后续怎么继续加固。无论你是在维护一个老项目还是从零搭建一个新服务的鉴权体系这套东西都能直接抄。1. 越权裸奔的两种病横向越权和纵向越权必须分开治动手改代码之前我先把项目里所有接口过了一遍把所有越权场景归成两类。这两类问题的病根不同对应的解法也完全不同合在一起叫越权分开来看其实是两种病。1.1 横向越权A 用户能访问 B 用户的数据横向越权的典型症状是普通用户登录后把请求参数里的 ID 换掉就拿到了其他用户的数据。比如一个订单查询接口是 GET /orders/{order_id}后端拿到 order_id 直接查库返回结果完全不校验这个订单到底是不是当前登录用户的。这就是最常见、危害最直接的越权漏洞黑产渗透测试最先测的就是这种。在 FastAPI 项目里这种问题特别容易发生。框架本身对参数校验做得很好但对资源归属和资源操作权限这种业务层逻辑是完全不管的。它不是面向业务的安全框架而是一个偏底层的 Web 框架。很多人在写路由函数的时候只要看到了 User ID 在请求里传过来就下意识地信任它然后直接拿去查询这一步就是漏洞的根源。1.2 纵向越权普通用户碰了管理员接口纵向越权的典型症状是普通用户直接调管理员接口。比如 DELETE /users/{user_id} 这种接口本来应该只有管理员能调结果普通用户拿着自己的 token 也能删人。这类问题的根子是接口没有做角色分级。我巡检的那个项目管理员接口和普通接口完全混在一起既没有统一前缀也没有中间件区分。结果是所有接口对所有登录用户一视同仁地开放。我当时第一反应是尽快给所有接口加上角色校验但很快发现如果只是零散地在每个路由函数里手动判断角色代码会散得到处都是后续维护就是一场灾难。所以必须有一个统一机制在接口入口处就把角色关卡卡死。1.3 为什么认证和授权是两回事这里必须澄清一个关键概念认证Authentication和授权Authorization是两码事。认证是你是谁授权是你能干什么。JWT 只解决认证问题它证明了请求者确实是用某个身份登录的但它不解决授权问题——也就是说拿到合法 token 不代表这个人有权访问某个资源、执行某个操作。我之前见过不少项目团队觉得反正我们已经用了 JWT安全没问题了。这是对 JWT 最大的误解。JWT 是一张进场门票不是一张万能通行证。进场之后你能去哪些区域、能碰哪些设备还需要检票员再查一次。这篇文章里的核心工作就是在 FastAPI 里把检票员这套机制搭起来JWT 负责确认身份依赖注入负责做授权校验。2. 为什么在 FastAPI 里选 JWT 依赖注入对比过 Session 和 OAuth2 后的结论技术选型不是越高级越好而是要贴合团队、场景和部署形态。当时我先后对比了几种主流方案Session 认证、JWT 直接硬编码校验、OAuth2 全流程以及 FastAPI 的 OAuth2PasswordBearer JWT 依赖注入的组合。最终选了后者下面说说取舍过程。2.1 Session 形态为什么被排除Session 认证的核心是服务端存储会话状态客户端持有一个 session_id每次请求拿着这个 ID 来服务端换取用户信息。这个方案在传统多页应用里是主流但在前后端分离 多端访问 水平扩展的架构下就不太舒服了。服务端必须维护会话存储多实例部署时还要引入 Redis 这类共享存储来同步会话状态链路里多了一个强依赖。另外这个项目的接口除了给 Web 端用还要开放给外部合作方对接。外部系统对接时最方便的形式是对方拿着一个 token 直接调接口而不是陪着我走一遍登录流程、维护会话状态。所以无状态、自包含的 JWT 天然更合适。2.2 OAuth2 完整流程对这个项目来说太重了OAuth2 本身是一个授权框架解决的是第三方应用如何获得资源所有者的授权的问题。它包含授权码、令牌刷新、scope 管理、客户端注册等一整套流程。对于我当时的场景——自己系统的用户登录自己的 API并没有真正的第三方角色OAuth2 完整流程完全用不上反而会引入不必要的复杂度。FastAPI 里有个类叫 OAuth2PasswordBearer它的名字带 OAuth2但实际上它只承担了一个职责从请求头里取出 Bearer token。它不是一个完整的 OAuth2 实现。我们用到的也只是它这个取 token 的能力底层鉴权逻辑完全自己控制。2.3 依赖注入FastAPI 权限控制的灵魂FastAPI 的依赖注入Dependency Injection是整个方案里最核心的机制。它的工作方式可以类比为路由函数的装配线路由函数声明自己需要什么依赖FastAPI 在调用这个函数之前先把这些依赖逐层构建好然后像穿串一样传给路由函数。更关键的是依赖可以嵌套、可以复用、可以写业务逻辑。比如我可以写一个依赖 get_current_user它接收 OAuth2PasswordBearer 取到的 token解析 JWT查出当前用户然后返回。任何路由函数只需要声明 Depends(get_current_user)FastAPI 就会自动完成整个链路。如果 token 无效、用户被禁用依赖内部可以直接抛出 401/403路由函数压根不会执行。这种机制天然适合做权限收敛。权限校验逻辑不用散落在每个路由里而是集中在依赖层统一完成。我在后面会把核心代码完整贴出来你就明白为什么说闭眼抄。3. 权限方案核心拆解从 JWT 签发到依赖注入守卫逐段代码说明下面进入正题。我会按环境准备 → JWT 工具函数 → 用户依赖 → 角色依赖 → 路由接入 → 资源归属校验的顺序把完整代码串联起来。这套代码严格对应我当时落地的版本只是把业务字段做了脱敏。3.1 环境准备与目录结构我当时的项目是 FastAPI SQLAlchemy PostgreSQL认证相关的包用到了这些包名用途fastapiWeb 框架本身python-jose[cryptography]生成和解析 JWTpasslib[bcrypt]密码哈希校验python-multipart兼容 OAuth2PasswordBearer 的表单解析依赖注入的目录我单独建了 app/deps.pyJWT 工具单独建了 app/core/security.py用户模型在 app/models/user.py。这里强调一下目录组织不是因为好看而是因为依赖注入必须形成独立的模块才能到处复用。如果你把 get_current_user 写在某个路由文件里过两天其他路由想用的时候就得 import 一个路由文件模块依赖直接乱掉。3.2 JWT 签发与解析两个工具函数搞定JWT 的底层机制不需要自己实现python-jose 已经封装好了。我主要封装了 create_access_token 和 decode_token 两个函数。签发时把用户 ID 和角色放进 token 的 payload过期时间设为 30 分钟。密钥从环境变量读取生产环境务必用足够长的随机字符串。from datetime import datetime, timedelta, timezone import os from jose import jwt, JWTError SECRET_KEY os.getenv(SECRET_KEY, dev-secret-key-change-me) ALGORITHM HS256 ACCESS_TOKEN_EXPIRE_MINUTES 30 def create_access_token(data: dict, expires_delta: timedelta | None None): to_encode data.copy() expire datetime.now(timezone.utc) ( expires_delta or timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES) ) to_encode.update({exp: expire}) return jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) def decode_token(token: str) - dict: try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) return payload except JWTError: raise ValueError(token 无效或已过期)有几个细节必须提醒。第一timezone 一定要用 UTC不要用本地时间。JWT 的 exp 字段是绝对时间戳不同服务器时区不同一旦混用就会出现这台机器签发的 token 那台机器解析直接过期的诡异问题。第二algorithms 参数别省略有些安全扫描工具会盯着这一点。第三payload 里不要放密码这类敏感信息JWT 本身只是 Base64 编码任何人拿到 token 都可以解码看内容。3.3 用户依赖守卫Depends(get_current_user) 背后的完整逻辑这一步是整个方案的基石。我定义了 oauth2_scheme OAuth2PasswordBearer(tokenUrl/auth/login)FastAPI 会自动从 Authorization: Bearer xxx 请求头里取 token。然后写了一个 get_current_user 依赖。from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session from app.core.security import decode_token from app.db.session import get_db from app.models.user import User oauth2_scheme OAuth2PasswordBearer(tokenUrl/auth/login) def get_current_user( token: str Depends(oauth2_scheme), db: Session Depends(get_db), ): credentials_exception HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail无效的认证凭据, headers{WWW-Authenticate: Bearer}, ) try: payload decode_token(token) user_id payload.get(sub) if user_id is None: raise credentials_exception except ValueError: raise credentials_exception user db.query(User).filter(User.id int(user_id)).first() if user is None: raise credentials_exception return user这个依赖做了三件事解析 token、从 token 中取用户 ID、查库确认用户真实存在。为什么要查库如果只信 token 里的信息万一用户在签发 token 之后被禁用或删除token 依然有效这是个安全隐患。当然每次请求都查库会引入一次数据库查询性能上有点损耗。我接受这个损耗因为内部系统的接口调用量不大而且实时校验用户状态带来的安全性收益明显高于那几毫秒的延迟。如果接口量特别大可以在依赖里加一层缓存或者只在关键写操作时查库。3.4 角色依赖四种角色一个工厂函数搞定上面解决了是不是合法用户接下来解决有没有权限做这件事。我写了一个角色依赖工厂 get_required_role它接受一个角色列表返回一个依赖函数。FastAPI 在调用路由函数前会先执行这个依赖检查当前用户角色是否在允许列表里。def require_roles(allowed_roles: list[str]): def role_checker(user: User Depends(get_current_user)): if user.role not in allowed_roles: raise HTTPException( status_codestatus.HTTP_403_FORBIDDEN, detail没有权限执行此操作, ) return user return role_checker我们项目的角色体系比较简单admin、operator、viewer 三种外加外部对接账号 external。admin 能管理用户和配置operator 能操作业务数据viewer 只能读取external 只访问合作方专用的接口。这种依赖工厂的方式扩展起来非常方便比如某接口允许 admin 和 operator只需要写 Depends(require_roles([admin, operator]))不需要为了每种组合写一个新的依赖函数。这里顺便说一下 401 和 403 的区别。token 缺失、无效、过期返回 401 Unauthorized用户身份有效但角色不够返回 403 Forbidden。一个是没证明你是你一个是证明了你但没资格。两者混用会导致前端判断逻辑混乱所有错误码都要在依赖层统一定义好后面排查问题也省事。3.5 路由接入一行 Depends权限校验全自动有了上面的依赖路由函数写起来非常清爽。以获取用户列表接口为例只需要加 Depends(require_roles([admin]))FastAPI 会自动先解析 token、校验用户状态、校验角色全部通过才执行路由函数体。app.get(/users, response_modellist[UserOut]) def list_users( current_user: User Depends(require_roles([admin])), db: Session Depends(get_db), ): return db.query(User).all()再往下一层还有资源归属校验。比如普通用户查询订单即使登录了、角色也匹配也不能查别人的订单。所以查询逻辑里必须强制带上当前用户的 ID比如 db.query(Order).filter(Order.id order_id, Order.user_id current_user.id)。这一步看起来不起眼实际上是对付横向越权的关键防线。JWT 角色依赖只能保证这个人是合法用户、角色对但这条数据是不是他的必须由业务层自己把关。我在代码 review 中明确要求凡是查询单条数据的接口必须带上归属条件。3.6 登录接口唯一签发 token 的地方登录接口负责校验用户名密码并签发 token。密码校验用了 passlib 的 CryptContext哈希算法选的 bcrypt。有个细节是 passlib 1.7.4 版本和 bcrypt 4.x 有个兼容性警告会提示about属性问题虽然不影响运行但建议直接把 bcrypt 固定到 3.2.0 规避或者干脆换用单独安装 bcrypt 库。from passlib.context import CryptContext from fastapi.security import OAuth2PasswordRequestForm pwd_context CryptContext(schemes[bcrypt], deprecatedauto) app.post(/auth/login) def login( form_data: OAuth2PasswordRequestForm Depends(), db: Session Depends(get_db), ): user db.query(User).filter(User.username form_data.username).first() if not user or not pwd_context.verify(form_data.password, user.hashed_password): raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail用户名或密码错误, ) token create_access_token({sub: str(user.id), role: user.role}) return {access_token: token, token_type: bearer}OAuth2PasswordRequestForm 会自动解析 application/x-www-form-urlencoded 格式的请求体。前端那边不需要自己拼 JSON用标准的 form 提交就行。FastAPI 在 /docs 的 Swagger 界面里也会自动生成对应的交互控件调试起来非常方便。4. 踩坑 8 小时权限系统里最容易翻车的细节复盘这一部分是我最想写的。权限方案的主流程其实不难难的是那些藏在水面下的细节。我这次踩坑的 8 个小时里至少有 6 个小时耗在下面这四类问题上。我把排查过程完整写在下面希望能帮你跳过这些坑。4.1 Depends 的重复执行与假缓存陷阱我最早写 get_current_user 的时候没有认真理解 FastAPI 的依赖缓存机制。我当时在两个地方用了同一个依赖一个路由函数里同时声明了 Depends(get_current_user) 和 Depends(require_roles([admin]))。因为 require_roles 内部调用了 get_current_user所以 get_current_user 在这个请求里会被执行两次。FastAPI 对同一个依赖默认是有缓存行为的。判断依据是是否使用了可调用对象的同一个实例。这里的关键在于如果我用函数名直接传给 DependsFastAPI 能识别出这是同一个依赖调用并自动复用结果。但当时我用的是返回依赖的工厂函数每次调用它都会生成一个新的依赖函数对象这会导致缓存失效get_current_user 会背执行两次。赶上约束条件更新还会出现延迟。排查过程我一开始发现日志里每次请求都有两次数据库用户查询开始没在意以为是日志重复。后来通过加上中间件统计数据库 session 的开启次数才确认同一个请求真的查了两次库。我在两个依赖里分别打印了 user.id发现打印了两遍。进一步查看 FastAPI 官方文档里关于依赖项缓存的说明才发现问题出在工厂函数上。解决办法让工厂函数在模块内部实例化一次而不是在路由声明时反复调用。比如写成 admin_required require_roles([admin])然后在路由里用 Depends(admin_required)。这样 FastAPI 才能正确识别并复用同一个依赖实例。如果你用了 DB 查询依赖但走了两次查询大概率就是这个问题。admin_required require_roles([admin]) operator_required require_roles([operator])这是非常典型的 FastAPI 依赖缓存问题几乎每个用依赖工厂做权限控制的人都会遇到。养成模块常量化依赖实例的好习惯后问题自然消失。4.2 401 与 403 的边界不要全都抛 401一开始我把所有鉴权失败的情况都统一抛 401。结果前端在用户 token 没过期但角色不够的情况下会误判会话失效强制用户重新登录。体验特别差而且排查问题的时候也难以区分登录出问题和权限不足。排查过程某运营同事反馈他明明在用 operator 账号登录点某个功能却老是跳回登录页。我立刻想到是不是 token 出了问题但查日志发现接口返回的是 401。再一看代码role_checker 里抛的是 HTTPException(status_code401)前端全局拦截器一看 401 就把用户踢回登录页了。解决办法严格按照语义区分——认证失败token 缺失/无效/过期返回 401授权失败角色不足、无权限操作资源返回 403。另外还要统一异常响应格式方便前端处理。FastAPI 的 HTTPException 默认返回 {detail: ...} 结构我没有额外封装让全局异常处理器处理掉这个格式问题即可。4.3 JWT 的算法混淆与密钥管理我在一个分支版本里用过 HS256 做签名另一次在测试联调时不小心用 RS256 的验签逻辑去解析 HS256 签发的 token导致所有 token 解析失败。这个坑很隐蔽因为代码逻辑本身没有任何报错提示只是每次请求都 401。我一直建议项目里统一固定算法和密钥配置不要搞两套。decode_token 里把 algorithms 写死为一个列表不允许动态传入。密钥管理方面开发环境可以在 .env 里放一个默认值但生产环境必须从环境变量或配置中心注入。另外千万别把 SECRET_KEY 提交到 git 仓库哪怕只是内部仓库也不建议。一旦泄露攻击者可以自己签发任何身份的 token所有安全防护形同虚设。我见过有团队把密钥硬编码进代码仓库然后在运维平台里配置了另一个密钥两边不一致导致线上服务频繁 401排查了半天才发现是配置不一致。4.4 密码哈希的 bcrypt 兼容性这个坑出现在我升级依赖库的时候。passlib 1.7.4 搭配 bcrypt 4.1 以上版本时会抛出一个 warning(trapped) error reading bcrypt version。虽然不影响正常哈希和校验但日志被刷得非常难看而且在某些异常分支还可能触发异常。排查过程升级依赖后登录接口开始在日志里刷这个 warning但登录功能正常。我怀疑是环境问题在网上搜索确认是 passlib 与新版 bcrypt 的兼容性问题。两个方案一是把 bcrypt 固定为 3.2.0二是放弃 passlib直接使用 bcrypt 库自己封装 hash 和 verify 函数。我后来选了第二种顺便把密码哈希逻辑封装成了独立模块代码更轻也少一个依赖。import bcrypt def hash_password(password: str) - str: return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() def verify_password(password: str, hashed: str) - bool: return bcrypt.checkpw(password.encode(), hashed.encode())5. 上线前还要补的几件事防暴力破解、细粒度授权、审计日志权限主链路跑通之后整个系统终于不再是裸奔状态。但距离一个合格的权限体系还有好几件重要的事。有些是我当时上线前必须补齐的有些是后续迭代中逐步增加的这里一并分享。5.1 登录接口的限流与防暴力破解登录接口是攻击者重点照顾的对象。没有限流的话攻击者可以对密码做无限次猜测。我当时在网关层加了一个简单的 IP 维度限流同一 IP 每分钟最多 10 次登录尝试超过就拒掉并返回 429。同时还有用户名维度的策略一个账号连续失败 5 次锁定 15 分钟。限流可以放在网关、中间件或者业务代码里。考虑到部署简单我当时用 FastAPI 中间件实现的 IP 限流后来项目规模上来后迁移到了网关统一管理。两种方式都可以关键是要有限流这种意识具体实现可以因地制宜。另外登录成功和失败的日志必须记录包括用户名、IP、时间、User-Agent。一方面用于排查问题另一方面安全审计也需要这个数据。日志要设置保留周期尤其是包含敏感的登录行为日志避免无期限堆积带来的数据风险。5.2 从角色控制走向资源级细粒度控制角色控制解决的是角色能不能访问接口的问题但真实的业务场景往往更复杂。比如一个文档系统里文档本身有一个所有者字段还有协作者字段。你是一个普通用户角色没毛病但你能不能编辑某份文档取决于你是不是它的所有者或协作者。这时候单一的角色校验就不够用了必须做资源级授权。我的经验是分两层来设计第一层是功能权限有哪些角色能进这个门用 require_roles 这类依赖控制第二层是资源权限进了门之后能碰哪些东西在业务代码里根据当前用户和资源归属关系判断。两层组合起来才能真正杜绝越权。我当时还做了一个小型的 permission 模块把对某资源某操作的权限做成可配置的规则外接合作方账号时非常灵活。5.3 刷新令牌与令牌主动失效JWT 本身是无状态的它只认签名不会主动失效。如果某个 token 被泄露你没办法让它立刻失效只能等它过期。为了解决这个问题我给关键操作类型引入了 refresh token 机制access token 的有效期缩短到 15 分钟长期有效的 refresh token 存在数据库里刷新时校验 refresh token 的有效性和是否被吊销。很多团队觉得 JWT 已经是先进方案了没必要再做 refresh token。但一旦访问令牌泄露你难道让所有用户强制重新登录吗有了 refresh token你就可以在数据库里吊销它从而让关联的 access token 也失去刷新能力让泄露影响可控。这套机制实现不难而且对体验的伤害远比想象中小。5.4 审计日志谁、在什么时间、对什么资源、做了什么做权限加固不仅要拦住不该有的人还要在出问题时能还原现场。审计日志就是现场复原的关键。我当时在 core 操作接口上增加了日志记录操作人、操作时间、操作对象、操作内容摘要、来源 IP。落到独立的日志表通过一个简单的事件系统统一写入。日志至少要保证几条底线不能因为业务异常而丢日志不能由业务人员直接删时间要用 UTC8 统一记录。合规检查时这套日志会直接决定安全事故的责任判定平时也能通过分析异常操作及时发现问题。6. 一点额外心得先跑通最小闭环再谈复杂架构最后分享一个我踩了足够多次坑才悟出来的道理权限系统的建设一定要先跑通最小闭环再逐步加复杂度。我第一次做这套方案时思路特别宏大想一步到位把 RBAC、资源权限、刷新令牌、审计日志全做进去。结果卡了整整两周项目还停留在设计文档阶段业务方天天来催。后来我推倒重来先只做了最核心的四件事JWT 签发、用户依赖、角色依赖、资源归属校验。两天时间就上了线把越权漏洞补上了然后再逐个迭代增加限流、刷新令牌、审计日志。安全建设不是一次性交付而是一个持续加固的过程。先解决最痛的越权问题让系统从裸奔到有防护再一步步让防护体系更完整。如果你的项目也正处于API 裸奔状态我建议你今晚就开始做最小闭环写好 JWT 工具函数搭好 get_current_user 依赖把核心接口全部套上 Depends。当你看到那些原本谁都能调的接口开始自己拦截非法请求时你会觉得这 8 个小时花得特别值。