Djnago-解耦教程-二-

119 阅读8分钟

Djnago 解耦教程(二)

原文:Decoupled Django

协议:CC BY-NC-SA 4.0

七、API 安全性和部署

本章涵盖:

  • Django 硬化

  • REST API 强化

  • 部署到生产

在前一章中,我们用 Django REST 框架和 Vue.js 组装了一个伪解耦的 Django 项目。

现在是时候探索这种设置的安全含义了,这与运行 monolith 没有什么不同,但是由于 REST API 的存在,确实需要一些额外的步骤。在关注安全性之后,在本章的第二部分,我们将介绍使用 Gunicorn 和 NGINX 部署到生产环境中。

Note

在本章的第一部分,我们假设您在回购根decoupled-dj中,Python 虚拟环境是活动的,并且DJANGO_SETTINGS_MODULE被配置为decoupled_dj.settings.development

Django 硬化

Django 是最安全的 web 框架之一。

然而,很容易让事情溜走,尤其是当我们急于看到我们的项目在生产中启动和运行的时候。在向世界公开我们的网站或 API 之前,我们需要注意一些额外的细节以避免意外。重要的是要记住,本章提供的建议远非详尽无遗。安全性是一个巨大的话题,由于地区法规或政府要求,每个项目和每个团队在安全性方面可能都有不同的需求。

Django 生产设置

在第五章的“分割设置文件”一节中,我们配置了 Django 项目,为每个环境使用不同的设置。

到目前为止,我们有以下设置:

  • decoupled_dj/settings/base.py

  • decoupled_dj/settings/development.py

为了准备项目的生产,我们在decoupled_dj/settings/production.py中创建另一个设置文件,它将保存所有与生产相关的设置。这个文件里应该放些什么?Django 最重要的生产环境包括:

  • SECURE_SSL_REDIRECT:确保每个通过 HTTP 的请求都被重定向到 HTTPS

  • 驱动 Django 将服务的主机名

  • Django 将在这里寻找静态文件

除了这些设置之外,还有一些与 DRF 相关的配置,我们将在下一节中讨论。我们还会在第十章中介绍更多与认证相关的设置。首先,创建decoupled_dj/settings/production.py并如清单 7-1 所示进行配置。

from .base import *  # noqa

SECURE_SSL_REDIRECT = True
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
STATIC_ROOT = env("STATIC_ROOT")

Listing 7-1decoupled_dj/settings/production.py – The First Settings for Production

这些设置将根据环境从.env文件中读取。在开发中,我们有清单 7-2 中所示的设置。

DEBUG=yes
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=/static/

Listing 7-2The Development .env File

Note

如果我们传递的是yes而不是一个布尔值,那么DEBUG在这里是如何工作的?转换由django-environ为我们处理。

在生产中,我们需要根据我们在decoupled_dj/settings/production.py中描述的需求来调整这个文件。这意味着我们必须部署清单 7-3 中所示的.env文件。

ALLOWED_HOSTS=decoupled-django.com,static.decoupled-django.com
DEBUG=no
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=https://static.decoupled-django.com
STATIC_ROOT=static/

Listing 7-3decoupled_dj/settings/.env.production.example - The Production .env File

Note

这里显示的数据库设置假设我们使用 Postgres 作为项目的数据库。要使用 SQLite,请将数据库配置更改为DATABASE_URL=sqlite:/decoupleddjango.sqlite3

生产中最重要的是禁用DEBUG以避免错误泄漏。在前面的文件中,请注意静态相关设置与开发略有不同:

  • STATIC_URL现在被配置为从static.decoupled-django.com子域读取静态资产

  • 生产中的STATIC_ROOT将从static文件夹中读取文件

有了这个用于生产的基本配置,我们可以进一步加强我们的 Django 项目,使用身份验证。

Django 中的身份验证和 Cookies

在前一章中,我们配置了一个 Vue.js 单页应用,从 Django 视图提供服务。让我们回顾一下billing/views.py中的代码,清单 7-4 中总结了这些代码。

from django.views.generic import TemplateView

class Index(TemplateView):
   template_name = "billing/index.html"

Listing 7-4billing/views.py - A TemplateView Serves the Vue.js SPA

在本地,我们可以在运行 Django 开发服务器后在http://127.0.0.1:8000/billing/访问视图,这很好。然而,一旦项目上线,没有什么可以阻止匿名用户自由地访问视图和发出未经验证的请求。为了强化我们的项目,我们可以首先使用基于类的视图的LoginRequiredMixin来要求对视图进行认证。打开billing/views.py并改变视图,如清单 7-5 所示。

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView

class Index(LoginRequiredMixin, TemplateView):
   template_name = "billing/index.html"

Listing 7-5billing/views.py - Adding Authentication to the Billing View

从现在开始,任何想要访问该视图的用户都必须进行身份验证。对于现阶段的我们来说,使用以下命令在开发中创建一个超级用户就足够了:

python manage.py createsuperuser

完成后,我们可以通过管理视图进行身份验证,然后访问 http://127.0.0.1:8000/billing/ 来创建新发票。但是一旦我们填写表单并点击 Create Invoice,Django 就会返回一个错误。在浏览器控制台的 Network 选项卡中,在尝试提交表单后,我们应该在服务器的响应中看到以下错误:

"CSRF Failed: CSRF token missing or incorrect."

Django 可以抵御 CSRF 攻击,如果没有有效的 CSRF 令牌,它不会让我们提交 AJAX 请求。在传统的 Django 表单中,这个令牌通常作为一个模板标签包含在内,并由浏览器作为 cookie 发送到后端。但是,当前端完全由 JavaScript 构建时,必须从 cookie 存储中检索 CSRF 令牌,并作为报头与请求一起发送。为了在我们的 Vue.js 应用中解决这个问题,我们可以使用vue-cookies,这是一个用于处理 cookies 的方便的库。在终端中,移动到名为billing/vue_spa的 Vue 项目文件夹并运行以下命令:

npm i vue-cookies

接下来,在billing/vue_spa/src/main.js中加载库,如清单 7-6 所示。

...
import VueCookies from "vue-cookies";

Vue.use(VueCookies);
...

Listing 7-6billing/vue_spa/src/main.js - Enabling Vue-Cookies

最后,在billing/vue_spa/src/components/InvoiceCreate.vue中,获取 cookie 并将其包含为一个头,如清单 7-7 所示。

...
     const csrfToken = this.$cookies.get("csrftoken");

     fetch("/billing/api/invoices/", {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
         "X-CSRFToken": csrfToken
       },
       body: JSON.stringify(data)
     })
       .then(response => {
         if (!response.ok) throw Error(response.statusText);
         return response.json();
       })
       .then(json => {
         console.log(json);
       })
       .catch(err => console.log(err));
...

Listing 7-7billing/vue_spa/src/components/InvoiceCreate.vue - Including the CSRF Token in the AJAX Request

为了进行测试,我们可以使用以下命令重新构建 Vue 应用:

npm run build -- --mode staging

在运行 Django 之后,在http://127.0.0.1:8000/billing/创建一个新的发票应该可以正常工作了。

Note

作为 Fetch 的一个流行替代,axios 可以帮助实现拦截器特性。对于每个请求,全局附加 cookies 或其他头是很方便的。

回到认证的前面。在这个阶段,我们在 Django 中启用了最简单的认证方法:基于会话的认证。这是 Django 中最传统和最健壮的认证机制之一。它依赖于保存在 Django 数据库中的会话。当用户使用凭证登录时,Django 在数据库中存储一个会话,并向用户的浏览器发回两个 cookie:csrftokensessionid。当用户向网站发出请求时,浏览器发回这些 cookiess,Django 根据数据库中存储的内容对这些 cookie 进行验证。由于如今 HTTPS 加密是网站的强制性要求,禁用通过普通 HTTP 传输csrftokensessionid是有意义的。为此,我们可以在decoupled_dj/settings/production.py中添加两个配置指令,如清单 7-8 所示。

...
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
...

Listing 7-8decoupled_dj/settings/production.py - Securing Authentication Cookies

通过将CSRF_COOKIE_SECURESESSION_COOKIE_SECURE设置为True,我们确保与会话认证相关的 cookies 仅通过 HTTPS 传输。

随机化管理 URL

内置的管理面板可能是 Django 最受欢迎的特性之一。然而,该面板的 URL(默认为admin/)在网站在线暴露时,可能会成为自动暴力攻击的目标。为了缓解这个问题,我们可以在 URL 中引入一点随机性,将它改为不容易猜到的内容。这个变更需要发生在项目根decoupled_dj/urls.py中,如清单 7-9 所示。

from django.urls import path, include
from django.contrib import admin
from django.conf import settings

urlpatterns = [
   path("billing/", include("billing.urls", namespace="billing")),
]

if settings.DEBUG:
   urlpatterns = [
       path("admin/", admin.site.urls),
   ] + urlpatterns

if not settings.DEBUG:
   urlpatterns = [
       path("77randomAdmin@33/", admin.site.urls),
   ] + urlpatterns

Listing 7-9decoupled_dj/urls.py - Hiding the Real Admin URL in Production

这段代码告诉 Django,当DEBUGFalse时,将管理 URL 从admin/更改为77randomAdmin@33/。通过这个小小的改变,我们给管理面板增加了更多的保护。现在让我们看看我们可以做些什么来提高 REST API 的安全性。

REST API 强化

什么比 REST API 更好?当然是安全的 REST API。

在接下来的几节中,我们将介绍一组提高 REST API 安全性的策略。为此,我们从 OWASP 基金会的 REST 安全备忘单中借鉴了一些指导。

HTTPS 加密和 HSTS

如今,HTTPS 是每个网站的必争之地。

通过在 Django 项目中配置SECURE_SSL_REDIRECT,我们确保了 REST API 也是安全的。当我们在下一节讨论部署时,我们将看到在我们的设置中,NGINX 为我们的 Django 项目提供了 SSL 终端。除了 HTTPS,我们还可以配置 Django 将名为Strict-Transport-Security的 HTTP 头附加到响应上。这样做,我们可以确保浏览器只能通过 HTTPS 连接到我们的网站。这个特性被称为 HSTS,虽然 Django 有与 HSTS 相关的设置,但通常的做法是在 web 服务器/代理级别添加这些头。网站 https://securityheaders.com 提供了一个免费的扫描器,可以帮助识别哪些安全头可以添加到 NGINX 配置中。

审核日志记录

审计日志是指为系统中的每个操作写日志的实践——无论是 web 应用、REST API 还是数据库——作为记录特定时间点“谁做了什么”的一种方式。

与日志聚合系统配合使用,审计日志记录是提高数据安全性的好方法。OWASP REST 安全备忘单规定了 REST APIs 的审计日志。Django 已经在 admin 中提供了一些最小形式的审计日志。另外,Django 中的用户表记录了系统中每个用户的最后一次登录。但是这两种方法远不是一个成熟的审计日志解决方案,也没有涵盖 REST API。Django 有几个包可以添加审计日志功能:

  • django-simple-history

  • django-auditlog

django-simple-history可以跟踪模型的变化。这种能力与访问日志相结合,可以为 Django 项目提供有效的审计日志。django-simple-history是一个成熟的包,积极支持。另一方面,django-auditlog提供了相同的功能,但在撰写本文时它仍在开发中。

跨产地资源共享

在解耦设置中,JavaScript 是 REST 和 GraphQL APIs 的主要消费者。

默认情况下,JavaScript 可以用XMLHttpRequestfetch请求资源,只要服务器和前端住在同一个原点。HTTP 中的源是方案或协议、域和端口的组合。这意味着原点http://localhost:8000不等于http://localhost:3000。当 JavaScript 试图从不同的来源获取资源时,浏览器中就会出现一种称为跨来源资源共享 (CORS)的机制。在任何 REST 或 GraphQL 项目中,CORS 都是控制哪些源可以连接到 API 所必需的。为了在 Django 中启用 CORS,我们可以用下面的命令在我们的项目中安装django-cors-headers:

pip install django-cors-headers

要启用该包,在decoupled_dj/settings/base.py中包含corsheaders,如清单 7-10 所示。

INSTALLED_APPS = [
   ...
   'corsheaders',
   ...
]

Listing 7-10decoupled_dj/settings/base.py - Enabling django-cors-headers in Django

接下来,在中间件列表中更高的位置启用 CORS 中间件,如清单 7-11 所示。

