这几年做 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_length20, blankTrue) avatar models.URLField(blankTrue) 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, usernameusername, passwordpassword) 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 flogin_lock_{username} if cache.get(lock_key): # 提示账号暂时锁定 pass user authenticate(request, usernameusername, passwordpassword) 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_length100) author models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE) class Meta: permissions [ (publish_article, 可以发布文章), (archive_article, 可以归档文章), ]然后makemigrations会生成一条CreateModel或AlterModelOptions操作把这些权限注册到数据库。实际开发中忘了makemigrations就去调has_perm得到的永远是False排查半天才发现权限表里根本没这条记录这种教训我至少见过三次。3.2 has_perm 内部到底查了哪些表用户权限和组权限是两套多对多关系User.user_permissions直接绑定权限User.groups先关联到GroupGroup.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未登录用户会重定向到登录页。但如果你在写基于类的视图就得用LoginRequiredMixinfrom django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.views.generic import ListView class ArticleListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): model Article permission_required blog.view_articlepermission_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(authorself.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, pkpk, authorrequest.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_deletemodels.CASCADE, related_nameobject_permissions, ) content_type models.ForeignKey(ContentType, on_deletemodels.CASCADE) object_id models.PositiveIntegerField() can_view models.BooleanField(defaultFalse) can_edit models.BooleanField(defaultFalse) 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无模型级增删改查权限Permissionhas_perm权限在 UI 上的可见性控制角色分组Group绑定权限角色与业务流程的映射只能操作自己的数据无查询集过滤 get_object_or_404对象级共享权限无自定义 ObjectPermission 模型API 登录凭证DRFTokenAuthenticationToken 管理页面或过期策略API 带过期时间的凭证simplejwt刷新逻辑与前端配合多端登录互踢无session_version字段方案这张表基本概括了我在 Django 项目里处理认证权限的全部套路。做权限设计时先看内置方案能满足多少再看剩下的缺口有多大不要一上来就造轮子——但也不要天真地以为内置方案能包打天下。最理想的状态是把内置机制用透把缺口用清晰的业务逻辑补上并且从一开始就做好分层——认证管身份权限管能力数据过滤管数据边界。三层各司其职后面加功能、加接口的时候才不会被绕晕。