1. 为什么要在Django项目里自建Token认证
做过前后端分离项目的人多半都经历过这个阶段:一开始图省事,直接用Django自带的Session认证,前端登录后靠Cookie维持状态。本地开发时一切正常,等到前端换成移动端App、小程序,或者后端要同时服务多个客户端时,Session那套就开始处处别扭——跨域携带Cookie麻烦、多端登录状态难同步、服务端还得存一堆Session记录。这时候把认证方式换成Token,基本是绕不开的一步。
我这次要聊的,就是基于Django + Django REST framework + JWT这套组合,从零搭一套可用的API Token认证机制。核心目标很明确:用户用账号密码换到一对Token(访问令牌+刷新令牌),之后所有需要登录的接口都带着访问令牌来请求,服务端验证令牌合法性后放行。访问令牌过期了,用刷新令牌换新的,不用让用户重新登录。
这套东西能解决什么问题?简单说三点。第一,无状态,服务端不用存Session,令牌本身携带了用户身份信息,验证靠签名,天然适合分布式部署。第二,跨端友好,不管是Web、App还是其他客户端,只要能在请求头里带Token就能用,不依赖Cookie。第三,可控性强,令牌有明确的过期时间,可以主动失效,权限粒度也能做得比较细。
适合谁来参考?如果你已经写过一点Django,知道Model、View、URL怎么配,但对DRF的认证体系还比较模糊,或者用过JWT但没搞清楚里面每个参数到底在干嘛,那这篇内容应该能帮到你。我会把选型理由、核心原理、完整实操步骤、参数计算,还有我自己踩过的坑都摊开讲,尽量让你看完能直接照着搭起来。
需要提前说明的是,下面涉及的具体项目名、模块名我都会用通用叫法,你套到自己项目里时按实际结构调整即可。整套方案基于常见实践补充了细节,不同版本之间可能有细微差异,以你实际安装的版本为准。
2. 认证方案选型与整体设计思路
2.1 为什么是JWT而不是普通Token
在DRF里做Token认证,其实有两条主流路线。一条是DRF自带的TokenAuthentication,用户登录后服务端生成一个随机字符串存进数据库,每次请求拿这个字符串来比对。另一条就是JWT,令牌本身是一段经过签名的结构化数据,服务端不存。
我选JWT,主要基于几个考量。普通Token每次验证都要查一次数据库,请求量大的时候数据库压力不小;JWT验证只需要用密钥验签,纯计算,不碰数据库,性能上更占优。另外普通Token默认没有过期机制,得自己额外做清理逻辑,而JWT天生带exp过期时间。还有一点,JWT的Payload里可以塞一些非敏感的自定义信息,比如用户ID、角色标识,省去一次查询用户的开销。
当然JWT也不是没缺点。最典型的就是令牌一旦签发,在过期前很难主动作废,因为它不存服务端。这个后面我会讲怎么用刷新令牌+黑名单机制来缓解。选型这事没有银弹,关键看你的场景更在意什么。对大多数中小规模的前后端分离项目,JWT的收益是明显大于成本的。
2.2 双令牌机制的设计逻辑
只用一个访问令牌行不行?行,但体验不好。访问令牌如果设得太短,用户动不动就要重新登录;设得太长,一旦泄露风险窗口就很大。所以业界常见做法是拆成两个令牌:
- 访问令牌(Access Token):有效期短,一般几分钟到几小时,用来访问业务接口。
- 刷新令牌(Refresh Token):有效期长,一般几天到几周,唯一用途是换取新的访问令牌。
这样即使访问令牌泄露,攻击者能利用的时间窗口也很短。而刷新令牌虽然有效期长,但它只在刷新接口使用,暴露面小,还可以配合黑名单做主动失效。
我一般会把访问令牌设成30分钟到2小时,刷新令牌设成7天到30天,具体看业务对安全性的要求。金融类场景可以更短,内部工具类可以更长。
2.3 整体请求流程
把流程捋一遍,后面写代码时心里就有谱了:
- 用户提交账号密码到登录接口。
- 服务端校验通过,签发一对令牌返回。
- 客户端保存令牌(Web端一般存内存或localStorage,App端存安全存储)。
- 之后每次请求业务接口,在请求头
Authorization: Bearer <access_token>里带上访问令牌。 - 服务端验证访问令牌的签名和有效期,通过则处理请求。
- 访问令牌过期,客户端拿刷新令牌去刷新接口换新令牌。
- 刷新令牌也过期或失效,用户重新登录。
这个流程里,第5步的验证是最高频的,所以它必须足够快,这也是JWT的优势所在。
3. 环境搭建与依赖配置
3.1 依赖安装与版本说明
先把需要的包装上。核心就三个:Django本体、DRF、以及处理JWT的库。JWT这块我用的是djangorestframework-simplejwt,它是目前DRF生态里维护得比较活跃、文档也比较全的一个实现。
pip install django djangorestframework djangorestframework-simplejwt装完之后建议pip freeze看一下版本,因为simplejwt在不同大版本之间配置项有过调整。我写这篇时参考的是较新的稳定版本,如果你装的是老版本,个别配置名可能对不上,遇到报错先去官方文档核对一下。
3.2 settings.py关键配置
依赖装好,接下来在settings.py里注册应用并配置认证方式。
INSTALLED_APPS = [ # ... 其他应用 'rest_framework', 'rest_framework_simplejwt', 'accounts', # 你自己的用户应用 ] REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), }这里有个细节值得说。DEFAULT_PERMISSION_CLASSES设成IsAuthenticated意味着全局默认所有接口都需要登录。这是个安全优先的默认值,避免你哪天新加了个接口忘了加权限控制,结果裸奔。对于登录、注册这类本来就要匿名访问的接口,单独用@permission_classes([AllowAny])放开就行。反过来,如果你默认设成AllowAny,那就得给每个需要保护的接口手动加IsAuthenticated,漏一个就是漏洞。两害相权,我强烈建议用前者。
3.3 JWT的详细参数配置
simplejwt的配置都放在SIMPLE_JWT字典里。下面这份是我常用的配置,每一项我都标了为什么这么设。
from datetime import timedelta SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, 'UPDATE_LAST_LOGIN': True, 'ALGORITHM': 'HS256', 'SIGNING_KEY': SECRET_KEY, 'AUTH_HEADER_TYPES': ('Bearer',), 'USER_ID_FIELD': 'id', 'USER_ID_CLAIM': 'user_id', }逐条解释一下。ACCESS_TOKEN_LIFETIME设60分钟,是安全性和体验的折中,太短用户频繁刷新,太长风险窗口大。ROTATE_REFRESH_TOKENS设为True,意思是每次用刷新令牌换新访问令牌时,连刷新令牌也一起换新的,这样刷新令牌相当于一次性的,被截获后重放的机会更小。BLACKLIST_AFTER_ROTATION配合前者,把用过的旧刷新令牌加入黑名单,防止它被重复使用。
ALGORITHM用HS256,也就是对称加密,签名和验证用同一个密钥。对单体或中小规模项目足够了。如果你有多个服务需要独立验证令牌,可以考虑RS256非对称加密,公钥验证私钥签名,但配置会复杂一些。
注意:
SIGNING_KEY这里直接用了Django的SECRET_KEY,方便是方便,但生产环境务必确保SECRET_KEY是从环境变量读取的,绝不能硬编码在代码里提交到仓库。密钥一旦泄露,任何人都能伪造令牌。
3.4 黑名单应用的启用
上面配置里用到了黑名单功能,需要额外启用它,否则BLACKLIST_AFTER_ROTATION不生效。
INSTALLED_APPS = [ # ... 'rest_framework_simplejwt.token_blacklist', ]启用后要执行迁移,它会创建两张表,分别记录未过期的令牌和黑名单令牌。
python manage.py migrate这两张表会随着令牌签发和刷新不断增长,所以定期清理过期记录是必须的运维动作。simplejwt自带了一个清理命令,可以挂到定时任务里每天跑一次:
python manage.py flushexpiredtokens我见过有项目上线几个月没清理,这两张表涨到几百万行,刷新接口越来越慢,最后排查半天才发现是这里的问题。这个坑提前避开。
4. 核心接口的实操实现
4.1 登录接口:签发令牌
simplejwt已经内置了登录视图TokenObtainPairView,直接挂到URL上就能用。
from rest_framework_simplejwt.views import ( TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) urlpatterns = [ path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'), ]请求POST /api/token/,body里带上username和password,返回体里就有access和refresh两个字段。开箱即用,非常省事。
但实际项目里,登录往往不只是返回令牌那么简单。你可能还想返回用户昵称、头像、角色这些信息,免得前端登录后再发一次请求查用户资料。这时候就需要自定义序列化器,继承TokenObtainPairSerializer并往返回数据里加字段。
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): @classmethod def get_token(cls, user): token = super().get_token(user) # 往令牌payload里塞自定义信息 token['username'] = user.username token['role'] = user.role return token def validate(self, attrs): data = super().validate(attrs) # 往返回体里加用户信息 data['user'] = { 'id': self.user.id, 'username': self.user.username, 'role': self.user.role, } return data然后在视图里指定这个序列化器:
class CustomTokenObtainPairView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer这里有个经验点:往令牌payload里塞的信息要克制。JWT的payload是Base64编码的,不是加密的,任何人拿到令牌都能解出里面的内容。所以只放非敏感的标识信息,像用户ID、角色这种。手机号、邮箱、身份证这类,坚决不要往里塞。而且payload越大,令牌字符串越长,每个请求都要传输,白白增加带宽。
4.2 刷新接口:换取新令牌
刷新接口同样用内置的TokenRefreshView。请求POST /api/token/refresh/,body里带refresh字段,返回新的access。因为我们开了ROTATE_REFRESH_TOKENS,返回体里还会带一个新的refresh,客户端记得把旧的替换掉。
这里要提醒一个容易出错的点。开了轮换之后,旧的刷新令牌会立即失效。如果客户端在并发场景下同时发起多个刷新请求,只有一个能成功,其他的会拿到401。前端要做个处理:刷新请求加锁,或者收到401后统一走一次刷新再重试。这个坑在移动端弱网环境下特别容易触发,我当初排查了好久才定位到。
4.3 受保护接口的写法
有了认证机制,写受保护接口就很清爽了。DRF的认证类会自动从请求头里解析令牌,验证通过后把用户对象挂到request.user上。
from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated class ProfileView(APIView): permission_classes = [IsAuthenticated] def get(self, request): user = request.user return Response({ 'id': user.id, 'username': user.username, 'email': user.email, })客户端请求时带上头Authorization: Bearer <access_token>,就能拿到当前用户信息。如果令牌无效或过期,DRF会自动返回401,前端捕获到401就走刷新流程。
4.4 自定义权限类
IsAuthenticated只判断有没有登录,实际业务里经常需要更细的权限,比如只有管理员能访问。这时候自定义权限类就派上用场。
from rest_framework.permissions import BasePermission class IsAdminRole(BasePermission): message = '需要管理员权限' def has_permission(self, request, view): return bool( request.user and request.user.is_authenticated and request.user.role == 'admin' )用的时候挂到视图上即可。message属性会在权限不通过时作为错误信息返回,比默认的英文提示友好得多。我建议每个自定义权限类都写上message,前端展示错误时能直接用。
5. 令牌安全加固与进阶处理
5.1 令牌存储位置的选择
令牌签发出来,客户端存哪里,这是个安全性和便利性的权衡题。Web端常见三种选择:
| 存储位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| localStorage | 使用方便,跨标签页共享 | 易受XSS攻击读取 | 对安全要求不高的内部系统 |
| sessionStorage | 标签页关闭即清除 | 同样受XSS影响,不跨标签 | 短会话场景 |
| HttpOnly Cookie | JS无法读取,防XSS | 需处理CSRF,跨域配置复杂 | 安全要求高的场景 |
我的建议是:如果项目对安全要求高,访问令牌放内存(页面刷新就没了,靠刷新令牌恢复),刷新令牌放HttpOnly Cookie。这样即使有XSS,攻击者也拿不到长期有效的刷新令牌。如果只是内部工具,localStorage图省事也能接受,但要清楚风险在哪。
5.2 主动让令牌失效
JWT最大的软肋就是签发后难以主动作废。用户改了密码、账号被封禁,理论上旧令牌在过期前还能用。simplejwt的黑名单机制能解决一部分问题——把刷新令牌加入黑名单,它就换不出新访问令牌了。但已经签发的访问令牌,在它过期前仍然有效。
要彻底解决,有两个思路。一是把访问令牌有效期设得很短,比如5分钟,让失效窗口足够小。二是在服务端维护一个"令牌版本号",用户改密码时版本号加一,验证时比对令牌里的版本号和当前版本号是否一致。后者需要自定义认证类,实现起来稍复杂,但对安全性要求高的场景值得做。
from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.exceptions import InvalidToken class VersionedJWTAuthentication(JWTAuthentication): def get_user(self, validated_token): user = super().get_user(validated_token) token_version = validated_token.get('token_version') if token_version != user.token_version: raise InvalidToken('令牌已失效,请重新登录') return user用户模型上加一个token_version字段,签发令牌时写进去,改密码时递增。这样旧令牌立刻失效。这个方案我在几个对安全敏感的项目里用过,效果不错。
5.3 限流防暴力破解
登录接口是暴力破解的重灾区,必须加限流。DRF自带限流机制,配置一下就能用。
REST_FRAMEWORK = { # ... 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.AnonRateThrottle', ], 'DEFAULT_THROTTLE_RATES': { 'anon': '20/min', }, }匿名用户每分钟20次,对正常用户够用,对暴力破解就是一道墙。登录接口可以单独设更严格的限流,比如每分钟5次。配合账号锁定策略(连续失败N次锁定一段时间),防护就比较到位了。
注意:限流基于IP或用户标识,如果你的服务前面有反向代理,要确保能拿到真实客户端IP,否则所有请求会被当成同一个来源,正常用户也会被误伤。
6. 常见问题排查与避坑实录
6.1 令牌验证失败的排查思路
令牌验证失败是最常见的问题,报错信息往往比较笼统。我整理了一张速查表,按现象定位原因。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 401 且提示令牌无效 | 签名密钥不一致 | 检查多环境SECRET_KEY是否统一 |
| 401 且提示令牌过期 | 访问令牌超时 | 走刷新流程或调整有效期 |
| 401 且提示认证头格式错误 | 请求头写法不对 | 确认是Bearer <token>格式 |
| 刷新时提示令牌在黑名单 | 旧刷新令牌被重复使用 | 检查前端是否并发刷新 |
| 令牌能解出但用户查不到 | 用户已被删除 | 检查USER_ID_CLAIM对应字段 |
签名密钥不一致这个坑特别隐蔽。开发环境用一套密钥,测试环境用另一套,令牌跨环境自然验不过。如果你们有多环境部署,务必确认密钥管理策略,别让令牌跨环境混用。
6.2 时钟偏移导致的诡异过期
JWT的exp是基于签发服务器的时间。如果签发令牌的服务器和验证令牌的服务器系统时间有偏差,就可能出现"刚签发就过期"或者"过期了还能用"的诡异现象。分布式部署时尤其要注意,所有节点必须做时间同步。这个问题排查起来很折磨人,因为本地测试永远复现不了,只有线上多节点才偶发。我的经验是,部署时把NTP时间同步作为标准动作,别省这一步。
6.3 令牌体积过大的处理
前面提过往payload塞太多信息会让令牌变长。我实测过一个极端案例,payload里塞了用户权限列表、部门树、菜单配置,令牌字符串长到快2KB,每个请求都带着它,接口响应时间肉眼可见地变慢。后来把非必要信息全砍掉,只留用户ID和角色,令牌缩到300字节以内,问题解决。令牌里只放验证身份必需的最小信息,其他数据需要时再查,这是原则。
6.4 刷新令牌轮换的并发问题
前面提过并发刷新会导致部分请求失败。除了前端加锁,服务端也可以做点优化。比如给刷新接口加一个短暂的宽限期,旧刷新令牌在轮换后的一小段时间内仍然可用。不过这需要改simplejwt的默认行为,实现成本不低。更简单的做法是前端统一封装请求库,拦截401后暂停其他请求,等刷新完成再统一重放。这个方案我在多个项目里用过,稳定可靠。
7. 我在实际项目中的几点体会
整套机制搭下来,代码量其实不大,simplejwt把大部分脏活累活都干了。真正花时间的,是那些边界情况的处理——令牌过期后的用户体验、并发刷新的竞态、多环境密钥管理、黑名单表的清理。这些细节官方文档不会重点讲,但恰恰是上线后最容易出问题的地方。
我个人最看重的一条经验是:认证机制的安全性,取决于最薄弱的那个环节。你把JWT配得再严谨,如果登录接口不限流、密钥硬编码在代码里、令牌存在localStorage还挡不住XSS,那整体安全性还是上不去。所以别只盯着JWT本身,把登录、存储、传输、失效这几个环节都过一遍,才算真正做扎实。
最后分享一个实用小技巧。调试阶段,你可以用simplejwt的TokenVerifyView快速验证一个令牌是否有效,不用每次都发业务请求。把令牌贴进去,返回200就是有效,返回401会告诉你具体原因。排查问题时能省不少事。另外,令牌的payload可以直接用Base64解码看内容,网上有很多在线工具,本地用Python一行代码也行:
import base64 payload = token.split('.')[1] # 补齐padding payload += '=' * (-len(payload) % 4) print(base64.urlsafe_b64decode(payload))调试时看看payload里到底有什么,比对着文档猜要快得多。这个习惯帮我定位过好几次"令牌里字段名写错"的低级问题。