MIDDLEWARE = [
   ...
   'corsheaders.middleware.CorsMiddleware',
   'django.middleware.common.CommonMiddleware',
   ...
]

Listing 7-11decoupled_dj/settings/base.py - Enabling CORS Middleware

有了这个改变,我们就可以配置django-cors-headers。在发展中,我们可能希望让所有的起源完全绕过 CORS。向decoupled_dj/settings/development.py添加清单 7-12 中所示的配置。

CORS_ALLOW_ALL_ORIGINS = True

Listing 7-12Decoupled_dj/settings/development.py - Relaxing CORS in Development

在生产中,我们必须更加严格。django-cors-headers允许我们定义一个允许原点的列表,可以在decoupled_dj/settings/production.py中配置,如清单 7-13 所示。

CORS_ALLOWED_ORIGINS = [
   "https://example.com",
   "http://another1.io",
   "http://another2.io",
]

Listing 7-13decoupled_dj/settings/production.py - Hardening CORS in Production

因为我们在每个环境中使用变量,所以我们可以把这个配置指令做成一个列表,如清单 7-14 所示。

CORS_ALLOWED_ORIGINS = env.list(
   "CORS_ALLOWED_ORIGINS",
   default=[]
)

Listing 7-14decoupled_dj/settings/production.py - Hardening CORS in Production

这样,我们可以在.env中将允许的原点定义为一个逗号分隔的列表,用于生产。CORS 是保护用户的一种基本形式,因为如果没有这种机制,任何网站都可以在页面中获取和注入恶意代码,它也是对 REST APIss 的一种保护,REST API 可以明确允许预定义来源的列表,而不是向外界开放。当然,CORS 并不能完全取代认证,认证将在下一节中简要介绍。

DRF 的认证和授权

DRF 的身份验证与 Django 已经提供的现成功能无缝集成。默认情况下,DRF 使用两个类别对用户进行身份验证,SessionAuthenticationBasicAuthentication,这两个类别是根据网站最常用的两种身份验证方法恰当命名的。基本身份验证是一种非常不安全的身份验证方法,即使在 HTTPS 下也是如此,因此完全禁用它,至少只启用基于会话的身份验证是有意义的。要配置 DRF 的这一方面,打开decoupled_dj/settings/base.py,添加REST_FRAMEWORK字典,并配置所需的认证类,如清单 7-15 所示。

REST_FRAMEWORK = {
   "DEFAULT_AUTHENTICATION_CLASSES": [
       "rest_framework.authentication.SessionAuthentication",
   ],
}

Listing 7-15decoupled_dj/settings/base.py - Tweaking Authentication for the Django REST Framework

在 web 应用中,身份验证指的是“你是谁?”身份识别流程的一部分。相反,授权查看“您可以用您的凭证做什么”部分。事实上,单靠身份验证不足以保护网站或 REST API 中的资源。到目前为止,我们的计费应用的 REST API 对任何用户开放。具体来说,我们需要在billing/api/views.py中获得两个 DRF 视图,在清单 7-16 中进行了总结。

from .serializers import InvoiceSerializer
from .serializers import UserSerializer, User
from rest_framework.generics import CreateAPIView, ListAPIView

class ClientList(ListAPIView):
   serializer_class = UserSerializer
   queryset = User.objects.all()

class InvoiceCreate(CreateAPIView):
   serializer_class = InvoiceSerializer

Listing 7-16billing/api/views.py – The DRF View for the Billing App

这两个视图处理以下端点的逻辑:

  • /billing/api/clients/

  • /billing/api/invoices/

现在,任何人都可以访问这两个网站。默认情况下,DRF 不对视图强制任何形式的权限。默认的权限类是AllowAny。为了修复项目中所有 DRF 视图的安全性,我们可以全局应用IsAdminUser权限。为此,在decoupled_dj/settings/base.py中,我们用一个许可类来扩充REST_FRAMEWORK字典,如清单 7-17 所示。

REST_FRAMEWORK = {
   "DEFAULT_AUTHENTICATION_CLASSES": [
       "rest_framework.authentication.SessionAuthentication",
   ],
   "DEFAULT_PERMISSION_CLASSES": [
       "rest_framework.permissions.IsAdminUser"
   ],
}

Listing 7-17decoupled_dj/setting/base.py - Adding Permissions Globally in the DRF

权限类不仅可以全局设置,还可以在单个视图上设置,这取决于特定的用例。

Note

我们也可以只在decoupled_dj/settings/production.py执行这些检查。这意味着我们不会被开发中的认证所困扰。然而,我更喜欢全局应用身份验证和授权,以确保更真实的场景,尤其是在测试中。

禁用可浏览 API

DRF 简化了构建 REST APIs 的大部分日常工作。当我们创建一个端点时,DRF 为我们提供了一个与 API 交互的免费 web 接口。例如,对于创建视图,我们可以通过界面访问 HTML 表单来创建新对象。在这方面,可浏览 API 对开发者来说是一个巨大的福音,因为它提供了一个方便的 UI 来与 API 交互。然而,如果我们忘记保护 API,接口可能会泄漏数据和暴露太多的细节。默认情况下,DRF 使用BrowsableAPIRenderer来呈现可浏览的 API。我们可以通过只暴露JSONRenderer来改变这种行为。这种配置可以放在decoupled_dj/settings/production.py,如清单 7-18 所示。

...
REST_FRAMEWORK = {**REST_FRAMEWORK,
   "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"]
}
...

Listing 7-18decoupled_dj/setting/production.py - Disabling the Browsable API in Production

这只会在生产中禁用可浏览 API。

部署分离的 Django 项目

现代云环境为部署 Django 提供了无限可能。

不可能涵盖每一种部署风格,不包括 Docker、Kubernetes 和无服务器设置。相反,在这一节中,我们为 Django 的制作采用了最传统的设置之一。在流行的自动化工具 Ansible 的帮助下,我们部署了 Django、NGINX 和 Gunicorn。本章的源代码中包含了一个可行的行动手册,它有助于在您自己的服务器上复制设置。从目标机器的准备到 NGINX 的配置,下面几节涵盖了我们到目前为止构建的项目的部署理论。

Note

Ansible 剧本的源代码在 https://github.com/valentinogagliardi/decoupled-dj/blob/chapter_07_security_deployment/deployment/site.yml *。*如何启动行动手册的说明可以在自述文件中找到。

准备目标机器

要部署 Django,我们需要所有必需的包:NGINX、Git(Python 的新版本)和用于请求 SSL 证书的 Certbot。

Ansible 行动手册涵盖了这些包的安装。在这一章中,为了简单起见,我们跳过 Postgres 的安装。建议读者查看 PostgreSQL 下载页面,查看安装说明。在目标系统上,Django 项目还应该有一个非特权用户。一旦完成了这些先决条件,就可以开始配置反向代理 NGINX 了。

Note

Ansible playbook 希望 Ubuntu 作为部署使用的操作系统;不低于 Ubuntu 20.04 LTS 的版本就足够了。

配置 NGINX

在典型的生产安排中,NGINX 工作在系统的边缘。

它接收来自用户的请求,处理 SSL,并将这些请求转发给 WSGI 或 ASGI 服务器。Django 住在窗帘后面。为了配置 NGINX,在这个例子中,我们使用域名decoupled-django.com和子域static.decoupled-django.com。典型 Django 项目的 NGINX 配置至少由三部分组成:

  • 一个或多个upstream声明

  • Django 主要入境点的申报

  • 用于服务静态文件的server声明

deployment/templates/decoupled-django.com.j2文件包括整个配置;在这里,我们只是概述了设置的一些细节。upstream指令指示 NGINX 关于 WSGI/ASGI 服务器的位置。清单 7-19 显示了相关配置。

upstream gunicorn {
   server 127.0.0.1:8000;
}

Listing 7-19deployment/templates/decoupled-django.com.j2 - Upstream Configuration for NGINX

在第一个server块中,我们告诉 NGINX 将主域的所有请求转发给upstream,如清单 7-20 所示。

server {
   server_name {{ domain }};

   location / {
       proxy_pass http://gunicorn;
       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-Forwarded-Proto $scheme;
   }

   ## SSL configuration is managed by Certbot
}

Listing 7-20deployment/templates/decoupled-django.com.j2 - Server Configuration for NGINX

这里的{{ domain }}是剧本中声明的一个变量。这里重要的是proxy_pass指令,它将请求转发给 Gunicorn。此外,在这一节中,我们还为代理设置了头,这些头在每个请求中被传递给 Django。特别是,我们有:

  • X-Real-IPX-Forwarded-For,它们确保 Django 获得真实访问者的 IP 地址,而不是代理的地址

  • X-Forwarded-Proto,它告诉 Django 客户端使用哪种协议进行连接(HTTP 或 HTTPS)

Gunicorn 和 Django 的生产要求

在第三章,我们介绍了异步 Django,我们使用 Uvicorn 在 ASGI 下本地运行 Django。在生产中,我们可能想用 Gunicorn 运行 Uvicorn。为此,我们需要为生产配置依赖关系。在requirements文件夹中,创建一个名为production.txt的新文件。在这个文件中,我们声明了 ASGI 部件的所有依赖项,如清单 7-21 所示。

-r ./base.txt
gunicorn==20.0.4
uvicorn==0.13.1
httptools==0.1.1
uvloop==0.15.2

Listing 7-21requirements/production.txt - Production Requirements

这个文件应该放在 Git repo 中,因为它将在部署阶段使用。现在让我们看看如何为生产准备我们的 Vue.js 应用。

与 Django 一起准备生产中的 Vue.js

在第六章中,我们看到了如何在开发中为 Django 下的 Vue.js 服务。我们在 Vue.js 应用的根文件夹中配置了vue.config.js和一个名为.env.staging的文件。这一次,我们将发运生产中的产品。这意味着我们需要一个生产 Vue.js 包,它应该由 NGINX 提供服务,而不是来自 Django。关于静态文件,在生产中,Django 想知道在哪里可以找到 JavaScript 和 CSS。这是在STATIC_URL中配置的,如清单 7-22 所示,摘自本章开头。

...
STATIC_URL=https://static.decoupled-django.com/
STATIC_ROOT=static/
...

Listing 7-22decoupled_dj/settings/.env.production.example - Static Configuration for Production

注意我们用的是 https://static.decoupled-django.com ,这个子域必须在 NGINX 中配置。清单 7-23 显示了子域配置。

...
server {
   server_name static.{{ domain }};

   location / {
       alias /home/{{ user }}/code/static/;
   }
}
...

Listing 7-23deployment/templates/decoupled-django.com.j2 - Ansible Template for NGINX

这里,{{ user }}是在 Ansible 剧本中定义的另一个变量。在设置好 Django 和 NGINX 之后,为了配置 Vue.js,使它“知道”它将从上面的子域得到服务,我们需要在billing/vue_spa中创建另一个环境文件,命名为.env.production,其内容如清单 7-24 所示。

VUE_APP_STATIC_URL=https://static.decoupled-django.com/billing/

Listing 7-24billing/vue_spa/.env.production - Production Configuration for Vue.js

这告诉 Vue.js 它的包将从一个特定的子域/路径提供服务。文件就绪后,如果我们移动到billing/vue_spa文件夹,我们可以运行以下命令:

npm run build -- --mode production

这将在static/billing中构建优化的 Vue.js 包。我们现在需要将这些文件推送到 Git repo。这样做之后,在下一节中,我们将最终看到如何从这个回购开始部署项目。

Note

在现实世界的项目中,生产 JavaScript 包不会被直接推送到源代码控制中。相反,在所有测试套件通过之后,持续集成/部署系统负责构建生产资产,或者 Docker 映像。

部署

在本地构建用于生产的 Vue 并将文件提交给 repo 之后,我们需要将实际代码部署到目标机器上。

为此,我们以在前面步骤中创建的非特权用户身份登录(Ansible playbook 定义了一个名为decoupled-django的用户)或者使用 SSH 登录。完成后,我们将回购克隆到一个文件夹中,为了方便起见,可以称之为code:

git clone --branch chapter_07_security_deployment https://github.com/valentinogagliardi/decoupled-dj.git code

该命令从指定的分支chapter_07_security_deployment中克隆项目的 repo。代码准备就绪后,我们移动到新创建的文件夹,并激活一个 Python 虚拟环境:

