Django鉴权方案深度解析:从Session到JWT与权限控制
接手一个新项目的时候只要涉及用户登录会议室里必定会吵起来用Session还是TokenJWT要不要上refresh token怎么续期权限要细到按钮还是只管到接口吵到最后往往就是拍脑袋定方案等真上线了才发现各种别扭。这篇文章我想把Python生态里Django框架的鉴权方案从头到尾捋一遍从Django自带的Session认证讲到DRF的Token认证再讲到目前最主流的JWT方案以及权限控制这块的落地写法把每个方案的核心逻辑和适用边界都交代清楚希望对正在选型和踩坑的人有点帮助。1. 先分清认证与授权Django鉴权体系里最容易被绕晕的概念很多人把鉴权当成一个东西但其实它拆开是两个完全不同的问题认证Authentication解决的是你是谁授权Authorization解决的是你能干什么。这两个概念不掰扯清楚后面看Django源码也好、配DRF权限也好都会一头雾水。1.1 认证过程的完整链路认证这一步Django默认走的是Session机制整个链路大致是用户在登录表单输入用户名密码前端提交到视图视图调用authenticate()做身份校验校验通过后调用login(request, user)框架把用户信息序列化之后存到Session记录里同时往浏览器种一个sessionid的Cookie。后续每次请求进来Django通过SessionMiddleware从Cookie里取出sessionid到Session表里查对应的数据再把user对象挂到request.user上。这套机制里有个关键点Session数据存在服务端客户端只存一个无法反推内容的ID。默认情况下Session数据存在数据库的django_session表里也可以换成缓存、文件、Cookie等后端。生产环境我建议用Redis做Session存储第一是查询速度快第二是天然支持过期时间第三是方便以后多台机器共享Session。1.2 授权过程的权限判断授权发生在认证之后核心就是问一个问题这个已经登录的用户能不能执行当前这个操作Django的授权体系围绕permission权限来展开一个权限通常对应一个应用.动作_模型名格式的字符串比如blog.add_article、blog.change_article。系统提供的默认权限有三种add、change、delete再加上一个viewDjango 2.1之后才有。判断权限的方式有几种在视图函数上用permission_required装饰器或在模板里用{% if perms.blog.add_article %}做按钮级控制或在类视图里重写has_permission()方法。到了DRF里权限判断的入口变成了permission_classes列表每个权限类实现has_permission(request, view)方法就行。1.3 为什么这个概念这么重要我见过不少项目开发到一半发现接口被不该访问的人访问了排查到最后往往是权限判断写错了位置。比如有人把权限校验写在dispatch()之前放行了一部分匿名请求有人把认证和授权混在一个装饰器里拿login_required当权限用结果只要是登录用户就能访问管理员接口。所以你在设计鉴权方案的时候第一件事就是把认证和授权拆成两条独立的链路来考虑谁负责校验身份谁负责校验权限各干各的互不干扰。2. Django原生Session认证默认方案能撑多久Django项目一创建settings.py里就已经自动配置好了django.contrib.auth和django.contrib.sessions这两个应用。这意味着你什么都不用做就拥有一套完整的用户登录、登出、修改密码能力。但能用和好用是两回事这套默认方案有自己的适用边界。2.1 默认认证后端的执行顺序Django认证的核心入口是authenticate()函数它会遍历AUTHENTICATION_BACKENDS配置里的所有认证后端默认只有一条django.contrib.auth.backends.ModelBackend。这个后端做的事很简单接收username和password去User表里查用户用check_password()校验密码哈希最后检查is_active字段。# settings.py AUTHENTICATION_BACKENDS [ django.contrib.auth.backends.ModelBackend, ]值得留意的是is_active的检查。如果用户被管理员禁用is_activeFalseModelBackend会直接返回None登录失败。这里有个隐蔽的坑ModelBackend也负责权限校验get_all_permissions()、has_perm()这些方法都是在它里面实现的你如果自定义了认证后端往往还要同步实现权限相关方法否则权限功能会失效。2.2 login()之后发生了什么login()函数在会话里写入用户信息时会调用request.session的API。具体流程是这样的login()拿到user对象后调用user.backend属性获取认证后端路径然后把用户的主键、后端路径等数据存到Session里同时调用rotate_session_key()换掉旧的Session ID这就是网上常说的登录后Session固定防护。from django.contrib.auth import authenticate, login def my_login_view(request): username request.POST[username] password request.POST[password] user authenticate(request, usernameusername, passwordpassword) if user is not None: login(request, user) return redirect(home) else: return render(request, login.html, {error: 用户名或密码错误})注意authenticate()如果返回None可能是密码错也可能是用户不存在也可能是被禁用了。为了安全起见你的登录视图最好不要把用户不存在和密码错误区分提示统一返回用户名或密码错误避免攻击者通过提示信息枚举有效用户名。2.3 Session认证的短板与适用场景Session认证最大的问题集中在三个地方。第一个是跨域困难浏览器的同源策略加Cookie的SameSite属性导致纯前端项目要对接Django后端时Cookie能不能带上都是个问题更别提跨域时还要处理CSRF Token。第二个是对移动端非常不友好App没有浏览器的Cookie管理机制你要自己维护Cookie的存储和发送体验很别扭。第三个是服务端需要存储Session用户量一大Session表或Redis的占用率蹭蹭往上涨别忘了清理过期Session。但如果你做的是企业内部管理系统、后台运营平台这类B端项目用户量小、访问集中、又是浏览器访问Session方案反而是最省事的因为有Django官方维护有完整的密码重置和权限管理功能还天然支持CSRF防护。我个人的建议是B端纯网页项目优先考虑Session不用为了赶时髦强行上JWT。3. DRF的Token认证移动端接入时的第一站如果项目要出移动端API大家想到的第一个方案就是Token认证。Django REST Framework自带的TokenAuthentication是最简单的一种Token实现它不需要你自己写JWT的签名验签逻辑数据表存什么、查询怎么做框架全给你包好了。3.1 DRF Token的存储机制DRF的Token认证基于rest_framework.authtoken应用这个应用里有两个核心模型Token和TokenProxy。Token模型只有三个字段key主键一个随机生成的40位字符串、user外键一对一关系、created创建时间。pip install djangorestframework# settings.py INSTALLED_APPS [ # ... rest_framework, rest_framework.authtoken, ] REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework.authentication.TokenAuthentication, ], }数据库迁移之后Token表就建好了。但表里是空的用户怎么拿到TokenDRF给了一个现成的视图obtain_auth_token。你只需要把URL注册进去用户拿用户名密码POST过来DRF校验通过后返回一个Token。# urls.py from rest_framework.authtoken.views import obtain_auth_token urlpatterns [ path(api-token-auth/, obtain_auth_token), ]请求方式curl -X POST http://127.0.0.1:8000/api-token-auth/ \ -H Content-Type: application/json \ -d {username: admin, password: password123}3.2 客户端如何携带Token拿到Token之后客户端后续请求只要在请求头里带上Authorization: Token key就行curl http://127.0.0.1:8000/api/posts/ \ -H Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4bDRF的TokenAuthentication类在处理请求时会先解析Authorization头提取出Token字符串然后查数据库确认Token是否存在存在就取出对应的user挂到request.user上。整个流程就是一次数据库查询相比JWT的本地验签确实慢一点但对于中小项目这个性能差异根本感知不到。3.3 真正要命的坑Token过期与多端登录DRF原生Token方案有两个坑是绕不开的。第一个是Token永不失效除非你手动删除Token记录否则Token是永久有效的。用户改了密码Token照样能用用户离职了Token还是能用。第二个是默认一对一关系每个用户只能有一个Token新Token生成会导致旧Token失效用户在一台手机登录另一台手机就被踢下线。我在实际项目里解决这两个问题的办法是自己写一个辅助函数监听password_changed信号用户改密码时自动删除旧的Token记录用rest_framework.authtoken.models.Token.objects.filter(useruser).delete()实现旧Token的清理。如果要做多端登录就得换成自定义的Token模型把user字段从OneToOneField改成ForeignKey。from django.contrib.auth.signals import user_logged_in, password_changed from django.dispatch import receiver from rest_framework.authtoken.models import Token receiver(password_changed) def revoke_token_on_password_change(sender, user, **kwargs): Token.objects.filter(useruser).delete()这么一通操作下来Token方案基本能对付常规的App后端需求。但如果你想彻底摆脱每个请求都要查一次数据库的负担那就得看下一章了。4. JWT方案从Header.Payload.Signature看无状态登录的取舍JWTJSON Web Token这几年几乎成了API鉴权的代名词它和Session认证最本质的区别在于服务端不再保存任何用户状态。用户登录成功后服务端签发一个自包含的Token之后每个请求带上这个Token服务端验签通过就认为请求合法。这个特性让它天然适合分布式部署也天然适合前后端分离架构。4.1 JWT的三段式结构一个JWT字符串是三个部分用点号拼接的Header.Payload.Signature。Header指定签名算法和Token类型比如{alg: HS256, typ: JWT}经过Base64Url编码生成第一段Payload是声明部分就是你想塞进去的用户信息比如{user_id: 123, username: admin, exp: 1700000000}经过Base64Url编码生成第二段第三段Signature则是对前两段加盐做哈希的结果。signature HMACSHA256( base64url_encode(header) . base64url_encode(payload), SECRET_KEY )一般来说有效信息应该放在payload里的某个自定义字段中。但千万别把密码这类敏感信息放进去因为Payload只是Base64Url编码不是加密任何人拿到Token都能解开看里面是什么。4.2 simplejwt库的接入过程Django生态里用JWT目前最主流的库是djangorestframework-simplejwt。接入步骤并不复杂pip install djangorestframework-simplejwt# settings.py from datetime import timedelta REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], } SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(minutes5), REFRESH_TOKEN_LIFETIME: timedelta(days7), AUTH_HEADER_TYPES: (Bearer,), AUTH_HEADER_NAME: HTTP_AUTHORIZATION, SIGNING_KEY: your-secret-key, }# urls.py from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns [ path(api/token/, TokenObtainPairView.as_view(), nametoken_obtain_pair), path(api/token/refresh/, TokenRefreshView.as_view(), nametoken_refresh), ]完成后端接口后客户端拿用户名密码去/api/token/换一对Token一个是access短时效一个是refresh长时效。接口请求时用Authorization: Bearer access_token。4.3 access和refresh双Token机制的设计意图第一次接触JWT的人往往会问为什么不直接发一个不过期的Token答案在于风险平衡。access Token有效期短假设设置成15分钟就算被截获攻击者的利用窗口也就15分钟有效期一到客户端用refresh Token去换一个新的access Token。refresh Token有效期可以长比如7天但要设置为无法直接访问业务接口只能用于换取access Token。这样拆分之后你在安全性和用户体验之间找到了一个折中短Token降低泄露风险长Token减少用户频繁登录的烦躁感。配合后端一个/logout/接口把refresh Token加入黑名单就能实现用户主动登出。# 自定义登出视图示例 from rest_framework.views import APIView from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken class LogoutView(APIView): def post(self, request): refresh_token request.data.get(refresh) if refresh_token: token RefreshToken(refresh_token) token.blacklist() return Response({detail: 已登出})使用simplejwt时要启用blacklist功能需要安装rest_framework_simplejwt.token_blacklist应用否则token.blacklist()会报错。4.4 JWT方案绕不开的几个隐患JWT最大的优势是无状态但它也有一个无状态带来的隐患服务端无法主动让一个Token失效。比如用户改了密码旧Token只要还没过期就依然有效用户被封禁已经签发的Token端点依然能访问。要解决这类问题通常的做法是引入Token版本号机制在User模型上维护一个版本号字段签发Token时把版本号写进Payload认证时对比Payload里的版本号和数据库里的版本号不一致就拒绝访问。这样改密码时版本号加1所有旧Token立刻失效。还有一个非常容易被忽略的点JWT依赖的SECRET_KEY一旦泄露等于整套系统的鉴权体系都失效了。我在代码审查里见过有人直接把SECRET_KEY硬编码放在settings.py里提交到Git仓库这种问题的严重性远大于业务代码里的普通Bug。生产环境务必使用环境变量或密钥管理服务注入SECRET_KEY并且定期轮换。关于过期策略我踩过一个坑一开始把ACCESS_TOKEN_LIFETIME设成1分钟想着绝对安全结果用户写一篇长文章的时间就频繁弹出登录过期提示体验很差。后来又改成60分钟又觉得不安全。最终在项目里采用的策略是access放15分钟refresh放7天用户每次操作发现access过期就静默用refresh刷新用户无感知。这个参数没有标准答案要根据业务域和数据敏感度来调但我觉得15分钟到30分钟是一个比较均衡的范围。5. 权限控制不只是is_authenticatedRBAC与自定义权限类怎么写鉴权方案里认证选JWT还是Session只是第一步更见功力的是权限控制。接口设计得再好权限边界画错了整个系统就是一个漏勺。5.1 Django模型级权限的注册与使用Django的权限框架默认是RBAC基于角色的访问控制核心概念是用户User、角色Role/Group、权限Permission。系统自动为每个模型生成add、change、delete、view四种权限也允许在模型的Meta类里自定义权限from django.db import models class Article(models.Model): title models.CharField(max_length200) status models.CharField(max_length20, defaultdraft) class Meta: permissions [ (publish_article, Can publish article), (review_article, Can review article), ]迁移之后Django会在auth_permission表里多出两条记录。你可以通过Group把这些权限分配给一个角色再把用户加入Group就完成了RBAC最简单的落地。5.2 业务代码里怎么判断权限在视图代码里最常规的权限检查就是request.user.has_perm(blog.publish_article)。不过要注意这个调用和user.is_authenticated是两回事has_perm()默认会返回False哪怕用户已经登录只要没有显式分配权限也进不了接口。这一点经常被人忽视很多人以为登录了就能访问所有接口完全是误区。DRF里权限判断的粒度可以做到很细。比如只允许作者本人编辑自己的文章就需要自己写一个权限类from rest_framework.permissions import BasePermission class IsAuthorOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): if request.method in (GET, HEAD, OPTIONS): return True return obj.author request.user然后在视图里通过permission_classes [IsAuthorOrReadOnly]挂上去。5.3 权限判断与缓存性能问题的实战处理权限控制要小心性能坑。Django有个默认行为每一次has_perm()调用如果没有缓存都会触发数据库查询。而在循环里做权限判断N个对象就可能产生N1个查询。我在一个后台列表页遇到过类似情况因为加了权限过滤一个简单列表查询被权限检查拖慢了四倍。解决办法是权限判断尽量批量处理。DRF里可以用get_queryset()统一过滤当前用户可见的数据前端做按钮显示控制时把当前用户的权限列表一次性取出前端拿到权限码后自己判断而不是每个按钮都发一个请求让后端验证权限。# 视图层面批量过滤 class ArticleListApiView(generics.ListAPIView): serializer_class ArticleSerializer def get_queryset(self): qs Article.objects.all() if not self.request.user.has_perm(blog.publish_article): qs qs.filter(statuspublished) return qs权限判断还有个容易忽略的点用户被禁用后已经获取的权限缓存不会自动失效。如果缓存里存了一组权限结果管理员立刻禁用了这个用户被禁用用户可能还能访问一段时间受保护接口。所以涉及权限的关键操作最好每次从数据库读取用户的is_active状态不要只依赖缓存。6. 真实项目中的方案选型与踩坑记录聊了这么多最后落到实战选型上。我把自己在几个项目里的选择和踩过的坑整理一下不是标准答案但能给你一个参考坐标系。6.1 不同业务场景的推荐组合表格能说明问题项目类型推荐方案理由企业内部后台纯网页Django原生Session认证开发量小、官方维护、天然CSRF防护前后端分离的Web应用DRF JWT跨域友好、无状态、适合多端复用API移动App后端DRF JWT或自定义TokenToken携带方便、Refresh机制提升体验开放平台API给第三方调用应用级Token 签名认证面向机器身份验证跟用户认证分离混合场景后台App共用一套API后台走SessionApp走JWT共存配置灵活但注意两套认证策略权限要一致需要说明的是Django工程的认证器是可以配置多个的Session和JWT可以同时启用。视图上根据业务需要可以同时接受两种认证方式。DRF的DEFAULT_AUTHENTICATION_CLASSES配置里把两种认证都写上就行框架会逐个尝试只要有一个通过就算认证成功。6.2 我实际踩过的坑跨域、过期与权限缓存跨域问题是最早遇到的。前后端分离之后后端接口和前端页面不在同一个域名下前端用axios发请求时Cookie默认不会带上即使带上SameSite属性设置不对还会被浏览器拦掉。后来直接把项目切到JWT方案跨域问题瞬间解决因为Token是放在请求头里的浏览器不会对他做同源限制。过期策略的坑在于最初我的权限测试环境把ACCESS_TOKEN_LIFETIME设置得太短结果测试同学跑自动化脚本时用例一旦执行超过15分钟就大量报401排查了很久才发现不是接口写错了而是Token过期了。后来把刷新机制做进了前端请求拦截器里响应码是401时先静默调refresh接口成功后再重发原请求整个体验就顺了。前端请求拦截器的逻辑可以做成每次请求前检查access是否过期过期就先刷新再发请求。权限缓存的问题前面提过。我曾在一个后台系统里遇到这种情况管理员把某个用户从运营组移出去结果这个用户因为权限缓存还能继续访问运营后台的操作接口持续到缓存过期。最后排查到原因是DRF的ModelPermission对象在进程内做了权限缓存用户权限变更后缓存没有感知到。解决办法有两个要么在权限变更的接口里显式调用user.refresh_from_db()和权限缓存清空逻辑要么降低权限缓存的时间。6.3 鉴权方案落地的核心心法做了这么多年项目我最大的体会就是不要为了技术而技术鉴权方案的选择永远要从业务场景出发。小团队做内部系统Session就是最好的方案不用羡慕别人用的JWT要给第三方做开放平台根据AppId和AppSecret发Token才是正确姿势JWT并不适合所有场景。安全是一个持续投入的事不是上线那天一次性配好就完事了。Secret的管理、Token的轮换策略、权限模型的迭代都需要长期维护。你可以在项目初期用一个相对简单的方案跑通业务随着用户量和安全要求的提升再逐步演进。最后再分享一个可以立刻用起来的小技巧把所有的认证和权限配置独立到一个配置文件或配置区块里不要散落在各个视图里。这样以后要换认证方案或者增加第三方登录方式时你不用满工程找代码改配置和认证类就够了。这也是我在多个项目重构中踩过坑之后才养成的习惯。