跳转至

第 14 章:权限与授权

我们当前的 Newspaper 网站存在几个问题。首先,我们希望报纸在财务上是可持续的。如果有更多时间,我们可以添加一个专门的支付应用来收取访问费用。但至少,我们希望添加关于权限和授权的规则,例如要求用户登录后才能查看文章。作为一个成熟的 Web 框架,Django 具有内置的授权功能,我们可以用它来限制对文章列表页面的访问,并添加额外的限制,使只有文章的作者才能编辑或删除它。

改进 CreateView

目前,新文章的作者可以设置为任何现有用户。相反,它应该自动设置为当前登录的用户。我们可以通过修改 Django 的 CreateView 来实现这一点,移除作者字段,改为通过 form_valid 方法自动设置。

# articles/views.py
class ArticleCreateView(CreateView):
    model = Article
    template_name = "article_new.html"
    fields = ("title", "body")

    # new
    def form_valid(self, form):  # new
        form.instance.author = self.request.user
        return super().form_valid(form)

我怎么知道可以这样更新 CreateView 呢?答案是我查看了源代码,并使用了 Classy Class-Based Views,这是一个非常棒的资源,它详细解析了 Django 中每个通用基于类的视图的工作原理。通用基于类的视图很好用,但当你想要自定义它们时,你必须卷起袖子,理解底层发生了什么。这就是基于类的视图相对于基于函数的视图的缺点:更多的东西被隐藏了,必须理解继承链。你使用并自定义内置视图越多,就越能熟练地进行这类自定义。通常,一个特定的方法,比如 form_valid,可以被重写以达到你期望的结果,而不必从头开始重写所有内容。

现在重新加载浏览器,尝试点击顶部导航栏中的"+ New"链接。它将重定向到更新后的创建页面,其中作者不再是表单字段。

新文章链接

如果你创建了一篇新文章并进入 admin 后台,你会看到它自动设置为当前登录的用户。

授权

当前项目缺乏授权机制,这带来了多个问题。我们希望将访问限制为仅限用户,以便有朝一日可以向读者收取访问报纸的费用。但

除此之外,任何知道正确 URL 的未登录用户都可以访问网站的任何部分。

考虑一下如果未登录用户尝试创建新文章会发生什么。要试一试,请点击导航栏右上角的用户名,然后从下拉选项中选择"Log Out"。"+ New"链接从导航栏中消失了,但如果你直接访问 http://127.0.0.1:8000/articles/new/ 会怎样呢?

页面仍然存在。

未登录状态下的创建页面

现在,尝试用标题和正文创建一篇新文章,然后点击"Save"按钮。

创建页面错误

出错了!这是因为我们的模型期望有一个与当前登录用户关联的作者字段。但由于我们没有登录,没有作者信息,所以提交失败了。该怎么办呢?

Mixin

我们希望设置一些授权规则,使只有登录用户才能访问特定的 URL。为此,我们可以使用 mixin,这是一种特殊的多重继承方式,Django 用它来避免重复代码,同时仍然允许自定义。例如,内置的通用 ListView 需要一种方式来返回模板。但 DetailView 和几乎所有其他视图也需要这样做。Django 没有在每个大型通用视图中重复相同的代码,而是将这个功能提取到一个称为 TemplateResponseMixin 的 mixin 中。ListViewDetailView 都使用这个 mixin 来渲染正确的模板。

如果你阅读 Django 的源代码(可在 Github 上免费获取),你会看到 mixin 无处不在。为了将视图访问限制为仅登录用户,Django 提供了一个 LoginRequiredMixin,我们可以使用它。它功能强大且极其简洁。

articles/views.py 文件中,导入 LoginRequiredMixin 并将其添加到 ArticleCreateView 中。确保 mixin 位于 CreateView 的左侧,这样它会被优先读取。我们希望 CreateView 知道我们打算限制访问。

就是这样!我们完成了。

# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin  # new
from django.views.generic import ListView, DetailView
...

class ArticleCreateView(LoginRequiredMixin, CreateView):  # new
    ...

返回首页 http://127.0.0.1:8000/ 以避免重新提交表单。再次导航到 http://127.0.0.1:8000/articles/new/ 来访问新文章的 URL 路由。

登录重定向页面

发生了什么?Django 自动将用户重定向到了登录页面!如果你仔细观察,URL 是 http://127.0.0.1:8000/accounts/login/?next=/articles/new/,这表明我们试图访问 articles/new/ 但被重定向到了登录页面。

限制视图访问

限制视图访问需要在所有现有视图的开头添加 LoginRequiredMixin。让我们更新其余的文章视图,因为我们不希望未登录的用户能够创建、阅读、更新或删除文章。

完整的 views.py 文件现在应该如下所示:

# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from .models import Article


class ArticleListView(LoginRequiredMixin, ListView):  # new
    model = Article
    template_name = "article_list.html"


class ArticleDetailView(LoginRequiredMixin, DetailView):  # new
    model = Article
    template_name = "article_detail.html"


class ArticleUpdateView(LoginRequiredMixin, UpdateView):  # new
    model = Article
    fields = (
        "title",
        "body",
    )
    template_name = "article_edit.html"


class ArticleDeleteView(LoginRequiredMixin, DeleteView):  # new
    model = Article
    template_name = "article_delete.html"
    success_url = reverse_lazy("article_list")


