FastAPI 接口鉴权与权限设计
做 API 服务时,很多人一开始只关心“接口能不能跑起来”,等真正接到前端、移动端、第三方调用方,或者被爬虫、脚本、越权访问打过几次之后,才会意识到:鉴权不是给接口加个 token 就结束,权限也不是简单判断是不是管理员。
我自己在做 FastAPI 项目时,踩过一个很典型的坑:
前期为了开发快,所有接口只校验 JWT 是否有效;结果上线后发现,“登录用户 A 可以通过修改路径参数,读到用户 B 的资源”。这不是认证失败,而是授权设计不完整。
这篇文章我会从架构角度,把 FastAPI 中常见的接口鉴权与权限设计拆开讲清楚:
- 认证和授权到底怎么分层
- JWT、角色、资源权限该怎么组合
- FastAPI 依赖注入怎么优雅落地
- 什么场景适合 RBAC,什么场景要加 ABAC
- 最后给一套可运行示例,能直接作为项目骨架
背景与问题
在中型 Web 服务里,接口安全通常会遇到这几类问题:
-
只有登录校验,没有资源级权限控制
- 用户拿到 token 后,能访问“本不属于自己”的数据
- 常见于
/users/{id}、/orders/{id}、/files/{id}这类资源接口
-
权限逻辑散落在各个接口里
- 每个路由函数都自己写 if/else
- 时间一长,规则不一致,审计困难
-
角色设计过粗
- 只有 admin / user 两种角色
- 一旦业务增加“运营、审计、客服、只读管理员”等身份,就开始失控
-
令牌设计不完整
- Access Token 没过期时间
- Refresh Token 没有吊销机制
- token 泄露后影响面过大
-
对外接口和内部接口混用同一套策略
- 内部服务间调用适合服务身份认证
- 用户侧接口适合用户身份认证
- 两者混在一起,后面很难扩展
所以,真正稳定的设计通常是三层:
- 认证(Authentication):你是谁
- 授权(Authorization):你能做什么
- 审计(Audit):你做过什么
如果把这三件事全塞进一个 get_current_user() 里,后期会非常痛苦。
核心原理
1. 认证与授权的边界
很多同学会把“登录了”和“有权限”当成一回事。实际上不是。
- 认证:校验身份是否可信
例如用户名密码登录成功、JWT 签名合法、令牌未过期 - 授权:判断该身份是否允许执行当前动作
例如是否允许删除订单、导出报表、查看别人的数据
可以把它理解成:
认证回答“你是谁”,授权回答“你配不配”。
2. 常见鉴权模型
2.1 Session / Cookie
适合传统服务端渲染场景。FastAPI 也能做,但前后端分离项目里不如 JWT 常见。
优点:
- 服务端可控,易失效
- 适合浏览器场景
缺点:
- 分布式部署需要共享 session
- 对移动端、第三方 API 支持一般
2.2 JWT Bearer Token
这是 FastAPI 里最常见的接口鉴权方式。
优点:
- 无状态,便于横向扩展
- 适合前后端分离和 OpenAPI 风格接口
缺点:
- 一旦签发,失效控制相对麻烦
- payload 放太多信息会导致变更困难
- 容易被误用成“把所有权限都写死到 token 里”
我更推荐的原则是:
- token 里只放稳定且必要的信息:
sub、uid、role_version、exp - 细粒度权限仍以数据库或权限中心为准
- 对高风险接口,必要时做二次校验
3. 权限设计:RBAC、ABAC、资源归属
权限设计最常见的有三种思路。
3.1 RBAC:基于角色的访问控制
例如:
- admin:全部权限
- operator:查看/编辑部分业务数据
- auditor:只读审计数据
优点:
- 简单,易维护
- 适合后台管理系统
缺点:
- 对“只能看自己创建的数据”这类规则表达不够自然
3.2 ABAC:基于属性的访问控制
规则基于用户、资源、环境属性判断,例如:
- 用户部门 == 资源部门
- 用户是资源创建者
- 请求时间在允许窗口内
优点:
- 灵活,适合复杂业务
缺点:
- 实现复杂,容易分散在代码里
3.3 混合模式:RBAC + 资源归属
这是很多中型项目最实用的方案:
- 先用 RBAC 决定“有没有这个动作的资格”
- 再用 资源归属规则 决定“能不能操作这条具体数据”
例如:
admin可以删除任意文章editor可以编辑任意文章,但不能删author只能编辑自己写的文章
这比纯 RBAC 更贴近真实业务。
方案对比与取舍分析
方案一:只做 JWT 登录校验
适合:
- 内部小工具
- 低风险接口
- 原型验证阶段
问题:
- 只能识别“已登录”
- 很快就会出现水平越权
方案二:JWT + 角色校验(RBAC)
适合:
- 中后台系统
- 菜单和按钮权限相对固定
问题:
- 面对数据归属规则时不够细
方案三:JWT + RBAC + 资源级授权
适合:
- 大多数业务 API
- 用户数据、订单、内容平台、SaaS 系统
优点:
- 架构清晰
- 兼顾可维护性和业务表达能力
这也是本文推荐的主方案。
整体架构设计
下面先看一个典型调用链。
flowchart TD
A[客户端请求] --> B[FastAPI 路由]
B --> C[认证依赖: 校验 JWT]
C --> D[加载当前用户]
D --> E[授权依赖: 校验角色/权限]
E --> F[资源级校验: 是否拥有该资源]
F --> G[执行业务逻辑]
G --> H[审计日志/响应结果]
这个流程里有三个关键分层:
- 认证层:从 Header 中取 Bearer Token,解码并验证
- 授权层:判断用户是否具备某个权限点
- 资源层:检查资源是否归属于该用户,或其是否有跨资源能力
权限模型设计建议
建议把权限拆成下面这几个概念:
- User:用户
- Role:角色
- Permission:权限点,例如
article:read、article:write - Resource Owner:资源归属,如
article.owner_id - Policy:补充规则,如“可读全部 / 仅可读本人”
一个比较实用的权限关系如下:
classDiagram
class User {
+int id
+string username
+bool is_active
+list roles
}
class Role {
+string name
+list permissions
}
class Permission {
+string code
}
class Article {
+int id
+int owner_id
+string title
+string content
}
User --> Role
Role --> Permission
User --> Article : owns
权限码命名建议
推荐统一成:
resource:actionarticle:readarticle:writearticle:deleteuser:read_all
这样前后端、审计、接口文档都能对齐。
实战代码(可运行)
下面给一个可运行的 FastAPI 示例,演示:
- JWT 登录
- 获取当前用户
- 基于权限点的鉴权
- 基于资源归属的权限控制
安装依赖:
pip install fastapi uvicorn pyjwt
完整示例
from datetime import datetime, timedelta, timezone
from typing import Optional, Set, Dict, Any
import jwt
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
app = FastAPI(title="FastAPI Auth Demo")
SECRET_KEY = "replace-this-with-a-strong-secret"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
security = HTTPBearer()
# ---- 模拟数据库 ----
users_db = {
1: {
"id": 1,
"username": "alice",
"password": "alice123",
"is_active": True,
"roles": ["admin"],
"permissions": {"article:read", "article:write", "article:delete", "user:read_all"},
},
2: {
"id": 2,
"username": "bob",
"password": "bob123",
"is_active": True,
"roles": ["author"],
"permissions": {"article:read", "article:write"},
},
3: {
"id": 3,
"username": "charlie",
"password": "charlie123",
"is_active": False,
"roles": ["viewer"],
"permissions": {"article:read"},
},
}
articles_db = {
1: {"id": 1, "title": "Admin Article", "content": "by alice", "owner_id": 1},
2: {"id": 2, "title": "Bob Article", "content": "by bob", "owner_id": 2},
}
# ---- Pydantic 模型 ----
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class User(BaseModel):
id: int
username: str
is_active: bool
roles: list[str]
permissions: Set[str]
class Article(BaseModel):
id: int
title: str
content: str
owner_id: int
# ---- 工具函数 ----
def find_user_by_username(username: str) -> Optional[Dict[str, Any]]:
for user in users_db.values():
if user["username"] == username:
return user
return None
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 已过期",
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效",
)
# ---- 认证依赖 ----
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> User:
token = credentials.credentials
payload = decode_access_token(token)
user_id = payload.get("uid")
if user_id is None or user_id not in users_db:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或 token 无效",
)
user_data = users_db[user_id]
user = User(**user_data)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用户已被禁用",
)
return user
# ---- 授权依赖 ----
def require_permissions(*required_permissions: str):
def checker(current_user: User = Depends(get_current_user)) -> User:
missing = [p for p in required_permissions if p not in current_user.permissions]
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"缺少权限: {', '.join(missing)}",
)
return current_user
return checker
# ---- 资源级校验 ----
def get_article(article_id: int) -> Article:
if article_id not in articles_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文章不存在",
)
return Article(**articles_db[article_id])
def can_access_article(current_user: User, article: Article, action: str) -> bool:
# admin 拥有全部文章操作能力
if "admin" in current_user.roles:
return True
# 读取文章:有 article:read 即可;如果后续要求只读自己的文章,可在这里加归属判断
if action == "read":
return "article:read" in current_user.permissions
# 写文章:只能修改自己的文章
if action == "write":
return "article:write" in current_user.permissions and article.owner_id == current_user.id
# 删除文章:普通作者不允许
if action == "delete":
return "article:delete" in current_user.permissions and article.owner_id == current_user.id
return False
# ---- 接口 ----
@app.post("/login", response_model=TokenResponse)
def login(data: LoginRequest):
user = find_user_by_username(data.username)
if not user or user["password"] != data.password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
token = create_access_token(
data={"uid": user["id"], "sub": user["username"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return TokenResponse(access_token=token)
@app.get("/me")
def me(current_user: User = Depends(get_current_user)):
return current_user
@app.get("/users")
def list_users(current_user: User = Depends(require_permissions("user:read_all"))):
return [{"id": u["id"], "username": u["username"]} for u in users_db.values()]
@app.get("/articles/{article_id}")
def read_article(article_id: int, current_user: User = Depends(get_current_user)):
article = get_article(article_id)
if not can_access_article(current_user, article, "read"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看该文章",
)
return article
@app.put("/articles/{article_id}")
def update_article(
article_id: int,
payload: dict,
current_user: User = Depends(get_current_user),
):
article = get_article(article_id)
if not can_access_article(current_user, article, "write"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权修改该文章",
)
articles_db[article_id]["title"] = payload.get("title", article.title)
articles_db[article_id]["content"] = payload.get("content", article.content)
return articles_db[article_id]
@app.delete("/articles/{article_id}")
def delete_article(
article_id: int,
current_user: User = Depends(get_current_user),
):
article = get_article(article_id)
if not can_access_article(current_user, article, "delete"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权删除该文章",
)
deleted = articles_db.pop(article_id)
return {"message": "删除成功", "article": deleted}
启动服务
uvicorn main:app --reload
试一下调用流程
1)登录获取 token
curl -X POST "http://127.0.0.1:8000/login" \
-H "Content-Type: application/json" \
-d '{"username":"bob","password":"bob123"}'
2)查看当前用户
curl "http://127.0.0.1:8000/me" \
-H "Authorization: Bearer <你的token>"
3)访问文章
curl "http://127.0.0.1:8000/articles/2" \
-H "Authorization: Bearer <你的token>"
4)尝试修改别人的文章
如果用 bob 去修改 article_id=1,会返回 403,这就是资源级权限控制的意义。
请求时序图
把上面的过程再用时序图串一下,会更容易看清责任边界。
sequenceDiagram
participant C as Client
participant A as FastAPI API
participant D as Auth Dependency
participant P as Permission Checker
participant R as Resource Checker
C->>A: 请求 /articles/2
A->>D: 解析 Authorization Bearer Token
D->>D: 验证 JWT / 加载用户
D-->>A: current_user
A->>R: 校验文章归属与动作权限
R-->>A: 允许/拒绝
A-->>C: 200 或 403
为什么 FastAPI 适合做鉴权分层
FastAPI 一个很大的优势,就是依赖注入天然适合把认证和授权拆开。
比如:
get_current_user()只负责认证require_permissions()只负责权限点校验can_access_article()只负责资源级判断
这样做的好处有三个:
-
可复用
- 同一个权限依赖可以挂到多个接口
-
可测试
- 可以分别测 token 校验、权限点校验、资源归属逻辑
-
可演进
- 以后从本地内存切到数据库、Redis、权限中心,不会把整个路由层改烂
常见坑与排查
这一部分很重要,因为很多问题不是“代码写不出来”,而是“明明写了,为什么线上还是出事”。
1. 只校验 token,不校验资源归属
现象
用户已登录,但能访问或修改不属于自己的数据。
根因
把“已登录”误认为“有权限”。
排查方式
- 检查所有带
id的资源接口:/users/{id}/orders/{id}/articles/{id}
- 看是否存在“只用
get_current_user(),没有资源归属判断”的情况
建议
- 所有资源型接口都做 owner / scope / tenant 校验
- 管理员例外规则单独处理,不要默认放行所有用户
2. 把完整权限集合塞进 JWT
现象
角色变了,但老 token 仍然保留旧权限;或者 token 太大。
根因
过度依赖 JWT payload,把权限快照写死。
排查方式
- 解码 token 看 payload
- 如果里面包含几十上百个权限项,基本就要警惕
建议
- token 只存最小身份信息
- 权限实时从服务端加载,或者至少挂一个
permission_version
3. 401 和 403 混用
这是很多项目里最常见的小坑。
- 401 Unauthorized:你还没通过身份认证
- 403 Forbidden:你身份是对的,但没权限
错误做法
- token 错误返回 403
- 权限不足返回 401
这会让前端、日志系统、网关策略都不好处理。
4. 依赖顺序混乱
有些人会把权限校验写在认证之前,导致空用户对象、异常不一致。
建议顺序
- 认证
- 用户状态校验(是否禁用)
- 权限点校验
- 资源级校验
5. Swagger 文档里能调,线上客户端却失败
常见原因
- Header 名不对
- 没带
Bearer - CORS 配置没允许
Authorization - 反向代理丢掉了认证头
排查建议
- 用浏览器开发者工具看实际请求头
- 在网关和应用层都打日志
- 检查 Nginx / API Gateway 是否透传
Authorization
6. 密码明文存储
示例代码里为了演示简单,直接用明文密码比较。
生产环境绝对不能这样做。
至少要做:
passlib/bcrypt哈希存储- 登录失败次数限制
- 异常登录告警
这个坑我必须强调,因为很多人会把 demo 代码直接复制进项目。
安全/性能最佳实践
这一部分是落地时最有价值的地方。能不能长期稳定运行,往往取决于这些细节。
1. Access Token 短效,Refresh Token 可控
推荐:
- Access Token:15~30 分钟
- Refresh Token:7~30 天
- Refresh Token 存储到数据库或 Redis,可吊销
这样即使 access token 泄露,风险窗口也有限。
2. 不要把“鉴权”当成唯一防线
鉴权只解决“谁能访问”,还要配合:
- 限流
- 风控
- 审计日志
- 敏感操作二次确认
- IP / 设备异常检测
尤其是对外 API,如果只靠 JWT,防不住 token 被盗后的滥用。
3. 高风险接口做细粒度审计
例如:
- 删除数据
- 导出报表
- 修改权限
- 下载敏感文件
建议记录:
- 用户 ID
- 接口路径
- 请求参数摘要
- 结果码
- 请求时间
- 来源 IP
- Trace ID
后面出了问题,定位效率会高很多。
4. 权限缓存要有失效策略
如果权限实时查库压力太大,可以做缓存,但要注意:
- 缓存 key 包含用户版本号或角色版本号
- 用户角色变更时主动失效
- 不要无脑长时间缓存
否则会出现“权限已回收,但还能访问几分钟甚至几小时”的问题。
5. 多租户系统必须加入 Tenant 边界
如果你的系统是 SaaS,多租户隔离必须作为默认规则,而不是额外逻辑。
权限判断至少包含:
- 用户是否有该动作权限
- 资源是否属于当前租户
- 是否允许跨租户访问
这类问题一旦出错,后果通常比普通越权更严重。
6. 生产环境的密钥管理
JWT 的 SECRET_KEY 不要写死在代码里。
建议:
- 通过环境变量注入
- 使用密钥管理服务
- 定期轮换密钥
- 区分开发、测试、生产环境
如果项目规模再大一些,可以考虑非对称签名(如 RS256),便于签发端与验证端分离。
容量与演进思路
架构设计不只是“今天能用”,还要考虑后面怎么扩展。
小型项目
可以先用:
- FastAPI + JWT
- 用户表直接带角色
- 代码内权限依赖
特点:
- 简单直接
- 适合早期业务
中型项目
建议升级到:
- 用户、角色、权限三张核心关系表
- 权限码统一管理
- Redis 做权限缓存
- 关键操作审计
特点:
- 能支撑团队协作
- 便于管理后台和 API 统一
大型项目
进一步演进为:
- 独立认证中心
- 独立权限服务 / Policy Engine
- 服务间使用机器身份认证
- 用户鉴权与内部服务鉴权分离
特点:
- 复杂度高,但更适合多系统协同
下面这个状态图可以帮助理解令牌生命周期:
stateDiagram-v2
[*] --> Issued
Issued --> Active: 客户端携带访问
Active --> Expired: 超过 exp
Active --> Revoked: 主动吊销/退出登录
Revoked --> [*]
Expired --> [*]
一个更稳妥的落地原则
如果让我给中级开发者一个最实用的建议,我会总结成这 5 条:
- 认证、授权、资源校验必须拆开
- 接口权限不要只看角色,还要看资源归属
- JWT 只放必要信息,不要把权限快照塞满
- 401/403 语义分清楚
- 权限变更、敏感操作必须有审计
这五条做好,绝大多数 FastAPI 项目的接口安全基线就有了。
总结
FastAPI 的优势,不只是“写接口快”,而是它用依赖注入把鉴权链路天然分层,这非常适合做清晰的权限架构。
这篇文章的核心结论可以归纳为:
- 认证解决“你是谁”
- 授权解决“你能做什么”
- 资源级校验解决“你能不能操作这条数据”
在实际项目里,我最推荐的不是纯 JWT,也不是纯角色判断,而是:
JWT + RBAC + 资源归属校验 的混合方案
它足够实用,也足够稳定,适合大多数中型 FastAPI 服务。
如果你现在正准备落地一套权限系统,可以按下面顺序执行:
- 先实现
get_current_user()做认证 - 再抽象
require_permissions()做权限点校验 - 最后为每类资源建立归属判断函数
- 对高风险接口补上审计和吊销机制
这样做,代码不会一开始就很重,但后续扩展空间非常大。
而且最关键的是:你不会在“用户明明登录了,为什么还能越权”这种问题上反复返工。