cd code
python3.8 -m venv venv
source venv/bin/activate

接下来,我们使用以下命令安装生产依赖项:

pip install -r requirements/production.txt

在运行 Django 之前,我们需要为生产配置环境文件。这个文件必须放在decoupled_dj/settings/.env中。管理这个文件时必须格外小心,因为它包含敏感的凭证和 Django 密钥。特别是,。env文件不应该进入源代码控制。清单 7-25 概括了生产环境的配置指令。

ALLOWED_HOSTS=decoupled-django.com,static.decoupled-django.com
DEBUG=no
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=https://static.decoupled-django.com/
STATIC_ROOT=static/

Listing 7-25decoupled_dj/settings/.env.production.example - Environment Variables for Production

这个文件的一个例子可以在decoupled_dj/settings/.env.production.example.中的源 repo 中找到。有了这个文件,我们可以用下面的命令将 Django 切换到生产环境:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.production

最后,我们可以用collectstatic收集静态资产并应用迁移:

python manage.py collectstatic --noinput
python manage.py migrate

第一个命令将静态文件复制到/home/decoupled-django/code/static,由 NGINX 拾取。在 Ansible 行动手册中,有一系列任务可以自动执行这里介绍的所有步骤。在运行项目之前,我们可以创建一个超级用户来访问受保护的路由:

python manage.py createsuperuser

为了进行测试,仍然在/home/decoupled-django/code中,我们可以用下面的命令运行 Gunicorn:

gunicorn decoupled_dj.asgi:application -w 2 -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 --log-file -

Ansible playbook 还包括一个 Systemd 服务,用于在引导时设置 Gunicorn。如果一切顺利,我们可以访问 https://decoupled-django.com/77randomAdmin@33/ ,用超级用户凭证登录网站,访问我们 Vue.js app 所在的 https://decoupled-django.com/billing/ 。图 7-1 显示了我们工作的结果。

img/505838_1_En_7_Fig1_HTML.jpg

图 7-1

Django 和 Vue.js 应用在生产中部署

同样,Ansible 行动手册也涵盖了来自 Git repo 的部署。对于大多数项目,Ansible 是设置和部署 Django 项目的良好起点。现在其他的选择是 Docker 和 Kubernetes,越来越多的团队已经将它们完全内化到他们的部署工具链中。

Note

这是提交到目前为止所做的更改,并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_07_security_deployment 找到本章的源代码。

摘要

这一章我们讲了很多。我们检查了安全和部署。在此过程中,您了解到:

  • Django 在默认情况下非常安全,但是在公开 REST API 时必须采取额外的措施

  • Django 不是一个人在工作;像 NGINX 这样的反向代理对于生产设置来说是必须的

  • 部署 Django 有很多种方法;像 Ansible 这样的配置工具在大多数情况下都能很好地工作

在下一章,我们将介绍如何使用 Next.js,React 框架,作为 Django 的前端。

额外资源

八、Django REST 和 Next.js

本章涵盖:

  • Django 作为内容回购

  • React 及其生态系统

  • Vue.js 及其生态系统简介

在本章中,在讨论了安全性和部署之后,我们回到本地工作站,用 Next.js、React 生产框架和 TypeScript 来构建一个博客。

Django 作为一个无头 CMS

基于 REST 和 GraphQL 的解耦架构促进了近年来一种新趋势的兴起:无头 CMS。

有了专门处理输入/输出的数据和序列化的后端,我们可以创建与后端完全分离的消费者前端。这些前端不仅限于充当单页面应用,还可以从后端检索数据来构建静态网站。在本章中,我们将介绍 Next.js,这是一个用于服务器端渲染和静态站点生成的 React 框架。

构建博客应用

Django 上有无数的书籍和教程使用博客应用作为向初学者介绍这个奇妙框架的最直接的方式。

它可能不是最令人兴奋的应用,但是在我们的例子中,它是使用 Django 作为 JavaScript 框架的内容存储库的完美候选。我们开始吧。

Note

本章假设您在回购根decoupled-dj中,Python 虚拟环境处于活动状态,环境变量DJANGO_SETTINGS_MODULE设置为decoupled_dj.settings.development

构建模型

对于我们的博客应用,我们需要一个Blog模型。这个模型应该连接一个User。每个User也应该能够访问其博客文章。首先,我们在blog/models.py中创建模型,如清单 8-1 所示。

from django.db import models
from django.conf import settings

class Blog(models.Model):
   class Status(models.TextChoices):
       PUBLISHED = "PUBLISHED"
       UNPUBLISHED = "UNPUBLISHED"

   user = models.ForeignKey(
       to=settings.AUTH_USER_MODEL,
       on_delete=models.PROTECT,
       related_name="blog_posts"
   )
   title = models.CharField(max_length=160)
   body = models.TextField()
   status = models.CharField(
       max_length=15,
       choices=Status.choices,
       default=Status.UNPUBLISHED
   )
   created_at = models.DateTimeField(auto_now_add=True)

Listing 8-1blog/models.py - Model for the Blog App

在这个模型中,我们为博客条目定义了一些最常见的字段:

  • title:博客条目的标题

  • body:博客条目的文本

  • created_at:创建日期

  • status:条目是否发布

user字段中,我们有一个User的外键,并适当地配置了related_name,这样用户就可以通过 ORM 中的.blog_posts属性访问它的帖子。我们可以添加更多的字段,比如 slug,但是对于本章的范围来说,这些已经足够了。

启用应用

模型就绪后,我们在decoupled_dj/settings/base.py中启用应用,如清单 8-2 所示。

INSTALLED_APPS = [
   ...
   "blog.apps.BlogConfig",
]

Listing 8-2decoupled_dj/settings/base.py - Enabling the Blog App

最后,我们应用迁移:

python manage.py makemigrations
python manage.py migrate

当我们在那里时,让我们在数据库中创建几个博客条目。首先我们打开一个 Django shell:

python manage.py shell_plus

然后我们创建条目(>>>是 shell 提示符):

>>> juliana = User.objects.create_user(username="jul81", name="Juliana", email="juliana@acme.io")
>>> Blog.objects.create(title="Exploring Next.js", body="Dummy body", user=juliana)
>>> Blog.objects.create(title="Decoupled Django", body="Dummy body", user=juliana)

我们以后会需要这些条目,所以这一步不能跳过。有了这个应用,我们现在就可以在继续之前构建 REST 逻辑了。

构建 REST 后端

我们的目标是将Blog模型暴露给外界。这样,任何 JavaScript 客户机都可以检索博客条目。正如我们在第五章中对计费应用所做的,我们需要连接 DRF 的基础:序列化器和视图。在下一节中,我们将为Blog构建一个序列化器,并为公开博客条目构建两个视图。

构建序列化程序

为了构建我们的 REST API,我们在blog中创建了一个名为api的新 Python 包。在这个包中,我们放置了 REST API 的所有逻辑。首先,让我们用清单 8-3 中的序列化程序在blog/api/serializers.py创建一个新文件。

from blog.models import Blog
from rest_framework import serializers

class BlogSerializer(serializers.ModelSerializer):
   class Meta:
       model = Blog
       fields = ["title",
                 "body",
                 "created_at",
                 "status",
                 "id"]

Listing 8-3blog/api/serializers.py - DRF Serializer for the Blog Model

这个序列化器没有什么神秘的:它公开了模型的字段,减去了user。保存并关闭文件。有了序列化器,我们就可以构建视图和 URL 配置了。

构建视图和 URL

对于这个项目,我们需要两个视图:

  • 一个ListAPIView来暴露整个帖子列表

  • 一个RetrieveAPIView暴露单个条目

我们在blog/api/views.py的一个新文件中创建视图,如清单 8-4 所示。

from .serializers import BlogSerializer
from blog.models import Blog
from rest_framework.generics import ListAPIView, RetrieveAPIView

class BlogList(ListAPIView):
   serializer_class = BlogSerializer
   queryset = Blog.objects.all()

class BlogDetail(RetrieveAPIView):
   serializer_class = BlogSerializer
   queryset = Blog.objects.all()

Listing 8-4blog/api/views.py - REST Views for Our Blog

接下来,我们在blog/urls.py的新文件中创建一个 URL 配置。像往常一样,我们给这个配置一个app_name,这有助于在根 URL 配置中命名应用,如清单 8-5 所示。

from django.urls import path
from .api.views import BlogList, BlogDetail

app_name = "blog"

urlpatterns = [
   path("api/posts/",
        BlogList.as_view(),
        name="list"),
   path("api/posts/<int:pk>",
        BlogDetail.as_view(),
        name="detail"),
]

Listing 8-5blog/urls.py - URL Configuration for the Blog App

最后,我们在decoupled_dj/urls.py中包含了我们博客的 URL 配置,如清单 8-6 所示。

from django.urls import path, include

urlpatterns = [
   ...
   path("blog/", include("blog.urls", namespace="blog")),
]

Listing 8-6blog/urls.py - Project URL Configuration

运行 Django 开发服务器后,我们应该能够在http://localhost:8000/blog/api/posts/访问端点。这将是 Next.js 的数据源。

Note

为了避免被本章的认证所困扰,可以暂时在decoupled_dj/setting/base.py中注释DEFAULT_PERMISSION_CLASSES

React 生态系统简介

React 是一个用于构建用户界面的 JavaScript 库,风靡了 web 开发。

通过组件、独立的标记单元和 JavaScript 代码来构建用户界面的 React 方法并不新鲜。然而,由于其灵活性,React 获得了巨大的人气,超过 Angular 和 Vue.js 成为构建单页面应用的首选库。在接下来的部分中,我们将回顾 React 基础知识,并介绍 Next.js,React 生产框架。

重新引入反应

大多数时候,用户界面不是一个单一的整体:它们由独立的单元组成,每个单元控制整个界面的一个特定方面。

例如,如果我们想到一个<select> HTML 元素,我们可能会注意到,在一个典型的应用中,它很少只出现一次。相反,它可以在同一个界面中多次使用。一开始,web 开发人员(包括我自己)通过一遍又一遍地复制粘贴相同的标记来重用应用的一部分。然而,这种方法经常导致不可持续的混乱。过去的问题是:“我如何重用这个标记及其 JavaScript 逻辑”?React 填补了这个巨大的空白,它仍然在某种程度上影响着 web 平台:缺乏原生组件,即可重用的标记和逻辑。

Note

值得注意的是,Web 组件(用于构建界面的原生组件)已经成为现实,但该规范仍有许多不完善之处。

React 支持基于组件的方法来构建用户界面。最初,React 组件是作为 ES2015 类构建的,因为它们能够保留内部状态。随着钩子的出现,React 组件可以作为简单的 JavaScript 函数构建,如清单 8-7 所示。

import React, { useState } from "react";

export default function Button(props) {
 const [text, setText] = useState("");
 return (
   <button onClick={() => setText("CLICKED")}>
     {text || props.initialText}
   </button>
 );
}

Listing 8-7React Component Example

在这个例子中,我们将一个Button组件定义为一个 JavaScript 函数。在组件中,我们使用useState钩子来保持内部状态。当我们点击按钮时,onClick处理程序(它映射到click DOM 事件)触发setText(),改变组件的内部状态。此外,组件从外部获取props,即一个只读对象,它获取任意数量的属性,组件可以使用这些属性向用户呈现数据。一旦我们创建了一个组件,我们就可以无限地重用它,如清单 8-8 所示。

import Button from "./Button";

export default function App() {
 return (

     <Button initialText="CLICK ME" />
     <Button initialText="CLICK ME" />

 );
}

Listing 8-8React Component Usage Example

这里我们有一个嵌套了我们的Button两次的App根组件。从外面我们经过一个initialText属性。React 组件并不总是那么简单,但是这个例子总结了 React 的重要理论,并为下一节铺平了道路。

Next.js 简介

构建单页应用可能看起来很容易。我们已经习惯于使用 create-react-app 和 Vue CLI 等工具来创建新的 SPA 项目。

这些工具给人一种工作已经完成的错觉,这在某种程度上是真实的。现实是,在生产中事情并不那么简单。根据项目的不同,我们需要路由、高效的数据获取、搜索引擎优化、国际化以及性能和图像优化。Next.js 是 React 的一个框架,旨在减轻反复手动设置的负担,并为开发人员提供一个自以为是的生产就绪环境。