class ArticleCreateView(LoginRequiredMixin, CreateView):
    model = Article
    template_name = "article_new.html"
    fields = ("title", "body",)

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)

在网站上操作一番,确认重定向到登录页面的功能按预期工作。如果你需要回忆正确的 URL,请先登录,然后记下每个路由的 URL,用于创建、编辑、删除和列出所有文章。

UpdateView 和 DeleteView

我们在不断进步,但编辑和删除视图仍然存在问题。任何登录用户都可以更改任何文章,但我们希望限制访问权限,使只有文章的作者才拥有此权限。我们可以在每个视图中添加权限逻辑,但更优雅的解决方案是创建一个专用的 mixin——一个具有我们想要在 Django 代码中重用的特定功能的类。更好的是,Django 自带了一个内置的 mixin UserPassesTestMixin,正是用于此目的!

要使用 UserPassesTestMixin,首先在 articles/views.py 文件顶部导入它,然后将其添加到我们希望添加限制的更新和删除视图中。test_func 方法由 UserPassesTestMixin 用于我们的逻辑判断;我们需要重写它。在这种情况下,我们将变量 obj 设置为使用 get_object() 从视图返回的当前对象。然后我们判断:如果当前对象的作者与当前网页上的用户(即登录并尝试进行更改的人)匹配,则允许操作。如果为 false,将自动抛出错误。

代码如下所示:

# articles/views.py
from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    UserPassesTestMixin  # new
)
from django.views.generic import ListView, DetailView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from django.urls import reverse_lazy
from .models import Article

...


class ArticleUpdateView(
    LoginRequiredMixin, UserPassesTestMixin, UpdateView):  # new
    model = Article
    fields = (
        "title",
        "body",
    )
    template_name = "article_edit.html"

    def test_func(self):  # new
        obj = self.get_object()
        return obj.author == self.request.user


class ArticleDeleteView(
    LoginRequiredMixin, UserPassesTestMixin, DeleteView):  # new
    model = Article
    template_name = "article_delete.html"
    success_url = reverse_lazy("article_list")

    def test_func(self):  # new
        obj = self.get_object()
        return obj.author == self.request.user

在使用 mixin 和基于类的视图时,顺序至关重要。LoginRequiredMixin 放在首位以强制登录,然后添加 UserPassesTestMixin 作为额外的功能层,最后是 UpdateViewDeleteView。只有按照这个顺序,代码才能正常工作。

使用你的 testuser 账户登录,然后进入文章列表页面。如果代码正常工作,你应该无法编辑或删除由超级用户账户撰写的任何帖子;相反,你将看到一个 Permission Denied 403 错误页面。

403 错误页面

但是,如果你用 testuser 创建一篇新文章,你将能够编辑和删除它。如果你改用超级用户账户登录,你可以编辑和删除由该作者撰写的帖子。

模板逻辑

虽然我们已成功限制了对每篇文章"编辑"和"删除"页面的访问,但这些链接仍然显示在文章列表页面和单个文章页面上。最好不要向无法访问它们的用户显示这些链接。换句话说,我们希望只对文章的作者显示这些链接。

我们可以在 article_listarticle_detail 模板文件中添加简单的逻辑,使用内置的 if 过滤器,使只有文章作者才能看到编辑和删除链接。

<!-- templates/article_list.html -->
...
<div class="card-footer text-center text-muted">
  <!-- new code here -->
  {% if article.author.pk == request.user.pk %}
    <a href="{% url 'article_edit' article.pk %}">Edit</a>
    <a href="{% url 'article_delete' article.pk %}">Delete</a>
  {% endif %} <!-- new code here -->
</div>
...
<!-- templates/article_detail.html -->
{% extends "base.html" %}
{% block content %}
<div class="article-entry">
  <h2>{{ object.title }}</h2>
  <p>by {{ object.author }} | {{ object.date }}</p>
  <p>{{ object.body }}</p>
</div>
<div>
  <!-- new code here -->
  {% if article.author.pk == request.user.pk %}
    <p><a href="{% url 'article_edit' article.pk %}">Edit</a>
      <a href="{% url 'article_delete' article.pk %}">Delete</a>
    </p>
  {% endif %} <!-- new code here -->
  <p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
</div>
{% endblock content %}

为了确保我们新的编辑/删除逻辑按预期工作,请确保你以 testuser 身份登录,并使用顶部导航栏中的"+ New"按钮创建一篇新文章。如果你刷新所有文章网页,只有由 testuser 撰写的文章才应该显示编辑和删除链接。

编辑/删除链接未显示

点击文章名称导航到其详情页面。编辑和删除链接可见。

testuser 的编辑/删除链接显示

但是,如果你导航到由 superuser 创建的文章详情页面,链接就消失了。

编辑/删除链接未显示

Git

在本章结束时,使用 Git 快速保存一下。

(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "permissions and authorizations"
(.venv) $ git push origin main

结论

我们的 Newspaper 应用差不多完成了。在此阶段我们可以采取进一步的措施,例如只对相应用户显示编辑和删除链接,这将涉及自定义模板标签,但总体而言,应用已经成型。我们的文章配置正确,设置了权限和授权,并拥有可用的用户认证流程。最后一个需要的功能是让已登录的用户可以留下评论,我们将在下一章中介绍。