这几年做 Django 项目,总能看到新人对着auth应用一头雾水:登录注册不是现成的吗?权限不也内置了吗?可真到了自己动手写一个带角色、带数据隔离的业务系统,才发现内置那套东西只是地基,上面该盖什么楼、怎么盖,全得自己琢磨。这篇就顺着认证和权限这条线,把 Django 里从用户模型到会话机制、从模型级权限到行级数据隔离、从后台视图到 API 授权的内容完整串一遍,顺便把我实际踩过的坑和最后的落地方案一并说清楚。适合刚把 Django 官方教程刷完、正准备做第一个正经项目的读者,也适合已经在项目里用了auth但总觉得哪里别扭、想回头把底层逻辑捋清楚的人。
1. 认证系统的地基:User 模型与 auth 应用
1.1 auth 应用到底内置了哪些东西
Django 自带的django.contrib.auth不是单一模块,它是一整套包含模型、中间件、视图、表单、装饰器的组合包。我第一次系统翻它的源码时挺惊讶的,因为它远比想象中完整:User、Group、Permission三个核心模型,配合AuthenticationMiddleware让每个request自动带上request.user,再加上login()、logout()、authenticate()这几个工具函数,以及login_required装饰器,基本把 Web 应用最常见的认证场景都覆盖了。
但这套东西的边界也很明显。它能帮你解决"你是谁"的问题,也能帮你在模型层面判断"你能不能对这个类型的对象做某事",可一旦需求变成"你只能编辑自己创建的那篇文章",内置机制就无能为力了。所以先把它吃透,再谈扩展。
1.2 AUTH_USER_MODEL 必须一开始就定好
这是我最想强调的一点。settings.py里的AUTH_USER_MODEL是一个一旦项目跑起来就几乎不能改的配置,因为数据库里所有指向用户的外键、多对多关系都依赖它。我在一个项目里就吃过亏:一开始图省事用了默认User,后来业务需要给用户加手机号、头像、积分字段,只能新建UserProfile用OneToOne外键去补,结果每次取用户资料都要多一次查询,代码里到处是user.profile.avatar,看着就别扭。
正确做法是项目创建之初就写:
from django.contrib.auth.models import AbstractUser class User(AbstractUser): phone = models.CharField(max_length=20, blank=True) avatar = models.URLField(blank=True) class Meta: db_table = "auth_user"然后在settings.py里指定AUTH_USER_MODEL = "yourapp.User"。AbstractUser已经包含默认User的全套字段——username、password、email、first_name、last_name、is_active、is_staff、is_superuser——直接继承再追加字段,是最省事也最稳妥的路子。
有个细节容易忽略:如果项目还没开始migrate,随时可以改AUTH_USER_MODEL;但一旦任何表已经建好,再改就要面临数据库重构,非常痛苦。所以这个决策必须在写第一个模型之前做完。另外,is_staff和is_superuser是两个不同的概念:is_staff控制能否登录 Django 自带后台,is_superuser则是拥有所有权限的超级用户,两者不要混用。
1.3 request.user 的匿名状态是个精妙设计
AuthenticationMiddleware做的核心事情是:从 session 里取出用户 id,查库,把用户对象挂到request.user上。如果没登录,它不会让request.user为None,而是挂上一个AnonymousUser的实例。这个设计让模板和视图代码不用反复判空:
{% if user.is_authenticated %} 欢迎回来,{{ user.username }} {% else %} 请先登录 {% endif %}AnonymousUser没有username属性,但一定有is_authenticated这个属性,而且永远是False。记住is_authenticated是属性不是方法,我见过不少人写user.is_authenticated()然后报TypeError,这属于 Django 升级改版后最经典的语法习惯坑。
2. 登录背后发生了什么:authenticate 与 session 的配合
2.1 登录三步走的完整链路
很多新手以为login()就是把用户名密码塞进去然后登录,其实标准流程是三步:表单校验、authenticate()验证、login()写会话。
from django.contrib.auth import authenticate, login def login_view(request): if request.method == "POST": username = request.POST.get("username") password = request.POST.get("password") user = authenticate(request, username=username, password=password) if user is not None: login(request, user) return redirect("dashboard") # 认证失败,回到登录页并提示错误关键在于authenticate()不只查一次密码,它会遍历settings.AUTHENTICATION_BACKENDS里注册的所有认证后端,默认的ModelBackend只是其中一种。这意味着你可以写自己的后端,比如支持邮箱登录、支持 LDAP、甚至支持对接第三方企业认证系统。所以如果你需要"用户名或邮箱都能登录"的需求,正确方向不是重写login视图,而是写一个自定义AuthenticationBackend,在authenticate()方法里先按邮箱查一次、再按用户名查一次。
2.2 session 里到底存了什么
用默认的数据库 session 时,用户登录成功后,django_session表里会新增一行记录。session_data字段存的是经过 Base64 编码和加密的字典,里面至少包含_auth_user_id、_auth_user_backend、_auth_user_hash三项。
_auth_user_hash这个字段很有意思。它是用户密码哈希再经过一层摘要计算得到的值,Django 用它来判断"这个会话是否仍然有效"。用户改了密码后,哈希值变化,旧会话自动失效。这带来一个常见现象:用户忘记密码重置后,所有设备上的登录态都会退出。对大多数系统这是安全特性,但如果你在做一个"允许用户重置密码但不想把用户踢下线"的场景,就需要自己额外维护一个"会话版本号"之类的机制,默认方案做不到。
login()内部还会拿到user.backend属性——这是authenticate()返回用户时顺手绑定上去的——把它存进 session。所以不要试图手动把用户塞进 session,请始终走authenticate()加login()的标准流程,否则可能踩到"登录后logout()失效"或者"下次请求拿不到用户"的诡异问题。
2.3 logout 和会话清理的细节
logout(request)内部会调用request.session.flush(),把整个会话清空,再删除sessionid的 cookie。所以别想着"登出只移除用户字段保留购物车数据",默认行为是全清。如果你确实需要保留部分数据,要在调用logout()之前先把数据存到别的地方,登出后再写回新 session。
另一个容易被忽略的点是"登录后生成新 session id"的安全习惯。Django 默认在login()时通过cycle_key()轮换 session key,防止 session fixation 攻击。这条安全策略是内置的,没必要自己去改。
2.4 一个实际的自定义登录逻辑样例
假设系统要求:连续输错 5 次密码,锁定账号 10 分钟;登录成功后如果发现是首次登录,强制跳转到修改密码页。这些逻辑放在authenticate()后端里不合适,应该放在视图层。
from django.core.cache import cache def login_view(request): if request.method == "POST": username = request.POST.get("username") password = request.POST.get("password") lock_key = f"login_lock_{username}" if cache.get(lock_key): # 提示账号暂时锁定 pass user = authenticate(request, username=username, password=password) if user is not None: login(request, user) cache.delete(lock_key) if user.last_login is None: return redirect("force_password_change") return redirect("dashboard") else: failed = cache.get(lock_key, 0) + 1 cache.set(lock_key, failed, 600)用cache而不是数据库字段,是因为这类临时状态没必要落库,cache本身自带过期时间,代码也简洁。这里的核心思路是:认证机制负责"凭证对不对",业务规则负责"能不能进、进来之后去哪"。
3. 模型级权限的真相:Permission 与 Group 的配合机制
3.1 权限数据是怎么来的
Django 的权限系统依赖django.contrib.contenttypes框架。每当你migrate一个包含模型的 app 时,contenttypes会为每个模型注册一条ContentType记录,同时自动创建add、change、delete、view四个默认权限,写进auth_permission表。
Permission表的核心字段是codename和content_type。一个权限的完整标识是应用名.codename,比如blog.delete_article、blog.view_article。判断用户是否有权限的 API 是:
user.has_perm("blog.delete_article")如果你需要在默认的四权限之外增加自定义权限,在模型的Meta里声明:
class Article(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) class Meta: permissions = [ ("publish_article", "可以发布文章"), ("archive_article", "可以归档文章"), ]然后makemigrations会生成一条CreateModel或AlterModelOptions操作,把这些权限注册到数据库。实际开发中忘了makemigrations就去调has_perm,得到的永远是False,排查半天才发现权限表里根本没这条记录,这种教训我至少见过三次。
3.2 has_perm 内部到底查了哪些表
用户权限和组权限是两套多对多关系:User.user_permissions直接绑定权限,User.groups先关联到Group,Group.permissions再关联权限。has_perm的检查顺序大致是:
is_active为假,直接返回Falseis_superuser为真,直接返回True- 检查
user_permissions里有没有这个codename - 检查该用户所有
Group的permissions里有没有这个codename
这里有个容易踩的坑:has_perm是从request.user上调用没错,但它是逐个遍历后端来判定的。默认ModelBackend的逻辑如上,但如果你注册了自定义后端,has_perm的结果就取决于所有后端的综合返回值了。另外,has_perm接收的字符串如果格式不合法,比如漏写了应用前缀,会直接返回False而不是报错,调试时要先确认字符串拼写、数据库权限记录是否存在这两件事。
3.3 为什么说默认权限是"粗颗粒"
默认权限绑定的是"某个模型"而不是"某一行数据",所以它回答不了"这篇草稿只有作者本人能编辑"这类问题。原因是has_perm只接收app_label.codename,根本没有对象维度。这就是社区里常说的"粗颗粒权限"。
那该怎么办?两条路:一是把行级归属逻辑放在视图层,查数据库时就只取当前用户的数据;二是自己建一张对象权限表,存"用户/组 + 内容类型 + 对象 id + 权限类型",相当于把权限精确到行。方案二在管理后台、审批流这类场景里很有用,但复杂度会明显上升,大多数普通业务系统用方案一就够了。
3.4 Group 是角色系统的低成本实现
Group本质上就是一组权限的集合,完全可以当"角色"来用。项目里最常见的做法是维护"管理员、编辑、作者"三个组,把权限批量挂到组上,再把用户拉进组。这样比给每个用户单独配user_permissions高效得多,而且业务语义也更清晰。
需要留意的是,组权限和直接权限是并集关系,不是覆盖关系。一个用户即使属于"作者"组,如果又被单独授予了"删除所有文章"权限,那他两个都有。所以做权限设计时建议定一条铁律:同一个人同时拥有组权限和直接权限的情况,要写清楚是哪一方临时授权的,否则排查问题时会非常混乱。
4. 视图层的权限控制与对象级数据隔离
4.1 装饰器和 Mixin 的正确用法
最简单的登录校验是login_required,未登录用户会重定向到登录页。但如果你在写基于类的视图,就得用LoginRequiredMixin:
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.views.generic import ListView class ArticleListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): model = Article permission_required = "blog.view_article"permission_required可以是一个权限字符串,也可以是一个列表。配合raise_exception属性可以控制无权限时的表现:设为True会直接抛 403,不设则重定向到登录页。做后台管理类页面建议设成True,因为用户既然已经登录了,说明他想操作但没有足够权限,这种情况下返回 403 比把用户绕回登录页更合理。
还有UserPassesTestMixin,它比PermissionRequiredMixin更灵活,适用于无法用简单权限字符串表达的逻辑,比如"必须是作者本人或管理员"。重写test_func方法即可:
from django.contrib.auth.mixins import UserPassesTestMixin class ArticleEditView(UserPassesTestMixin, UpdateView): model = Article fields = ["title", "content"] def test_func(self): article = self.get_object() return self.request.user == article.author or self.request.user.is_superuser4.2 行级隔离的核心:查询集过滤
系统里如果有多个用户各自管理自己的文章,最危险的做法是在模板里隐藏别人的编辑按钮,因为 URL 是可以被手工猜出来的。正确思路是:列表页只查当前用户的数据,详情页和编辑页要在查询阶段就排除越权对象。
class MyArticleListView(LoginRequiredMixin, ListView): model = Article def get_queryset(self): return Article.objects.filter(author=self.request.user)详情页也一样,不要直接用Article.objects.get(pk=...),要基于过滤后的查询集取对象:
from django.shortcuts import get_object_or_404 def article_detail(request, pk): article = get_object_or_404(Article, pk=pk, author=request.user) ...这样即使别人猜到了 URL,查询结果为空,统一返回 404,不会暴露"这篇文章确实存在但你没有权限看"的信息。这个细节在安全审计里经常被提到:返回 404 而不是 "403 你不许看",能减少信息泄露面。
4.3 模板层做权限可见性控制
数据安全靠查询过滤,界面的友好性靠模板权限。Django 在模板上下文里注入了perms变量,可以这样写:
{% if perms.blog.change_article %} <a href="{% url 'article_edit' article.pk %}">编辑</a> {% endif %} {% if article.author == user %} <a href="{% url 'article_edit' article.pk %}">编辑自己的文章</a> {% endif %}这两者往往需要配合使用。界面上的可见性控制能减少用户尝试无效操作的次数,但真正的安全边界永远在后端视图层。我见过"前端隐藏了删除按钮,以为这样就删不了了"的团队,后来被同行用curl直接调接口删了数据,教训很深刻。
4.4 更复杂的对象级权限:一个可落地的扩展思路
如果你的系统确实需要"共享文章给某些用户"这种精细权限,可以自己建一张对象权限表,核心模型大概是:
class ObjectPermission(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="object_permissions", ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() can_view = models.BooleanField(default=False) can_edit = models.BooleanField(default=False) class Meta: unique_together = ("user", "content_type", "object_id")查询时,先用ContentType和对象 id 查出所有有权用户,再配合查询集过滤。这种实现简单直接,适合内部管理系统。如果要做类似企业网盘那种复杂授权树,建议直接引入第三方库或考虑现成的授权方案,不要自己硬磕。需要注意,content_type加object_id其实是一个 GenericForeignKey 的雏形,用它做多模型共享权限会很方便,但查询性能需要额外关注。
5. API 场景下的认证授权:Token、JWT 与自定义权限类
5.1 DRF 的认证与权限是两套独立机制
用 Django REST Framework 写接口时,很多新手把认证和权限混为一谈。其实它们是两个独立环节:认证解决"你是谁",权限解决"你能做什么"。DRF 的请求处理顺序是先走认证类得到request.user,再走权限类判断是否放行。
默认的认证类是SessionAuthentication加BasicAuthentication,前台浏览器项目用起来顺手,但给 App 或第三方系统用就不合适。生产环境最常用的组合是TokenAuthentication或 JWT。这两者的区别在于:DRF 自带的Token是存在数据库里的一把"永久钥匙",用户拿到手就可以一直用,过期只能手动删掉重建,无法自然失效;而 JWT 自带过期时间,无状态、不用查库,更适合分布式和纯 API 场景。
5.2 权限类的组合逻辑
DRF 内置了几种权限类,务必搞清楚它们的关系:
AllowAny:不校验任何权限,适合注册、登录接口。IsAuthenticated:只要求登录。IsAdminUser:要求is_staff为真。DjangoModelPermissions:走 Django 的模型级权限。DjangoObjectPermissions:配合对象级权限后端使用,这个基本用不上。
自己写权限类也简单,核心是实现has_permission或has_object_permission。一个常见的需求是"作者可以编辑,其他人只能读":
from rest_framework.permissions import BasePermission, SAFE_METHODS class IsOwnerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True return obj.author == request.user再配合视图对详情接口指定此权限类即可。要注意has_object_permission只在获取到对象之后才调用,列表接口的隔离还是要靠重写get_queryset来完成。换句话说,DRF 里做行级数据隔离的正确姿势是:读取列表时按用户过滤查询集,操作单个对象时用对象权限类做兜底。
5.3 生产环境更推荐的方案:simplejwt
如果接口要长期给多个端使用,我的建议是直接用 JWT。官方推荐的三方库是djangorestframework-simplejwt,安装后配置REST_FRAMEWORK的默认认证类即可。
REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), }常用的设置项包括ACCESS_TOKEN_LIFETIME(访问令牌有效期,建议设短,比如 30 分钟)、REFRESH_TOKEN_LIFETIME(刷新令牌有效期,可以设几天甚至更长)。前端拿到 access token 和 refresh token 后,access token 到期就拿着 refresh token 去换新的。这个模式的思路和 Java 里常见的"签名认证 + token 续期"是相通的——不管技术栈怎么变,认证系统本质都是"确认身份,签发凭证,后续请求凭凭证访问,凭证到期再换新凭证"。给不同技术背景的同事讲权限设计时,我用这个类比,基本一次就能说通。
5.4 API 权限设计里最容易忽略的一个点
DEFAULT_PERMISSION_CLASSES默认是AllowAny,意味着如果视图不写permission_classes,接口是公开的。新项目建议从一开始就设成IsAuthenticated,再对个别接口用AllowAny放开。这样原则上是"默认关闭、按需开放",而不是"默认全裸、事后补洞",能少踩很多安全漏洞。
6. 真实项目落地时绕不开的坑与心得
6.1 换 User 模型的代价有多大
有朋友中途想把默认User换成自定义用户模型,结果发现所有ForeignKey都指向了旧的auth_user表,迁移复杂到基本只能重建数据库。所以我的建议非常明确:所有新项目,第一件事就是自定义AbstractUser子类,哪怕现在不需要额外字段。这个决定几乎零成本,却能给未来留足余地。
6.2 session 模式下的多端登录与踢人下线
用 session 做认证时,"一个账号只允许一台设备登录"这种需求不是默认提供的,得自己想办法。一个可行思路是给用户模型加一个session_version字段,登录时递增,同时把当前版本号写进 session;在每个请求里比较 session 里的版本号和数据库里的版本号,不一致就强制登出。这是业务层方案,虽然简单但很有效。
6.3 权限测试的覆盖面
权限相关的测试,业界通行做法是覆盖四类身份:匿名用户、普通登录用户、处于某个组里的用户、超级管理员。测试用例至少包含:未登录访问受保护页面重定向到登录页;普通用户访问自己资源成功、访问别人资源 404;组用户能访问组权限允许的页面;超管可以访问一切。这四类用例写完,权限相关回归基本就稳了。
6.4 后台管理里的权限配置误区
很多人用 Django 自带 admin 时,以为勾选了"查看"权限就能看列表,其实 admin 后台对模型列表页的控制比普通视图更严格。get_queryset在 admin 里默认不按用户过滤,超管和普通is_staff用户看到的是一样数据范围,要限制数据范围得重写Admin.get_queryset。这个门槛不低,所以如果项目后台对数据范围有严格隔离要求,我更推荐自己写管理视图而非硬套 Django admin。说到底,admin 是用来做运营后台的,不是用来做复杂权限系统的。
7. 整理一份顺手的权限清单
最后给一张我每次搭新项目都参考的对照表,把需求、默认方案和需要手写的部分列清楚:
| 需求 | Django 内置方案 | 需要自己动手的部分 |
|---|---|---|
| 用户名密码登录 | authenticate()+login() | 登录表单、登录页模板 |
| 邮箱登录 | 无 | 自定义 AuthenticationBackend |
| 判断用户是否登录 | request.user.is_authenticated | 无 |
| 模型级增删改查权限 | Permission+has_perm | 权限在 UI 上的可见性控制 |
| 角色分组 | Group绑定权限 | 角色与业务流程的映射 |
| 只能操作自己的数据 | 无 | 查询集过滤 +get_object_or_404 |
| 对象级共享权限 | 无 | 自定义 ObjectPermission 模型 |
| API 登录凭证 | DRFTokenAuthentication | Token 管理页面或过期策略 |
| API 带过期时间的凭证 | simplejwt | 刷新逻辑与前端配合 |
| 多端登录互踢 | 无 | session_version字段方案 |
这张表基本概括了我在 Django 项目里处理认证权限的全部套路。做权限设计时,先看内置方案能满足多少,再看剩下的缺口有多大,不要一上来就造轮子——但也不要天真地以为内置方案能包打天下。最理想的状态是:把内置机制用透,把缺口用清晰的业务逻辑补上,并且从一开始就做好分层——认证管身份,权限管能力,数据过滤管数据边界。三层各司其职,后面加功能、加接口的时候才不会被绕晕。