在第二章中,我们简单讨论了通用 JavaScript 应用,触及了在后端和前端之间共享和重用代码的能力。Next.js 正好属于这一类工具,因为它使开发人员能够编写服务器端呈现的 JavaScript 应用。Next.js 有两种主要的操作模式:

  • 服务器端渲染

  • 静态站点生成

在接下来的部分中,我们将在用 React 和 TypeScript 构建我们的博客前端时研究这两者。需要注意的是,这些框架不能直接与 Django 集成,因为它们有自己的服务器,由 Node.js 操作。它处理路由、认证、国际化,以及介于两者之间的一切。在这种安排中,像 Django 这样的框架只通过 REST 或 GraphQL API 为 Next.js 提供数据。

构建 Next.js 前端

首先,我们初始化一个 Next.js 项目。从根项目文件夹decoupled_dj/中,启动以下命令:

npx create-next-app --use-npm next-blog

这将在decoupled_dj/next-blog中创建项目。项目就绪后,进入文件夹:

cd next-blog

在 Next.js 项目文件夹中,安装 TypeScript 和几个其他类型定义,一个用于 Node、js,另一个用于 React:

npm i typescript @types/react @types/node --save-dev

安装完成后,使用以下命令创建 TypeScript 的配置文件:

touch tsconfig.json

在这个文件中,根据我们希望 TypeScript 执行的严格程度,我们可以将strict选项设置为false。然而,对于大多数项目,我们可能希望将其设置为true。文件就绪后,启动 Next.js 开发服务器:

npm run dev

这将在http://localhost:3000开始 Next.js。如果一切顺利,您应该会从控制台看到以下输出:

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
We detected TypeScript in your project and created a tsconfig.json file for you.

从那里,我们准备好编写我们的第一个组件。

页面和路由

Next.js 的基础理论围绕着页面的概念。

如果我们查看新创建的项目,应该会看到一个名为pages的文件夹。在这个文件夹中,我们可以定义子文件夹。例如,通过在pages/posts创建一个新文件夹,当运行 Next.js 项目时,我们可以访问http://localhost:3000/posts/。没什么特别刺激的。有趣的部分来自 React 组件。放置在pages中的任何.js.jsx.ts.tsx文件都成为 Next.js 的一个页面。为了理解 Next.js 如何工作,我们先从固定数据开始一步一步地创建一个页面,稍后介绍数据获取。

Note

对于 Next.js 部分,从现在开始我们在decoupled_dj/next-blog中工作。必须从该路径开始,在适当的子文件夹中创建每个建议的文件。

我们将在 Next.js 中创建一个简单的页面。对于下面的例子,在pages/posts/index.tsx中创建一个新文件,下面的 React 组件如清单 8-9 所示。

const BlogPost = () => {
 return (
   <div>
     <h1>Post title</h1>
     <div>
       <p>Post body</p>
     </div>
   </div>
 );
};

export default BlogPost;

Listing 8-9pages/posts/index.tsx - A First Next.js Page

这是一个 React 组件,也是 Next.js 的一个页面。让我们运行开发服务器:

npm run dev

现在,我们可以前往http://localhost:3000/posts,我们应该能够看到一个简单的页面,其中包含我们放在 React 组件中的内容。确实很有趣,但是对于一个动态网站来说有点没用。如果我们想显示不同的博客文章,也许可以通过id获取它们,该怎么办?

在 Next.js 中,我们可以使用动态路由按需构建页面。例如,用户应该能够访问http://localhost:3000/posts/2并在那里看到想要的内容。为此,我们需要将组件的文件名从index.ts改为:

[id].tsx

通过这样做,Next.js 将响应对http://localhost:3000/posts/$id的任何请求,其中$id是我们可以想象的任何数字 ID 的占位符。有了这些信息,组件就可以根据id从 REST API 中获取数据,对于 Next.js 来说,这就变成了一个 URL 参数。有了这些知识,让我们在进入数据获取之前用类型声明来丰富组件。清除我们一分钟前创建的组件中的所有内容,并将下面的代码放入pages/posts/[id].tsx,如清单 8-10 所示。

enum BlogPostStatus {
 Published = "PUBLISHED",
 Unpublished = "UNPUBLISHED",
}

type BlogPost = {
 title: string;
 body: string;
 created_at: string;
 status: BlogPostStatus;
 id: number;
};

const BlogPost = ({ title, body, created_at }: BlogPost) => {
 return (
   <div>
     <header>
       <h1>{title} </h1>
       <span>Published on: {created_at}</span>
     </header>
     <div>
       <p>{body}</p>
     </div>
   </div>
 );
};

export default BlogPost;

Listing 8-10pages/posts/[id]tsx - Blog Component for the Corresponding Next.js Page

该组件是用 TypeScript 静态类型化的。这个文件中有三种特定的类型脚本符号。这里有一个解释:

  • BlogPostStatus : TypeScript enum,为博客文章定义一组可能的状态。它映射了 Django 模型中定义的嵌套的Status类。

  • BlogPost:定义 React 组件属性的类型脚本type。它映射模型的字段(减去user)。

  • BlogPost:在组件参数中使用,强类型化我们的道具。

有了这个组件,我们现在就可以定义数据获取逻辑,用相应的数据填充每个博客文章。

Note

在 TypeScript 中,枚举在编译过程中会产生大量的 JavaScript 代码。这个问题的解决方案是 const enums,但是 Babel 不支持它们,Next.js 使用它们将 TypeScript 编译成 JavaScript。

数据提取

如前所述,Next.js 可以在两种模式下运行:

  • 服务器端渲染

  • 静态站点生成

使用服务器端呈现,页面是根据每个请求构建的,非常像传统的服务器端呈现的网站。想想 Django 模板或 Rails。在这种模式下,当用户点击相应的路径时,我们可以获取每个请求的数据。在 Next.js 中,这是通过getServerSideProps完成的。这应该是一个异步方法,从 React 组件所在的同一个文件中导出。在getServerSideProps中我们需要注意两件事:

  • 获取所需的数据

  • 至少返回一个props对象

一旦这些都完成了,Next.js 将负责把props传递给我们的 React 组件。清单 8-11 展示了一个函数的示例框架,包括类型。

export const getServerSideProps: GetServerSideProps = async (context) => {
 // fetch data
 return { props: {} };
};

Listing 8-11getServerSideProps Skeleton

context对象参数携带关于请求、响应和一个params对象的信息,我们可以在这个对象上访问请求参数。为了方便起见,我们将从context中析构params。让我们将这个函数添加到pages/posts/[id].tsx中,如清单 8-12 所示,并带有相应的数据获取逻辑。

import { GetServerSideProps } from "next";

const BASE_URL = "http://localhost:8000/blog/api";

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
 const id = params?.id;

 const res = await fetch(`${BASE_URL}/posts/${id}`);

 if (!res.ok) {
   return {
     notFound: true,
   };
 }

 const json = await res.json();
 const { title, body, created_at, status } = json;

 return { props: { title, body, created_at, status } };
};

Listing 8-12pages/posts/[id].tsx - Data Fetching Logic for the Page

这段代码需要解释一下:

  • 我们导入了GetServerSideProps类型,它用于为实际函数提供类型

  • getServerSideProps中:

    • 我们从params得到id

    • 我们从 Django REST API 获取数据

    • 如果来自 API 的响应是否定的,我们返回notFound

    • 如果后端返回博客文章,我们将为组件返回一个props对象

Note

getServerSideProps有更多的返回属性,便于特定的用例。请查看官方文档以了解更多信息。

有了这些代码,我们就可以进行测试了。首先,Django 必须逃跑。在终端中,转到decoupled_dj并启动 Django:

python manage.py runserver

在启动 Next.js 的另一个终端中,运行开发服务器(如果它还没有运行的话)(从decoupled_dj/next_blog文件夹中):

npm run dev

现在,访问http://localhost:3000/posts/1http://localhost:3000/posts/2。你应该会看到一篇博文,如图 8-1 所示。

img/505838_1_En_8_Fig1_HTML.jpg

图 8-1

Next.js 回应了一篇博文的详细路线

如您所见,这种方法完美无缺。在这种模式下,Next.js 在将页面发送给用户之前检索数据。但是对于博客来说,这不是最好的方法:没有比静态网站更好的让搜索引擎开心的网站了。下一节将解释如何使用 Next.js 实现数据获取和静态站点生成。

静态站点生成

每当我们想要向用户显示一篇博客文章时,调用 REST API 有点低效。

博客更适合作为静态页面。除了在每个请求上获取数据,Next.js 还支持在构建时获取数据。在这种模式下,我们可以以静态 HTML 的形式生成页面及其相应的数据,Next.js 将把这些数据提供给我们的用户。为了实现这一点,我们需要结合使用 Next.js 中的另外两个方法:getStaticPathsgetStaticProps。上一节的getServerSideProps和这些方法有什么区别?

getServerSideProps用于在服务器端渲染中异步获取每个请求的数据。也就是说,当用户到达给定的页面时,它必须等待一段时间,因为 Next.js 服务器必须从给定的源(REST API 或 GraphQL 服务)获取数据。这种方法对于动态且变化很大的数据来说很方便。

相反,getStaticProps用于在构建时异步获取数据。也就是说,当我们运行npm run buildyarn build时,Next.js 会创建一个产品包,其中包含它需要的所有 JavaScript,以及任何标记为静态的页面。清单 8-13 显示了该函数的示例框架。

import { GetStaticProps } from "next";

const BASE_URL = "http://localhost:8000/blog/api";

export const getStaticProps: GetStaticProps = async (_) => {
 const res = await fetch(`${BASE_URL}/posts/1`);

 const json = await res.json();
 const { title, body, created_at, status } = json;

 return { props: { title, body, created_at, status } };
};

Listing 8-13getStaticProps Example

注意我们是如何具体调用http://localhost:8000/blog/api/1的,这是相当有限的。在构建阶段之后,Next.js 生成相应的静态页面。通过运行npm run startyarn start,Next.js 可以为我们的网站服务。当页面导出getStaticProps时,相关组件接收从该方法返回的props。然而,为了让我们的例子工作,页面必须有一个固定的路径,比如1.tsx。事先知道我们后端的每篇博客文章的 ID 是不切实际的。这就是getStaticPaths发挥作用的地方。使用这种方法,结合使用getStaticProps,我们可以生成一个路径列表,供getStaticProps用来获取数据。为了利用静态站点生成,让我们更改pages/posts/[id].tsx,以便它使用getStaticPathsgetStaticProps而不是getServerSideProps,如清单 8-14 所示。

import { GetStaticPaths, GetStaticProps } from "next";

const BASE_URL = "http://localhost:8000/blog/api";

export const getStaticPaths: GetStaticPaths = async (_) => {
 const res = await fetch(`${BASE_URL}/posts/`);
 const json: BlogPost[] = await res.json();
 const paths = json.map((post) => {
   return { params: { id: String(post.id) } };
 });

 return {
   paths,
   fallback: false,
 };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
 const id = params?.id;

 const res = await fetch(`${BASE_URL}/posts/${id}`);

 if (!res.ok) {
   return {
     notFound: true,
   };
 }

 const json: BlogPost = await res.json();
 const { title, body, created_at, status } = json;

 return { props: { title, body, created_at, status } };
};

Listing 8-14pages/posts/[id].tsx - Data Fetching at Build Time with getStaticPaths and getStaticProps

这里,getStaticProps的逻辑与上一节中getServerSideProps的逻辑相同。然而,我们也有getStaticPaths。在此功能中,我们:

  • 调用 REST API 获取来自http://127.0.0.1:8000/blog/api/posts/的所有帖子的列表

  • 生成并返回路径数组

这个路径数组很重要,必须具有以下形状:

   paths: [
     { params: { id: 1 } },
     { params: { id: 2 } },
     { params: { id: 3 } },
   ]

在我们的代码中,它由以下代码片段生成:

...
 const paths = json.map((post) => {
   return { params: { id: String(post.id) } };
 });
...

getStaticPaths的返回对象中,还有一个fallback选项。它用于显示不包含在paths中的任何路径的 404 页面。此时,我们可以使用以下命令来构建博客:

npm run build

注意 Django 一定还在另一个终端中运行。一旦构建就绪,我们应该在.next/server/pages/posts中看到静态页面。为了给博客提供服务(至少目前是在本地),我们运行以下命令:

npm run start

现在,访问http://localhost:3000/posts/1http://localhost:3000/posts/2,你应该会看到一篇博文,如图 8-1 所示。显然,对于用户来说,这个版本和之前的getServerSideProps版本没有什么变化。但是如果我们停止 Django API,我们仍然可以访问我们的博客,因为现在它只是一堆静态页面,更重要的是,静态 HTML 的性能增益是无可匹敌的。

Note

getStaticPropsgetServerSideProps并不互斥。根据用例,Next.js 项目中的页面可以使用这两者。例如,站点的一部分可以作为静态 HTML,而另一部分可以作为单页应用。

我们谈了很多。这里展示的概念在一个简单的博客中可能看起来有点太多了。毕竟,单凭 Django 就足以处理这种类型的网站。但是越来越多的团队正在采用这种设置,前端开发人员可以使用他们最喜欢的工具来塑造 UI,从单页应用到静态网站。

部署 Next.js

Next.js 是一个成熟的 React 框架。它需要自己的 Node.js 服务器,这个服务器已经集成了,这也意味着它不能在 Django 内部运行。通常,部署的结构是 Django 后端和 Next.js 系统位于各自独立的机器/容器上。

对 Django 使用 React

2019 年,我在博客上发表了一篇题为“Django REST with React”的帖子。

该教程说明了如何配置 webpack 环境以在正确的 Django 静态文件夹中构建 React,就像我们在第五章中使用 Vue.js 所做的一样。博文中概述的方法本质上并不坏,但它可能不适合较大的团队,并且由于 webpack 中潜在的突破性变化,它可能变得难以跟上变化。一个解决方案是流行的 create-react-app,它抽象出了所有与 webpack 和 Babel 相关的平凡细节。然而,要让 Django 使用 create-react-app,必须指示 Django 寻找 react 静态文件。这包括调整TEMPLATESSTATICFILES_DIRS中的DIRS键。

Vue.js 生态系统

对于一个不经意的观察者来说,现代 web 开发领域似乎完全由 React 主导。

这与事实相去甚远。Vue.js 和 Angular 占据了不错的市场份额。Vue.js 有一个称为 Nuxt.js 的框架,在功能上等同于 Next.js。没有足够的空间来涵盖本书中的所有内容,但是考虑到 Next.js 和 Nuxt.js 几乎具有完全相同的功能,习惯于使用 Vue.js 的开发人员可以将本章中看到的相同概念应用到他们选择的框架中。事实上,我们鼓励您尝试一下 Nuxt.js。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_08_django_rest_meets_next 找到本章的源代码。

摘要

本章将 Next.js 项目与博客应用 REST API 配对。在此过程中,您了解了:

  • React 的类型脚本

  • Next.js 操作模式

  • Next.js 数据提取

在下一章,我们将更加认真地对待从后端到前端的整个范围的单元和功能测试。

额外资源

九、解耦的世界中的测试

本章涵盖:

  • 面向大量使用 JavaScript 的接口的功能测试

  • Django REST 框架的单元测试

在本章中,我们将测试添加到我们的应用中。在第一部分中,我们介绍了用 Cypress 对用户界面进行功能测试。在第二部分,我们转移到 Django 的单元测试。

Note

本章假设您在 repo root decoupled-dj中,Python 虚拟环境处于活动状态。

功能测试简介

通常情况下,软件开发中的测试是事后才想到的,这是一种被忽视的浪费时间的行为,会减慢开发速度。

对于用户界面的功能测试来说尤其如此,因为要测试的 JavaScript 交互量与日俱增。这种感觉可能来自于对 Selenium for Python 等工具的记忆,不幸的是,这些工具非常慢,并且很难用于测试 JavaScript 接口。然而,随着新型 JavaScript 工具的出现,这种情况在最近几年有了很大的改变,它减轻了测试单页面应用的负担。这些工具使得从用户的角度为界面编写测试变得容易。功能测试也是捕捉 UI 中的回归的一个很好的方法,也就是说,在一个不相关的特性的开发过程中,意外引入的 bug,而这个特性在变化之前工作得很好。在接下来的部分中,我们将介绍 Cypress,一个 JavaScript 的测试运行器。

Cypress 入门

Cypress 是 NPM 上提供的一个 JavaScript 包,可以放入我们的项目中。在只有一个 JavaScript 前端需要测试的项目中,可以将 Cypress 安装在 React/Vue.js 应用的根项目文件夹中。

在我们的例子中,由于我们可能有不止一个 JavaScript 应用要测试,我们可以将 Cypress 安装在根项目文件夹decoupled-dj中。首先,用以下命令初始化一个package.json:

npm init -y

接下来,安装 Cypress:

npm i cypress --save-dev

完成后,您可以使用以下命令第一次启动 Cypress:

./node_modules/.bin/cypress open

该命令将打开一个窗口,并在根项目文件夹中创建一个名为cypress的新文件夹。还创建了许多子文件夹,如下面的目录列表所示:

cypress
├── fixtures
├── integration
├── plugins
└── support

对于本章的范围,我们可以安全地忽略这些文件夹,除了integration。我们将在那里进行测试。有了 Cypress,我们可以在接下来的部分中编写我们的第一个测试了。

了解计费应用的功能测试

还记得第章第 6 的计费 app 吗?现在是时候为它编写功能测试了。

这个应用有一个表单,用户可以填写字段来创建一个新的发票,然后点击创建发票在后端创建新的实体。图 9-1 显示了第六章的最终形式。

img/505838_1_En_9_Fig1_HTML.jpg

图 9-1

第六章的发票表格

我们不要忘记,我们希望在功能测试中从用户的角度测试界面。通过一个漂亮流畅的语法,Cypress 允许我们像用户一样一步一步地与元素交互。我们如何知道如何测试和测试什么?编写功能测试应该是自然而然的事情。我们需要想象用户将如何与界面交互,为我们想要测试的每个 HTML 元素编写选择器,然后验证该元素的行为是否正确,或者它是否响应用户交互而改变。对于我们的表单,我们可以确定以下步骤。用户应该:

  • 选择发票的客户

  • 至少编制一个包含数量、说明和价格的发票行

  • 为发票选择一个日期

  • 选择发票的到期日

  • 点击创建发票提交表格

所有这些步骤都必须翻译成 Cypress 逻辑,它本质上只是 JavaScript。在下一节中,我们为表单的<select>元素编写第一个测试。

创建第一个测试

在我们测试的第一次迭代中,我们与界面的两个部分进行交互。特别是,我们:

  • 将表单作为目标

  • select互动

在 Cypress 中,这两个步骤转化为方法调用,看起来几乎像简单的英语。首先,用清单 9-1 中所示的代码在cypress/integration/Billing.spec.js中创建一个新的测试。

context("Billing app", () => {
 describe("Invoice creation", () => {
   it("can create a new invoice", () => {
     cy.visit("http://localhost:8080/"
);
     cy.get("form").within(() => {
       cy.get("select").select("Juliana - juliana@acme.io");
     });
   });
 });
});

Listing 9-1cypress/integration/Billing.spec.js - A First Test Skeleton in Cypress

让我们来分解这些说明:

  • 包含整个测试,并给它一个有凝聚力的组织

  • describe()包含了我们测试的一个方面,通常与context()结合使用

  • it()是实际的试块

  • cy.visit()导航至应用主页

  • 是 Cypress 本身,它提供了许多选择元素和与元素交互的方法

  • cy.get("form")选择界面中的第一个表单

  • within()告诉 Cypress 从先前选择的元素内部运行每个后续命令

  • cy.get("select")选择表单内的<select>元素

  • cy.get("select").select("Juliana - juliana@acme.io")select中选取值为"Juliana - juliana@acme.io"<option>元素

Note

由于我们的界面相当简单,我们不会过多关注高级选择器和最佳实践。鼓励读者阅读 Cypress 文档以了解更多关于高级技术的信息。

这段代码的突出之处在于每条语句的表现力。有了流畅的描述性方法,我们就可以像对用户期望的那样,瞄准 HTML 元素并与之交互。理论上,我们的测试已经准备好运行了,但是有一个问题。<select>需要来自网络的数据。这个数据来自 Vue 组件的mounted()方法,如清单 9-2 所示。

...
 mounted() {
   fetch("/billing/api/clients/")
     .then(response => {
       if (!response.ok) throw Error(response.statusText);
       return response.json();
     })
     .then(json => {
       this.users = json;
     });
 }
...

Listing 9-2billing/vue_spa/src/components/InvoiceCreate.vue - The Form’s Mounted Method

事实上,如果我们启动 Vue.js 应用,我们会在控制台中看到以下错误:

Proxy error: Could not proxy request /billing/api/clients/ from localhost:8080 to http://localhost:8000

这来自 Vue.js 开发服务器,我们在开发中指示它代理所有网络请求到 Django REST API。如果不在另一个终端中运行 Django,我们真的无法测试任何东西。这就是 Cypress 网络拦截发挥作用的地方。原来我们可以拦截网络通话,直接从 Cypress 回复。为此,我们需要通过在cy.visit()之前添加一个名为cy.intercept()的新命令来调整我们的测试,如清单 9-3 所示。

context("Billing app", () => {
 describe("Invoice creation", () => {
   it("can create a new invoice", () => {
     cy.intercept("GET", "/billing/api/clients", {
       statusCode: 200,
       body: [
         {
           id: 1,
           name: "Juliana",
           email: "juliana@acme.io",
         },
       ],
     });

     cy.visit("http://localhost:8080/");
     cy.get("form").within(() => {
       cy.get("select").select(
         "Juliana - juliana@acme.io"
       );
     });
   });
 });
});

Listing 9-3cypress/integration/Billing.spec.js - Adding Network Interception to the Test

从这个片段中,我们可以看到cy.intercept()需要:

  • 要拦截的 HTTP 方法

  • 要拦截的路径

  • 用作响应存根的对象

在这个测试中,我们拦截来自 Vue 组件的网络请求,在它到达后端之前停止它,并使用静态响应体进行回复。通过这样做,我们可以完全避免接触后端。现在,为了进行测试,我们可以运行测试套件。从我们安装 Cypress 的decoupled-dj文件夹中,我们用下面的命令运行测试运行程序:

./node_modules/.bin/cypress open

Note

为了方便起见,最好在package.json中创建一个e2e脚本作为cypress open的别名。

这将打开一个新窗口,我们可以从中选择运行哪个测试,如图 9-2 所示。

img/505838_1_En_9_Fig2_HTML.jpg

图 9-2

Cypress 欢迎页面

通过点击规格文件Billing.spec.js,我们可以运行测试,但在此之前,我们需要启动 Vue.js 应用。从另一个终端,进入billing/vue_spa并运行以下命令:

npm run serve

一旦完成,我们就可以重新加载测试,让 Cypress 来完成这项工作。测试运行人员将检查测试块中的每个命令,就像真实用户一样。当测试结束时,我们应该看到所有的绿色,这是测试通过的标志。图 9-3 显示了测试窗口。

img/505838_1_En_9_Fig3_HTML.jpg

图 9-3

第一次通过测试

Cypress 中的网络拦截对于没有后端的工作来说确实很方便。后端团队可以通过文档、实际的 JavaScript 对象或 JSON 装置与前端团队共享预期的 API 请求和响应。另一方面,前端开发人员可以构建 UI,而不必在本地运行 Django。在下一节中,我们通过测试表单输入来完成表单的测试。

填写并提交表格

为了提交表单,Cypress 需要填写所有必填字段。

为此,我们采用了一组用于表单交互的 Cypress 方法:

  • type()在输入栏中键入

  • submit()触发我们表单上的submit事件

使用type(),我们不仅可以输入表单域,还可以与日期输入交互。这非常方便,因为我们的表单有两个类型为date的输入。例如,要用 Cypress 选择并键入一个date输入,我们可以使用下面的命令:

cy.get("input[name=date]").type("2021-03-15");

在这里,我们用合适的选择器定位输入,并使用type()填充字段。这种方法适用于任何形式的输入。对于文本输入,这是一个瞄准 CSS 选择器并输入的问题。当页面上存在两个或更多相同类型的输入时,Cypress 需要知道哪一个是目标。如果我们只对页面上的第一个元素感兴趣,我们可以使用以下说明:

cy.get("input[type=number]").first().type("1");

这里我们告诉 Cypress 只选择页面上的第一个输入数字。如果我们想与两个或更多同类元素交互呢?作为一个快速的解决方法,我们可以使用.eq()通过索引来定位元素。索引从0开始,很像 JavaScript 数组:

cy.get("input[type=number]").eq(0).type("1");
cy.get("input[type=number]").eq(1).type("600.00");

在这个例子中,我们指示 Cypress 将页面上类型为number的两个输入作为目标。有了这些知识,并着眼于我们的应用的 HTML 表单结构,我们可以将清单 9-4 中所示的代码添加到我们之前的测试中。

...
     cy.get("form").within(() => {
       cy.get("select").select(
         "Juliana - juliana@acme.io"
       );

       cy.get("input[name=date]").type("2021-03-15");
       cy.get("input[name=due_date]").type("2021-03-30");
       cy.get("input[type=number]").eq(0).type("1");
       cy.get("input[name=description]").type(
         "Django consulting"
       );
       cy.get("input[type=number]").eq(1).type("5000.00");
     });

     cy.get("form").submit();
...

Listing 9-4cypress/integration/Billing.spec.js - Filling the Form with Cypress

在这里,我们填写所有必需的输入、两个日期、发票描述和价格。最后,我们提交表单。虽然这个测试通过了,但是 Vue.js 并不高兴,因为它不能将POST请求路由到/billing/api/invoices/。在控制台中,我们可以看到以下错误:

Proxy error: Could not proxy request /billing/api/invoices/ from localhost:8080 to http://localhost:8000

这是 Cypress 拦截可以提供帮助的另一种情况。在提交表单之前,我们再声明一次拦截,这次是针对/billing/api/invoices。还有,我们断言 API 调用是前端触发的;参见清单 9-5 。

...
     cy.intercept("POST", "/billing/api/invoices", {
       statusCode: 201,
       body: {},
     }).as("createInvoice");

     cy.get("form").submit();
     cy.wait("@createInvoice");
...

Listing 9-5cypress/integration/Billing.spec.js - Adding Another Network Interception to the Test

这里的新指令是as()cy.wait()。有了as(),我们可以别名 Cypress 选择,在这种情况下,也是我们的网络拦截。相反,使用cy.wait(),我们可以等待网络调用发生,并有效地测试前端正在对后端进行实际的 API 调用。有了这个测试,我们可以再次运行 Cypress,它现在应该给我们所有的绿色,如图 9-4 所示。

img/505838_1_En_9_Fig4_HTML.jpg

图 9-4

我们发票表单的完整测试套件

这就结束了我们的应用面向客户端的功能测试。虽然范围有限,但这个测试有助于说明 Cypress 的基本原理。到目前为止,我们编写的测试以 Vue.js 为目标,没有考虑 Django。为了使我们的功能测试尽可能接近真实世界,我们还需要测试从 Django 内部提供的 JavaScript 前端。这是在本章末尾留给用户的一个练习。现在让我们来关注一下后端。我们的 REST API 也需要测试。

单元测试简介

与功能测试相反,单元测试旨在确保单个代码单元(如函数或类)按预期工作。

在这一章中,我们不讨论 JavaScript 的单元测试,因为我们已经看到了 Cypress 的功能测试,而要正确地解决 React 和 Vue.js 的单元测试,另一章是不够的。相反,我们将看到如何对 Django 后端应用单元测试。从用户的角度来看,功能测试是检查 UI 功能的无价工具。相反,单元测试确保我们的 Django 后端和它的 REST API 为我们的 JavaScript 前端提供正确的数据。功能测试和单元测试并不相互排斥。一个项目应该具备这两种类型的测试,才能被认为是健壮的和对变化有弹性的。在下一节中,我们将看到如何用 Django 测试工具测试 Django REST 框架。

Django REST 框架中的单元测试

开箱即用,Django 使得从一开始就拥有优秀的代码覆盖率成为可能。代码覆盖率是测试覆盖了多少代码的度量。Django 是一个包含电池的框架,它带有一套强大的工具,如 API 视图和一个奇妙的 ORM,这些工具已经由 Django 贡献者和核心开发人员进行了测试。然而,这些测试还不够。

在构建项目时,我们需要确保视图、模型、序列化器和任何定制的 Python 类或函数都经过了正确的测试。幸运的是,Django 为我们提供了一套方便的单元和集成测试工具,比如TestCase类。Django REST 框架在此基础上添加了一些定制工具,包括:

  • APISimpleTestCase用于测试没有数据库支持的 API

  • 用于测试 API 和数据库支持

为 DRF 视图编写单元测试与为传统的 Django 视图编写测试没有太大的不同。清单 9-6 中的例子说明了入门的最小测试结构。

from rest_framework.test import APITestCase
from rest_framework.status import HTTP_403_FORBIDDEN
from django.urls import reverse

class TestBillingAPI(APITestCase):
   @classmethod
   def setUpTestData(cls):
       pass

   def test_anon_cannot_list_clients(self):
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

Listing 9-6Django REST Test Example

在这个例子中,我们子类化APITestCase来声明一个新的测试套件。在这个类中,我们可以看到一个名为setUpTestData()的类方法,它对我们的测试初始化数据很有用。接下来,我们将第一个测试声明为类方法:test_anon_cannot_list_clients()是我们的第一个测试。在这个块中,我们用测试 HTTP 客户端self.client.get()调用 API 视图。然后,我们检查从视图中得到的响应代码是否是我们所期望的,在本例中是一个403 Forbidden,因为用户没有经过身份验证。在接下来的小节中,我们将按照示例的结构为 REST 视图编写测试。

用于测试的 Django 设置

在开始之前,让我们配置 Django 项目进行测试。通常,我们需要在测试中稍微改变一些设置,因此为测试环境创建一个分离设置是很方便的。为此,在decoupled_dj/settings/testing.py中创建一个新文件,内容如清单 9-7 所示。

from .base import *  # noqa

Listing 9-7decoupled_dj/settings/testing.py - Split Settings for Testing

到目前为止,这个文件除了导入基本设置之外没有做任何事情,但是这确保了我们可以在需要时覆盖任何配置。

安装依赖项并配置测试要求

我们现在准备好安装用于测试的依赖项了。

对于我们的项目,我们将使用两个方便的库:pytestpytest-django。一起使用它们可以简化我们运行测试的方式。例如,当与pytest-django一起使用时,pytest可以自动发现我们的测试,所以我们不需要添加导入到我们的__init__.py文件中。我们还将使用model-bakery,它可以减轻我们在测试中创建模型的负担。要安装这些库,请运行以下命令:

pip install pytest pytest-django model-bakery

接下来,在requirements/testing.txt中创建一个测试需求文件,并添加清单 9-8 中所示的行。

-r ./base.txt
model-bakery==1.2.1
pytest==6.2.2
pytest-django==4.1.0

Listing 9-8requirements/testing.txt - Requirements for Testing

我们的设置到此结束。我们现在准备好编写测试了!

概述 Billing REST API 的测试

在编写测试时,理解项目中要测试什么是最具挑战性的任务,尤其是对初学者而言。

很容易迷失在测试实现细节和内部代码中,但实际上,它不应该那么复杂。当决定测试什么时,您需要关注一件事:系统的预期输出。在我们的 Django 应用中,我们公开了 REST 端点。这意味着我们需要看看这个系统是如何使用的,并相应地测试这些界限。在识别了系统的表面之后,对内部逻辑的测试自然就会到来。现在让我们看看我们的计费应用需要测试什么。第五章中的 Vue 前端调用以下端点:

  • /billing/api/clients/

  • /billing/api/invoices/

顺便说一句,这些就是我们和cy.intercept()在柏树上撞毁的相同的终点。这次我们需要用 Django 中的单元测试来覆盖它们,而不是用 Cypress 进行功能测试。但是让我们退后一步,想想我们的测试。在第六章中,我们在 REST API 中添加了认证和权限检查。只有经过身份验证的管理员用户才能调用 API。这意味着我们需要考虑认证,并测试我们没有忘记通过允许匿名用户潜入我们的 API 来实施认证。凭直觉,我们需要编写以下测试:

  • 作为匿名用户,我无法访问客户端列表

  • 作为管理员用户,我可以访问客户端列表

  • 作为匿名用户,我不能创建新发票

  • 作为管理员用户,我可以创建新的发票

让我们在下一节编写这些测试。

测试计费 REST API

首先,在billing中创建一个名为tests的新 Python 包。

在这个文件夹中创建一个名为test_api.py的新文件。在这个文件中,我们将放置我们的测试类,其结构与我们在前一个例子中看到的相同。我们还将所有的测试方法添加到我们的类中,如前一节所述。清单 9-9 展示了这个测试的主干。

from rest_framework.test import APITestCase
from rest_framework.status import HTTP_403_FORBIDDEN, HTTP_200_OK, HTTP_201_CREATED
from django.urls import reverse

class TestBillingAPI(APITestCase):
   @classmethod
   def setUpTestData(cls):
       pass

   def test_anon_cannot_list_clients(self):
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

   def test_admin_can_list_clients(self):
       # TODO: authenticate as admin
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_200_OK)

   def test_anon_cannot_create_invoice(self):
       response = self.client.post(
           reverse("billing:invoice-create"), data={}, format="json"
       )
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

Listing 9-9billing/tests/test_api.py - Test Case for the Billing API

这项测试还远未完成。对匿名用户的测试看起来很好,但是我们不能说对管理员也是如此,因为我们在测试中还没有被认证。为了在我们的测试中创建一个 admin 用户(Django 的 staff 用户),我们可以使用来自setUpTestData()model-bakerybaker(),然后在测试客户端使用force_login()方法,如清单 9-10 所示。

from rest_framework.test import APITestCase
from rest_framework.status import HTTP_403_FORBIDDEN, HTTP_200_OK, HTTP_201_CREATED
from django.urls import reverse
from model_bakery import baker

class TestBillingAPI(APITestCase):
   @classmethod
   def setUpTestData(cls):
       cls.admin = baker.make("users.User", is_staff=True)

   def test_anon_cannot_list_clients(self):
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

   def test_admin_can_list_clients(self):
       self.client.force_login(self.admin)
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_200_OK)

   def test_anon_cannot_create_invoice(self):
       response = self.client.post(
           reverse("billing:invoice-create"), data={}, format="json"
       )
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

Listing 9-10billing/tests/test_api.py - Authenticating as an Admin in Our Test

有了这个测试,我们现在就可以开始测试了。在终端中,运行以下命令将 Django 切换到测试环境:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.testing

然后,运行 pytest:

pytest

如果一切顺利,我们应该会在控制台中看到以下输出:

billing/tests/test_api.py ... [100%]
============= 3 passed in 0.94s ==========

我们的测试通过了!我们现在可以向我们的测试添加最后一个案例:作为一个管理员用户,我可以创建一个新的发票。为此,我们在类中创建了一个新方法。在这个方法中,我们以管理员身份登录,通过提供请求体向 API 发出一个POST请求。我们不要忘记,为了创建发票,我们还必须传递一个项目行列表。这可以在请求体中完成。下面的清单显示了完整的测试方法,其中我们还在请求体之前创建了一个用户。这个用户后来与发票相关联,如清单 9-11 所示。

...
def test_admin_can_create_invoice(self):
   self.client.force_login(self.admin)
   user = baker.make("users.User")
   data = {
       "user": user.pk,
       "date": "2021-03-15",i
       "due_date": "2021-03-30",
       "items": [
           {
               "quantity": 1,
               "description": "Django consulting",
               "price": 5000.00,
               "taxed": True,
           }
       ],
   }
   response = self.client.post(
       reverse("billing:invoice-create"), data, format="json"
   )
   self.assertEqual(response.status_code, HTTP_201_CREATED)
...

Listing 9-11billing/tests/test_api.py - Testing Invoice Creation as an Admin

我们对计费应用 REST API 的单元测试到此结束。除了功能测试,我们还涵盖了后端和前端之间的所有通信。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_09_testing 找到本章的源代码。

Exercise 9-1: Testing Django and Vue.Js

我们对 Cypress 的功能测试没有考虑到 Vue.js 在生产中是从 Django 的角度提供的。到目前为止,我们孤立地测试了 Vue.js 应用。针对为应用服务的 Django 视图编写一个功能测试。

Exercise 9-2: Testing the Blog App

既然您已经学习了如何使用 Cypress 和 Django 构建和编写测试,那么就为 Next.js 应用编写一组功能测试。也为 blog REST API 编写单元测试。

摘要

在识别和覆盖所有可能的极限情况时,测试通常是一种直觉艺术。本章概述了以下工具和技术:

  • 使用 Cypress 进行功能测试

  • 在 Django 使用 DRF 的测试工具进行单元测试

在下一章,我们转移到下一个大话题:认证。

额外资源

十、Django REST 框架中的认证和授权

本章涵盖:

  • 基于令牌的认证和 JWT 简介

  • 针对单页面应用的基于会话的认证

写一本技术书籍意味着从十亿个主题开始,没有足够的空间来容纳所有的内容。

认证是一个庞大的主题,几乎不可能在一章中深入讨论。有太多的场景:移动应用、桌面应用和单页应用。因为这本书更多的是关于单页应用和 JavaScript 与 Django 的结合,所以本章只关注这两个角色之间的交互。在第一部分中,我们讨论基于令牌的认证。在第二部分中,我们求助于一个久经考验的身份验证流程,与单页面应用配对。

Note

本章的其余部分假设您在 repo root decoupled-dj中,Python 虚拟环境是活动的,并且DJANGO_SETTINGS_MODULE被配置为decoupled_dj.settings.development

基于令牌的身份验证和 JWT 简介

在第六章中,我们用 DRF 和 Vue.js 创建了一个伪解耦的 Django 项目

在第七章中,我们通过添加一个最小形式的认证和授权来加强我们项目的安全性。我们看到了如何使用基于会话的认证来保护从 Django 视图提供的单页面应用。在第八章中,我们添加了 Next.js。为了从我们的 Django 项目开始生成博客,我们必须完全禁用认证。这远远不是最佳的,这让我们想到了前端与 Django 后端完全分离的所有情况。在传统设置中,使用 cookies 和基于会话的身份验证没有太多麻烦。然而,当前端和后端在不同的域上时,认证就变得棘手了。此外,由于会话存储在服务器上,前端和后端之间的会话 cookie 和 CSRF cookie 的交换违反了 REST 的无状态特性。出于这个原因,多年来,社区提出了一种基于令牌的认证形式,称为 JSON Web Token

对于非耦合设置中的身份验证,使用 JWT 进行基于令牌的身份验证现在非常流行,尤其是在 JavaScript 社区中。在 Django,JWT 还没有标准化。接下来是对 JWT、基于令牌的身份验证的介绍,以及对它们潜在缺陷的讨论。

基于令牌的认证:好与坏

无论如何,基于令牌的认证并不是一个新概念。

令牌是一个简单的标识符,前端可以与后端交换,以证明它有权读取或写入后端。在最简单的安排中,解耦的前端向后端发送用户名和密码。另一方面,后端验证用户的凭证,如果它们有效,它就向前端发回一个令牌。令牌通常是简单的字母数字字符串,如下例所示:

9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b

当前端想要向 API 上的受保护资源发出请求时,无论是GET还是POST请求,它都必须通过将令牌包含在请求头中来发回这个令牌。Django REST 框架通过TokenAuthentication方案为基于令牌的认证提供了现成的支持。

在安全性方面,这种认证机制远非刀枪不入。首先,通过网络发送凭证,即用户名和密码,即使在 HTTPS 下也不是最好的方法。此外,一旦我们在前端获得一个令牌,我们需要在整个用户会话期间持久化它,有时甚至超过这个时间。为了持久化令牌,大多数开发人员求助于将它保存在localStorage中,这是一个巨大的错误,实际上给应用带来了一系列全新的风险。localStorage易受 XSS 攻击,攻击者在其控制的网页中注入恶意 JavaScript 代码,引诱用户访问该网页,并窃取任何非HttpOnlycookie,以及保存在localStorage中的任何潜在数据。

相反,就这些令牌的功能而言,它们非常简单。它们不携带关于用户的信息,也不指示用户拥有什么权限。它们被严格地绑定到 Django 和它的数据库,只有 Django 可以将一个给定的用户和它的令牌配对。它们的简单性是这些基本令牌的一个“特征”。尽管有这些限制,基于令牌的身份验证在前端和后端位于不同域的所有情况下都工作得很好。然而,多年来,JavaScript 社区一直在思考创造更多结构化标记的机会。这导致了名为 JSON Web Tokens 的新标准的诞生,它带来了创新,也带来了更多的挑战。

Django 中的 JSON Web 令牌:优势和挑战

JSON Web Token(简称 JWT)是一种标准,它定义了一种在客户机和服务器之间交换身份验证信息的便捷方式。

JWT 令牌与简单的字母数字令牌完全不同。首先,他们已经签字了。也就是说,它们在通过网络发送出去之前由服务器加密,然后在客户机上解密。这是必要的,因为 JWT 令牌包含敏感信息,如果这些令牌被盗,可用于针对受保护的资源进行身份验证。JWT 在 JavaScript/Node.js 社区拥有稳固的市场份额。

相反,在 Django 场景中,它们被认为是一种不安全的身份验证方法。其原因是服务器端的 JWT 实现很难正确,规范中有太多的东西留给实现者去解释,这可能会在不知道的情况下构建一个不安全的 JWT 服务器。要了解更多关于 JWT 的所有安全含义,请查看附加资源中的第一个链接。简而言之,到今天为止,Django 没有对 JWT 的核心支持,这种情况在未来也不会改变。

如果你想在你自己的 Django 项目中使用 JWT,有很多库可以使用,比如django-rest-framework-simplejwt。该库不处理注册流程,而只处理 JWT 的发行阶段。换句话说,从前端我们可以使用用户名和密码的api/token/api/token/refresh/来请求新的令牌或者刷新令牌,如果我们手中有令牌的话。当客户端向服务器请求令牌时,服务器用两个令牌进行回复:访问令牌和刷新令牌。作为一种安全措施,访问令牌通常有一个截止日期。另一方面,当新的访问令牌过期时,客户端使用刷新令牌来请求新的访问令牌。访问令牌用于认证,刷新令牌用于请求新的认证令牌。因为这两个令牌同等重要,所以必须在客户端对它们进行充分保护。

与任何令牌一样,JWT 令牌也经常会遇到同样的问题。大多数开发人员在localStorage中保留 JWT 令牌,这容易受到 XSS 的攻击。这可能比保存一个简单的字母数字标记更糟糕,因为 JWT 在它的主体中携带了更多的敏感信息,即使它是加密的,我们也不能放松对它的保护。为了避免这些风险,开发人员求助于在HttpOnlycookie 中保存 JWT 令牌,巧合的是,这听起来很像最经典的基于会话的认证方法。最后,即使 JWT 令牌便于跨域和移动认证,维护这样的基础设施也可能很困难,并且容易出现安全风险。对于 Django 和单页面应用,有没有简单的认证方法?我们将在下一节探讨这个问题。

针对单页应用的基于会话的认证

最后,Django 项目的认证不应该很复杂,至少对于 web 应用是这样。

事实上,在 NGINX 的帮助下,我们可以使用基于会话的认证来代替令牌,即使是单页应用。在第七章中,我们用传统设置部署了 Django 应用,这是一个为单页面应用服务的 Django 模板。如果我们现在把事情颠倒过来,把一个单页面应用作为 Django 项目的主要入口,会怎么样?在这发生之前,我们需要考虑几个步骤。尤其是,NGINX 应该:

  • 从根位置块提供单页应用

  • 对 Django 的代理 API、auth 和 admin 请求

为此,我们需要对第七章的配置进行必要的调整。让我们看看在接下来的部分需要做什么。

Note

我们将要看到的配置完全独立于第七章中的配置。这是两种不同的方法,都是有效的。

关于生产和发展的一些话

除非您使用 Docker 或虚拟机,否则以下部分提供的场景不容易在本地工作站上复制。

为了尽可能接近现实,我们提供了一个生产环境,其中应用部署在 https://decoupled-django.com/ ,带有有效的 SSL 证书。如果您想要复制相同的环境,您有两种选择:

如果你选择第二种,这里有一些建议:

  • 在 VirtualBox 实例中,将一个 SSH 端口和另一个 web 服务器端口从客户机转发到主机。对于 SSH,您可以为客户机选择 8022,转发到主机上的 22,对于 web 服务器,选择从客户机转发到主机的端口 80。

  • 在主工作站的/etc/hosts文件中,配置 decoupled-django.com 域和 static.decoupled-django.com 子域指向127.0.0.1

虚拟机就绪后,从您的工作站使用以下命令运行 Ansible 行动手册:

ansible-playbook -i deployment/inventory deployment/site.yml --extra-vars "trustme=yes"

这个剧本将配置环境,部署代码,并为 decoupled-django.comstatic.decoupled-django.com 创建一个假的 SSL 证书。一旦完成,在为证书添加例外后,您可以在浏览器中访问 https://decoupled-django.com/

Note

如何运行行动手册的说明可在 https://github.com/valentinogagliardi/decoupled-dj/blob/chapter_10_authentication/README.md#deployment 找到。

为新设置准备 NGINX

作为第一步,我们需要配置 NGINX 来服务根location块上的单页面应用。

清单 10-1 显示了第一个变化。

...
location / {
   alias /home/{{ user }}/code/billing/vue_spa/dist/;
}
...

Listing 10-1deployment/templates/decoupled-django.​com.j2 - NGINX Configuration to Serve the Single-Page Application

这与我们在第七章中看到的不同,在第七章中,项目的主要入口是 Gunicorn。在本例中,我们重用了第六章中的 Vue.js 单页应用,这是一个创建发票的简单表单,但是为了进行测试,我们将它提升为我们项目的主单页应用。这里我们对 NGINX 说,当一个用户访问我们网站的根目录时,发送到/home/decoupled-django/code/billing/vue_spa/dist/中的 Vue.js app。这里的dist是什么?默认情况下,Vue CLI 在 Vue.js 项目的dist文件夹中构建生产 JS 包。这是默认配置,但是在第六章中,我们对其进行了修改,以在静态文件中向 Django 期望的地方发出包。现在我们回到默认值。为了实现这一点,我们还需要稍微调整一下 Vue.js。有了这个配置,通过访问生产中的 https://decoupled-django.com/ ,NGINX 将服务于单页 app。然而,Vue.js 一加载,它就调用billing/api/clients/来获取<select>的客户列表。这导致我们再次调整 NGINX 的配置,以便任何对/api/的请求都被代理到 Gunicorn,从而被代理到 Django。清单 10-2 显示了额外的 NGINX 块。

location ~* /api/ {
   proxy_pass http://gunicorn;
   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_set_header X-Forwarded-Proto $scheme;
}

Listing 10-2deployment/templates/decoupled-django.com.j2 - NGINX Configuration to Proxy API Requests to Django

有了这个改变,API 调用将真正到达 Django。还缺少一个细节:身份验证。这种设置改变了一切。Django 不再负责为单页面应用提供服务,但它确实应该为 API 和登录流提供服务,这是有道理的——下一节将详细介绍。

用 Django 处理登录流

我们希望使用一个单页面应用作为 Django 项目的主要入口点,但是我们还希望使用来自 Django 的基于会话的身份验证。

这就是我们遇到难题的地方。我们如何在不涉及令牌的情况下认证我们的用户?Django 有一个内置的认证系统,是contrib模块的一部分,从中我们可以看到一组处理最常见的认证流程的视图:登录/注销、注册/确认和密码重置。例如,django.contrib.auth.viewsLoginView可以帮助登录流程。然而,我们当前设置的问题是,单页面应用现在已经与 Django 项目完全分离了。

作为一种幼稚的方法,我们可以尝试从 JavaScript 向 Django LoginView发出一个POST请求,但是这些视图受到 CSRF 检查的保护。这也是我们之前遇到的问题,但是现在问题更严重了,因为我们没有任何 Django 视图可以在发出请求之前获取 CSRF 令牌。解决方案?我们可以让 Django 处理认证流程。为此,我们将为认证逻辑创建一个独立的 Django 应用。在根项目文件夹中,运行以下命令:

python manage.py startapp login

接下来,在login/urls.py中创建一个新的 URL 配置,并将清单 10-3 中所示的代码放入其中。

from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView

app_name = "auth"

urlpatterns = [
   path(
       "login/",
       LoginView.as_view(
           template_name="login/login.html",
           redirect_authenticated_user=True
       ),
       name="login",
   ),
   path("logout/", LogoutView.as_view(), name="logout"),
]

Listing 10-3login/urls.py - URL Configuration for Login and Logout Views

这里我们声明了两个路由,一个用于登录,另一个用于注销。LoginView使用自定义的template_name。在login/templates/login/login.html中创建模板,如清单 10-4 所示。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>Login</title>
</head>
<body>
<form method="POST" action="{% url "auth:login" %}">
   {% csrf_token %}
   <div>
       <label for="{{ form.username.id_for_label }}">Username:</label>
       <input type="text" name="username" autofocus maxlength="254" required id="id_username">
   </div>
   <div>
       <label for="{{ form.password.id_for_password }}">Password:</label>
       <input type="password" name="password" autocomplete="current-password" required="" id="id_password">
   </div>
   <input type="hidden" name="next" value="{{ next }}">
   <button type="submit" value="login">
       LOGIN
   </button>
</form>
{{ form.non_field_errors }}
</body>
</html>

Listing 10-4login/templates/login/login.html - Login Form

这是一个简单的 HTML 表单,增加了 Django 模板标签;具体包括{% csrf_token %}。当表单被呈现时,Django 在标记中放置一个隐藏的 HTML 输入,如清单 10-5 所示。

<input type="hidden" name="csrfmiddlewaretoken" value="2TYg60oC0GC2LW7oJEPwBsg2ajZsjJ0n5Wvjqd28J9wMcGBanbnNfkmfT5Qw3juK">

Listing 10-5Django’s CSRF Token in HTML Forms

这个输入的值和POST请求一起发送给 Django LoginView。如果用户的凭证有效,Django 将用户重定向到选择的 URL,并向浏览器发送两个 cookie:csrftokensessionid。为此,我们需要加载登录应用并在decoupled_dj/settings/base.py中配置重定向 URL,如清单 10-6 所示。

INSTALLED_APPS = [
   ...
   "login"
]

...

LOGIN_REDIRECT_URL = "/"

Listing 10-6decoupled_dj/settings/base.py - Enabling the Login App and Configuring the Login Redirect URL

一旦完成,在根配置中包含新的 URL,decoupled_dj/urls.py,如清单 10-7 所示。

urlpatterns = [
   ...
   path("auth/", include("login.urls", namespace="auth")),
]

Listing 10-7decoupled_dj/urls.py - Including the URL from the Login App

最后一步,我们告诉 NGINX 任何对/auth/的请求都必须被代理给 Django,如清单 10-8 所示。

location /auth/ {
   proxy_pass http://gunicorn;
   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_set_header X-Forwarded-Proto $scheme;
}

Listing 10-8deployment/templates/decoupled-django.com.j2 - NGINX Configuration to Proxy Authentication Requests to Django

我们用这种设置实现了什么?NGINX 现回复如下:

在这种安排中,Django 通过基于会话的认证来处理整个认证流程。另一方面,单页面应用只对 Django 进行 API 调用。在这方面,我们需要修复 Vue.js,以便与新的设置一起工作。

Note

你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_10_authentication/deployment/templates/ 找到 NGINX 配置的源代码。

为新设置准备 Vue.js 应用

概括一下,在第六章中,我们配置了vue.config.js.env.staging来让 Django 静态文件与 Vue.js 一起工作

在第七章中,我们添加了另一块拼图,通过配置.env.production使 Vue.js 能够识别它被加载的子域。在本章中,我们可以去掉那些配置。配置文件vue.config.js.env.staging.env.production可以从billing/vue_spa/中移除。通过这样做,当构建产品包时,JavaScript 文件和资产将放在dist文件夹中。这个文件夹通常被排除在源代码控制之外,所以我们需要在目标机器上安装 Node.js 来安装 JavaScript 依赖项,并从/home/decoupled-django/code/billing/vue_spa开始构建捆绑包。一旦完成,我们就可以运行我们的 Vue.js 应用作为 Django 项目的主要入口。

Note

位于 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_10_authentication 的 Ansible playbook 负责安装 Node.js 并构建包。

这种设置的效果是 JavaScript 前端会将 cookies 传递给 Django,而无需我们的任何干预。图 10-1 显示csrftokensessionid随着GET的请求行进至/billing/api/clients

img/505838_1_En_10_Fig1_HTML.jpg

图 10-1

从 JavaScript 到 Django 的 GET 请求包括会话 cookie 和 CSRF 令牌

图 10-2 显示了相同的 cookies,这次是通过POST请求传输的。

img/505838_1_En_10_Fig2_HTML.jpg

图 10-2

从 JavaScript 到 Django 的 POST 请求包括会话 cookie 和 CSRF 令牌

在这个设置中没有任何神奇之处:cookies 可以在同一个源上传播,甚至超过 Fetch。

关于 HttpOnly Cookies 的说明

cookie 是一种不能从 JavaScript 代码中读取的 cookie。

默认情况下,Django 已经确保了sessionid具有HttpOnly属性。这不会中断与fetch的 cookie 交换,因为same-origin的默认行为确保了当调用的 JavaScript 代码与目标 URL 具有相同的来源时,cookie 会被来回发送。至于csrftoken,我们需要让 JavaScript 可以访问它,因为我们将它作为一个头包含在不安全的 HTTP 请求(POST之类的)旁边。

在前端处理认证

既然我们已经配置了 NGINX 来将请求代理到适当的目的地,并且 Django 后端已经准备好处理登录请求,我们就可以在前端处理身份验证了。

好消息是,我们不会手工编写身份验证表单、发送令牌或将其保存在localStorage中。然而,我们需要找到一种绕过HttpOnlycookie 的方法,因为我们不能再从 JavaScript 访问sessionid。常见的做法是通过查看 cookies 来检查用户是否通过了 JavaScript 代码的身份验证。有了sessionid这个HttpOnly饼干,我们就没有这种奢侈了(放松这种保护也不是一个选项)。一个可能的解决方案隐藏在来自 REST API 的错误消息中。任何对 DRF 的未经验证的请求实际上都返回Authentication credentials were not provided,以及一个403 Forbidden错误。在前端,我们可以检查这个信号,然后将用户重定向到/auth/login/。我们开billing/vue_spa/src/App.vue吧。这是我们的 Vue.js 应用的根组件。在这个组件中,我们可以在将用户重定向到登录视图之前检查用户是否通过了身份验证。首先,在模板部分,我们只在通过检查组件状态中的布尔值来验证用户时才呈现InvoiceCreate。清单 10-9 显示了<template>部分的变化。

<template>
 <div id="app">
   <div v-if="isLoggedIn">
     <InvoiceCreate />
   </div>
   <div v-else></div>
 </div>
</template>

Listing 10-9billing/vue_spa/src/App.vue - Checking if the User Is Logged In

在组件的脚本部分,我们组装了以下逻辑:

  • mounted()中,我们调用一个端点

  • 如果我们得到一个200,我们就认为用户通过了身份验证

  • 如果我们得到了一个Forbidden,我们就从 Django REST 框架中检查错误的确切类型

清单 10-10 显示了<script>部分的变化。

<template>
 <div id="app">
   <div v-if="isLoggedIn">
     <InvoiceCreate />
   </div>
   <div v-else></div>
 </div>
</template>

<script>
import InvoiceCreate from "@/components/InvoiceCreate";

export default {
 name: "App",
 components: {
   InvoiceCreate
 },
 data: function() {
   return {
     isLoggedIn: false
   };
 },
 methods: {
   redirectToLogin: function() {
     this.isLoggedIn = false;
     window.location.href = "/auth/login/";
   }
 },
 mounted() {
   fetch("/billing/api/clients/")
     .then(response => {
       if (
         !response.ok &&
         response.statusText === "Forbidden"
       ) {
         return response.json();
       }
       this.isLoggedIn = true;
     })
     .then(drfError => {
       switch (drfError?.detail) {
         case "Authentication credentials were not provided.":
           this.redirectToLogin();
           break;
         default:
           break;
       }
     });
 }
};
</script>

Listing 10-10billing/vue_spa/src/App.vue - Handling Authentication in the Frontend

在这段代码中,我们向选择的端点发出一个 AJAX 请求。如果请求返回一个Forbidden,我们用一个简单的switch语句检查 Django REST 框架给出了什么样的错误。我们可能要检查的第一个错误消息是Authentication credentials were not provided.,这是一个明显的信号,表明我们试图在没有凭证的情况下访问受保护的资源。如果您担心通过字符串的方式检查身份验证或权限看起来不太好,因为 Django 迟早会更改错误消息并返回一个意外的字符串,根据我的经验,前端和后端开发人员之间总是有某种契约来商定他们可以从对方那里得到哪些响应体或错误消息。如果需要考虑字符串,可以很容易地将其抽象成常量。这还不算前端和后端必须始终置于强大的测试套件之下。

Note

在这个例子中,我们使用fetch()来避免引入额外的依赖关系。另一个有效的选择是axios,它有一个方便的拦截器特性。

有了这个逻辑,我们可以添加更多的检查,比如权限,我们将在下一节中看到。这不是最聪明的实现,但它完成了工作,更重要的是,它使用了一种久经考验的身份验证方法。React 也可以使用同样的方法:我们可以从 NGINX 提供单页面应用,Django 隐藏在后台。值得注意的是,只有当 Django 和单页面在同一个域中提供服务时,这种设置才有效。使用 NGINX 和 Docker 很容易实现这一点。对于客户端位于不同域的所有配置,都需要基于令牌的身份验证。有了身份验证部分,现在让我们探索 Django REST 框架中的授权。

Note

在前面的例子中,我们使用了window.location来重定向用户。如果使用 Vue 路由器,代码必须调整使用this.$router.push()

Django REST 框架中的授权和许可

一旦用户登录,我们就处于流程的中间。

认证是整个故事中“你是谁”的一部分。接下来是“你能做什么”的部分。在第七章中,我们通过只允许管理员用户访问来锁定我们的 API。清单 10-11 显示了decoupled_dj/settings/base.py中应用的配置。

REST_FRAMEWORK = {
   "DEFAULT_AUTHENTICATION_CLASSES": [
       "rest_framework.authentication.SessionAuthentication",
   ],
   "DEFAULT_PERMISSION_CLASSES": [
       "rest_framework.permissions.IsAdminUser"
   ],
}

Listing 10-11decoupled_dj/setting/base.py - Adding Permissions Globally in the DRF

为了在前端进行测试,我们可以在 Django 项目中创建一个无特权用户。打开 Django shell 并运行下面的 ORM 指令:

User.objects.create_user(username="regular-user", password="insecure-pass")

这将在数据库中创建一个新用户。如果我们试图在auth/login/用这个用户登录,Django 会像预期的那样重定向回主页,但是一旦我们登陆到那里,我们就不会在界面上看到任何东西。这是因为我们的 JavaScript 前端不能处理 Django REST 框架用You do not have permission to perform this action响应的情况。我们可以在调用billing/api/clients的浏览器控制台的网络标签中看到这个错误。通过 DRF 权限,我们可以让用户访问 REST 视图。权限不仅可以在配置级别设置,还可以在每个视图上设置粒度。为了允许经过身份验证的用户访问,而不仅仅是管理员访问billing/api/clients,我们可以使用IsAuthenticated权限类。要应用该权限,打开billing/api/views.py并调整代码,如清单 10-12 所示。

...
from rest_framework.permissions import IsAuthenticated

class ClientList(ListAPIView):
   permission_classes = [IsAuthenticated]

   serializer_class = UserSerializer
   queryset = User.objects.all()
...

Listing 10-12billing/api/views.py - Applying Permissions on the View Level

通过这一更改,任何经过身份验证的用户都可以访问该视图。在前端,我们可以通过在switch语句中添加另一个检查来处理权限错误,它在来自 API 的响应中寻找You do not have permission to perform this action,并向我们的用户显示一条用户友好的消息。当然,许可的故事并没有到此为止。在 Django REST 框架中,我们可以定制权限,在对象级别授予权限,等等。文档几乎涵盖了所有可能的用例。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_10_authentication 找到本章的源代码。

摘要

您从本章中学到了一些重要的要点:

  • 切勿在localStorage中存储令牌或其他敏感数据

  • 尽可能使用基于会话的身份验证来保护单页面应用

在下一章中,我们将从 Ariadne 开始探索 Django 中的 GraphQL。

额外资源

  • django 的 jwts