Python 全栈安全(二)

发布于:2024-04-26 ⋅ 阅读:(18) ⋅ 点赞:(0)

原文:annas-archive.org/md5/712ab41a4ed6036d0e8214d788514d6b

译者:飞龙

协议:CC BY-NC-SA 4.0

第二部分:认证与授权

本书的第二部分是最具商业价值的部分。我这样说是因为它充满了大多数系统需要具备的实用工作流示例:注册和验证用户、管理用户会话、更改和重置密码、管理权限和组成员、以及共享资源。本书的这部分主要关注的是安全地完成工作。

第七章:HTTP 会话管理

本章涵盖

  • 理解 HTTP cookie

  • 在 Django 中配置 HTTP 会话

  • 选择一个 HTTP 会话状态持久化策略

  • 防止远程代码执行攻击和重放攻击

在上一章中,你学习了有关 TLS 的知识。在本章中,你将在此基础上继续学习。你将了解如何使用 cookie 实现 HTTP 会话。你还将学习如何在 Django 中配置 HTTP 会话。在此过程中,我将向你展示如何安全地实现会话状态持久化。最后,你将学习如何识别和抵抗远程代码执行攻击和重放攻击。

7.1 什么是 HTTP 会话?

HTTP 会话 对于除了最简单的 Web 应用程序之外的所有应用程序都是必需的。Web 应用程序使用 HTTP 会话来隔离每个用户的流量、上下文和状态。这是每种在线交易的基础。如果你在亚马逊购物,Facebook 上与某人通信,或者从银行转账,服务器必须能够在多个请求中识别你。

假设 Alice 第一次访问维基百科。Alice 的浏览器对维基百科不熟悉,因此它创建了一个会话。维基百科生成并存储了此会话的 ID。该 ID 在 HTTP 响应中发送给 Alice 的浏览器。Alice 的浏览器保存会话 ID,并在所有后续请求中将其发送回维基百科。当维基百科接收到每个请求时,它使用传入的会话 ID 来识别与请求相关联的会话。

现在假设维基百科为另一个新访客 Bob 创建了一个会话。像 Alice 一样,Bob 被分配了一个唯一的会话 ID。他的浏览器存储了他的会话 ID,并在每个后续请求中发送回来。维基百科现在可以使用会话 ID 来区分 Alice 的流量和 Bob 的流量。图 7.1 说明了这个协议。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7.1 维基百科管理两个用户 Alice 和 Bob 的会话。

Alice 和 Bob 的会话 ID 保持私密非常重要。如果 Eve 窃取了会话 ID,她可以使用它来冒充 Alice 或 Bob。包含 Bob 被劫持的会话 ID 的 Eve 的请求看起来与 Bob 的合法请求没有任何区别。许多利用漏洞,其中一些在本书中专门有章节介绍,都依赖于窃取或未经授权控制会话 ID。这就是为什么会话 ID 应该通过 HTTPS 而不是 HTTP 进行机密发送和接收。

你可能已经注意到一些网站使用 HTTP 与匿名用户通信,使用 HTTPS 与经过身份验证的用户通信。恶意网络窃听者通过尝试在 HTTP 上窃取会话 ID,等待用户登录,然后在 HTTPS 上劫持用户账户来攻击这些网站。这被称为会话嗅探

Django,像许多 Web 应用程序框架一样,通过在用户登录时更改会话标识符来防止会话嗅探。为了保险起见,Django 无论协议是否从 HTTP 升级到 HTTPS 都会这样做。我建议增加一个额外的防御层:只为您的整个网站使用 HTTPS。

管理 HTTP 会话可能是一项挑战;本章涵盖了许多解决方案。每种解决方案都有不同的安全权衡,但它们都有一个共同点:HTTP cookies。

7.2 HTTP cookies

浏览器存储和管理称为cookies的小量文本。一个 cookie 可以由您的浏览器创建,但通常由服务器创建。服务器通过响应将 cookie 发送到您的浏览器。浏览器在随后对服务器的请求中回显 cookie。

网站和浏览器使用 cookies 进行会话 ID 通信。当创建新的用户会话时,服务器将会话 ID 作为 cookie 发送到浏览器。服务器使用Set-Cookie响应头将 cookie 发送到浏览器。此响应头包含表示 cookie 名称和值的键值对。默认情况下,Django 会话 ID 与名为sessionid的 cookie 通信,如以下粗体字所示:

Set-Cookie: sessionid=<cookie-value>

Cookies 通过Cookie请求头在随后的请求中回显到服务器。该头部是一个以分号分隔的键值对列表。每对代表一个 cookie。以下示例说明了发送至 alice.com 的请求的一些头部。粗体显示的Cookie头部包含两个 cookie:

...
Cookie: sessionid=cgqbyjpxaoc5x5mmm9ymcqtsbp7w7cn1; key=value;    # ❶
Host: alice.com
Referer: https:/./alice.com/admin/login/?next=/admin/
...

❶ 向 alice.com 发送两个 cookie

Set-Cookie响应头可以容纳多个指令。当 cookie 是会话 ID 时,这些指令与安全相关。我在第十四章中涵盖了HttpOnly指令。我在第十六章中涵盖了SameSite指令。在本节中,我涵盖了以下三个指令:

  • 安全

  • Max-Age

7.2.1 安全指令

服务器通过使用安全指令发送会话 ID cookie 来抵抗中间人攻击。以下是一个示例响应头,其中安全指令以粗体显示:

Set-Cookie: sessionid=<session-id-value>; Secure

安全指令禁止浏览器通过 HTTP 将 cookie 发送回服务器。这确保 cookie 只会通过 HTTPS 传输,防止网络窃听者拦截会话 ID。

SESSION_COOKIE_SECURE设置是一个布尔值,它向会话 ID Set-Cookie头部添加或删除安全指令。您可能会惊讶地发现,该设置默认为False。这使得新的 Django 应用程序可以立即支持用户会话;这也意味着会话 ID 可能会被中间人攻击拦截。

警告:您必须确保在系统的所有生产部署中将SESSION_COOKIE_SECURE设置为True。Django 不会为您执行此操作。

提示 在更改 settings 模块后,必须重新启动 Django 才能生效。要重新启动 Django,请在你的 shell 中按 Ctrl-C 停止服务器,然后再次使用 gunicorn 启动。

7.2.2 Domain 指令

服务器使用 Domain 指令来控制浏览器应该将会话 ID 发送到哪些主机。下面是一个示例响应头,其中 Domain 指令被加粗显示:

Set-Cookie: sessionid=<session-id-value>; Domain=alice.com

假设 alice.com 向浏览器发送一个不带 Domain 指令的 Set-Cookie 头部。没有 Domain 指令,浏览器会将 cookie 回显给 alice.com,但不会回显给子域名,比如 sub.alice.com。

现在假设 alice.com 发送了一个带有 Domain 指令设置为 alice.comSet-Cookie 头部。现在浏览器将 cookie 回显给 alice.com 和 sub.alice.com。这允许 Alice 在两个系统之间支持 HTTP 会话,但这不够安全。例如,如果 Mallory 黑入 sub.alice.com,她就能更轻松地威胁到 alice.com,因为来自 alice.com 的会话 ID 就这样交给了她。

SESSION_COOKIE_DOMAIN 设置配置了会话 ID 的 Set-Cookie 头部的 Domain 指令。此设置接受两个值:None 和表示域名的字符串,例如 alice.com。此设置默认为 None,省略响应头中的 Domain 指令。以下是一个示例配置设置:

SESSION_COOKIE_DOMAIN = "alice.com"      # ❶

❶ 从 settings.py 配置 Domain 指令

提示 Domain 指令有时会与 SameSite 指令混淆。为了避免这种混淆,请记住这种对比:Domain 指令与 cookie 去向 有关;SameSite 指令与 cookie 来源 有关。我在第十六章中研究了 SameSite 指令。

7.2.3 Max-Age 指令

服务器发送 Max-Age 指令来声明 cookie 的过期时间。以下是一个示例响应头,其中有一个加粗显示的 Max-Age 指令:

Set-Cookie: sessionid=<session-id-value>; Max-Age=1209600

一旦 cookie 过期,浏览器就不会再将其回显到它来自的站点。这种行为可能对你来说很熟悉。你可能已经注意到像 Gmail 这样的网站不会每次你返回时都强制你登录。但如果你有一段时间没有回来,你就会被迫重新登录。很有可能,你的 cookie 和 HTTP 会话已经过期。

选择站点的最佳会话长度归结为安全性与功能之间的权衡。极长的会话提供给攻击者一个易于攻击的目标,当浏览器处于无人看管状态时。另一方面,极短的会话强迫合法用户一遍又一遍地重新登录。

SESSION_COOKIE_AGE 设置配置了会话 ID 的 Set-Cookie 头部的 Max-Age 指令。此设置默认为 1,209,600 秒(两周)。对于大多数系统来说,这个值是合理的,但适当的值是特定于站点的。

7.2.4 浏览器长度的会话

如果设置的 Cookie 没有 Max-Age 指令,浏览器将在选项卡保持打开的时间内保持 Cookie 有效。这被称为浏览器长度会话。这些会话在用户关闭浏览器选项卡后无法被攻击者劫持。这似乎更安全,但是你如何强制每个用户在使用网站完成后关闭每个选项卡呢?此外,当用户不关闭浏览器选项卡时,会话实际上没有到期。因此,浏览器长度的会话会增加总体风险,通常应避免使用此功能。

浏览器长度会话由 SESSION_EXPIRE_AT_BROWSER_CLOSE 设置配置。将此设置为 True 将从会话 ID 的 Set-Cookie 头中删除 Max-Age 指令。Django 默认禁用浏览器长度会话。

7.2.5 以编程方式设置 Cookie

我在本章中涵盖的响应头指令适用于任何 Cookie,而不仅仅是会话 ID。如果您通过编程方式设置 Cookie,则应考虑这些指令以限制风险。以下代码演示了在 Django 中设置自定义 Cookie 时如何使用这些指令。

列表 7.1 在 Django 中以编程方式设置 Cookie

from django.http import HttpResponse

response = HttpResponse()
response.set_cookie(
    'cookie-name',
    'cookie-value',
    secure=True,           # ❶
    domain='alice.com',    # ❷
    max_age=42, )          # ❸

❶ 浏览器将仅通过 HTTPS 发送此 Cookie。

❷ alice.com 和所有子域都将接收到此 Cookie。

❸ 在 42 秒后,此 Cookie 将过期。

到目前为止,您已经学到了有关服务器和 HTTP 客户端如何使用 Cookie 来管理用户会话的很多知识。至少,会话可以区分用户之间的流量。此外,会话还可以作为每个用户管理状态的一种方式。用户的名称、语言环境和时区是会话状态的常见示例。下一节将介绍如何访问和持久化会话状态。

7.3 会话状态持久性

像大多数 Web 框架一样,Django 使用 API 对用户会话进行建模。通过 session 对象,可以访问此 API,该对象是请求的属性。session 对象的行为类似于 Python 字典,通过键存储值。通过此 API 创建、读取、更新和删除会话状态;这些操作在下一个列表中进行演示。

列表 7.2 Django 会话状态访问

request.session['name'] = 'Alice'            # ❶
name = request.session.get('name', 'Bob')    # ❷
request.session['name'] = 'Charlie'          # ❸
del request.session['name']                  # ❹

❶ 创建会话状态条目

❷ 读取会话状态条目

❸ 更新会话状态条目

❹ 删除会话状态条目

Django 自动管理会话状态的持久性。会话状态在收到请求后从可配置的数据源加载和反序列化。如果会话状态在请求生命周期中被修改,Django 在发送响应时序列化并持久化修改。序列化和反序列化的抽象层称为会话序列化器

7.3.1 会话序列化器

Django 将会话状态的序列化和反序列化委托给可配置的组件。该组件由 SESSION_SERIALIZER 设置配置。Django 本地支持两个会话序列化器组件:

  • JSONSerializer,默认会话序列化器

  • PickleSerializer

JSONSerializer 将会话状态转换为 JSON 并从 JSON 转换回来。这种方法允许您将会话状态与基本的 Python 数据类型(如整数、字符串、字典和列表)组合在一起。以下代码使用 JSONSerializer 来序列化和反序列化一个字典,如粗体所示:

>>> from django.contrib.sessions.serializers import JSONSerializer
>>> 
>>> json_serializer = JSONSerializer()
>>> serialized = json_serializer.dumps({'name': 'Bob'})    # ❶
>>> serialized
b'{"name":"Bob"}'                                          # ❷
>>> json_serializer.loads(serialized)                      # ❸
{'name': 'Bob'}                                            # ❹

❶ 序列化一个 Python 字典

❷ 序列化的 JSON

❸ 反序列化 JSON

❹ 反序列化的 Python 字典

PickleSerializer 将会话状态转换为字节流并从字节流转换回来。顾名思义,PickleSerializer 是 Python pickle 模块的包装器。这种方法允许您存储任意 Python 对象以及基本的 Python 数据类型。一个应用���序定义的 Python 对象,如粗体所示,通过以下代码进行序列化和反序列化:

>>> from django.contrib.sessions.serializers import PickleSerializer
>>> 
>>> class Profile:
...     def __init__(self, name):
...         self.name = name
... 
>>> pickle_serializer = PickleSerializer()
>>> serialized = pickle_serializer.dumps(Profile('Bob'))          # ❶
>>> serialized
b'\x80\x05\x95)\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__...'   # ❷
>>> deserialized = pickle_serializer.loads(serialized)            # ❸
>>> deserialized.name                                             # ❹
'Bob'

❶ 序列化一个应用程序定义的对象

❷ 序列化的字节流

❸ 反序列化字节流

❹ 反序列化对象

JSONSerializerPickleSerializer 之间的权衡是安全性与功能性。JSONSerializer 是安全的,但无法序列化任意 Python 对象。PickleSerializer 执行此功能,但存在严重风险。pickle 模块文档给出了以下警告(docs.python.org/3/library/pickle.html):

pickle 模块不安全。只有信任的数据才能反序列化。可能构造恶意 pickle 数据,在反序列化过程中执行任意代码。永远不要反序列化可能来自不受信任来源或可能被篡改的数据。

如果攻击者能够修改会话状态,PickleSerializer 可能会被恶意滥用。我将在本章后面讨论这种形式的攻击;请继续关注。

Django 会自动使用会话引擎持久化序列化的会话状态。会话引擎 是对底层数据源的可配置抽象层。Django 提供了这五个选项,每个选项都有其自己的优缺点:

  • 简单基于缓存的会话

  • 写入缓存的会话

  • 基于数据库的会话,即默认选项

  • 基于文件的会话

  • 签名 cookie 会话

7.3.2 简单基于缓存的会话

简单 基于缓存的会话 允许您将会话状态存储在诸如 Memcached 或 Redis 之类的缓存服务中。缓存服务将数据存储在内存中而不是在磁盘上。这意味着您可以非常快速地存储和加载数据,但偶尔数据可能会丢失。例如,如果缓存服务的空间用完了,它将覆盖最近访问的旧数据以写入新数据。如果缓存服务重新启动,所有数据都会丢失。

缓存服务的最大优势,速度,与会话状态的典型访问模式相辅相成。会话状态经常被读取(在每个请求上)。通过将会话状态存储在内存中,整个站点可以减少延迟,增加吞吐量,同时提供更好的用户体验。

缓存服务的最大弱点,数据丢失,并不像其他用户数据那样适用于会话状态。在最坏的情况下,用户必须重新登录网站,重新创建会话。这是不可取的,但称其为数据丢失有些牵强。因此,会话状态是可以牺牲的,而且缺点是有限的。

存储 Django 会话状态的最流行和最快的方法是将简单基于缓存的会话引擎与 Memcached 等缓存服务结合使用。在settings模块中,将SESSION_ENGINE分配给django.contrib.sessions.backends.cache会配置 Django 用于简单基于缓存的会话。Django 本地支持两种 Memcached 缓存后端类型。

Memcached 后端

MemcachedCachePyLibMCCache是最快和最常用的缓存后端。CACHES设置配置了缓存服务集成。这个设置是一个字典,表示一组单独的缓存后端。第 7.3 节列举了两种配置 Django 用于 Memcached 集成的方法。MemcachedCache选项配置为使用本地回环地址;PyLibMCCache选项配置为使用 UNIX 套接字。

第 7.3 节 使用 Memcached 进行缓存

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
        'LOCATION': '127.0.0.1:11211',        # ❶
    },
    'cache': {
        'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
        'LOCATION': '/tmp/memcached.sock',    # ❷
    }
}

❶ 本地回环地址

❷ UNIX 套接字地址

本地回环地址和 UNIX 套接字是安全的,因为到这些地址的流量不会离开机器。在撰写本文时,遗憾的是,Memcached 维基上的 TLS 功能被描述为“实验性”。

Django 支持四种额外的缓存后端。这些选项要么不受欢迎,要么不安全,或者两者兼而有之,因此我在这里简要介绍它们:

  • 数据库后端

  • 本地内存后端,默认选项

  • 虚拟后端

  • 文件系统后端

数据库后端

DatabaseCache选项配置 Django 使用您的数据库作为缓存后端。使用此选项可以让您有更多理由通过 TLS 发送数据库流量。没有 TLS 连接,您缓存的所有内容,包括会话 ID,都可以被网络窃听者访问。下一个列表说明了如何配置 Django 使用数据库后端进行缓存。

第 7.4 节 使用数据库进行缓存

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'database_table_name',
    }
}

缓存服务和数据库之间的主要权衡是性能与存储容量之间的关系。您的数据库无法像缓存服务那样运行。数据库将数据持久化到磁盘;缓存服务将数据持久化到内存。另一方面,您的缓存服务永远无法存储与数据库一样多的数据。在会话状态不可牺牲的罕见情况下,这个选项是有价值的。

本地内存、虚拟和文件系统后端

LocMemCache将数据缓存在本地内存中,只有一个位置极佳的攻击者才能访问。DummyCache是比LocMemCache更安全的唯一选项,因为它不存储任何内容。这些选项,如下列表所示,非常安全,但在开发或测试环境之外并不实用。Django 默认使用LocMemCache

列表 7.5 使用本地内存进行缓存,或者什么都不使用

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
    },
    'dummy': {
        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
    }
}

正如您可能已经猜到的,FileBasedCache 不受欢迎且不安全。FileBasedCache 用户不必担心它们的未加密数据是否会被发送到网络;相反,它会被写入文件系统,如下所示。

列表 7.6 使用文件系统进行缓存

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': '/var/tmp/file_based_cache',
    }
}

7.3.3 基于写透式缓存的会话

基于写透式缓存的会话允许您将缓存服务和数据库组合起来管理会话状态。在这种方法下,当 Django 将会话状态写入缓存服务时,该操作也将“写透”到数据库中。这意味着会话状态是持久的,但写入性能会受到影响。

当 Django 需要读取会话状态时,它首先从缓存服务中读取,然后才考虑使用数据库。因此,您在读取操作上偶尔会受到性能影响。

SESSION_ENGINE 设置为 django.contrib.sessions.backends.cache_db 将启用基于写透式缓存的会话。

7.3.4 基于数据库的会话引擎

基于数据库的会话完全绕过了 Django 的缓存集成。如果您选择不将应用程序与缓存服务集成,这个选项非常有用。通过将 SESSION_ENGINE 设置为 django.contrib.sessions.backends.db 来配置基于数据库的会话。这是默认行为。

Django 不会自动清理被放弃的会话状态。使用持久会话的系统将需要确保定期调用 clearsessions 子命令。这将有助于您减少存储成本,但更重要的是,如果您在会话中存储敏感数据,它将有助于您减小攻击面的大小。下面的命令,从项目根目录执行,演示了如何调用 clearsessions 子命令:

$ python manage.py clearsessions

7.3.5 基于文件的会话引擎

正如您可能已经猜到的,这个选项极不安全。每个基于文件的会话都被序列化为一个文件。会话 ID 在文件名中,会话状态以未加密形式存储。任何拥有文件系统读取权限的人都可以劫持会话或查看会话状态。将 SESSION_ENGINE 设置为 django.contrib.sessions.backends.file 将配置 Django 将会话状态存储在文件系统中。

7.3.6 基于 cookie 的会话引擎

基于 cookie 的会话引擎将会话状态存储在会话 ID cookie 中。换句话说,使用此选项时,会话 ID cookie 不仅仅 标识 会话;它 会话。Django 不是将会话存储在本地,而是将整个会话序列化并发送到浏览器。然后,当浏览器在后续请求中回显它时,Django 会对有效载荷进行反序列化。

在将会话状态发送到浏览器之前,基于 cookie 的会话引擎使用 HMAC 函数对会话状态进行哈希处理。(您在第三章学习了 HMAC 函数。)从 HMAC 函数获取的哈希值与会话状态配对;Django 将它们一起作为会话 ID cookie 发送到浏览器。

当浏览器回显会话 ID cookie 时,Django 提取哈希值并验证会话状态。Django 通过对入站会话状态进行哈希处理并比较新的哈希值和旧的哈希值来执行此操作。如果哈希值不匹配,Django 知道会话状态已被篡改,并拒绝请求。如果哈希值匹配,Django 信任会话状态。图 7.2 说明了这个往返过程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7.2 Django 对发送的内容进行哈希处理,并对接收到的内容进行身份验证。

之前,您学习到 HMAC 函数需要一个密钥。Django 从 settings 模块中获取秘密密钥。

SECRET_KEY 设置

Django 应用程序的每个生成的应用程序都包含一个 SECRET_KEY 设置在 settings 模块中。这个设置很重要;它将在几个其他章节中重新出现。与流行观点相反,Django 并不使用 SECRET_KEY 来加密数据。相反,Django 使用此参数执行键控散列。此设置的值默认为唯一的随机字符串。在开发或测试环境中使用此值是可以的,但在生产环境中,重要的是从比您的代码存储库更安全的位置检索不同的值。

警告 SECRET_KEY 的生产值应保持三个属性。该值应该是唯一的、随机的,并且足够长。生成的默认值的长度为五十个字符,已经足够长了。不要将 SECRET_KEY 设置为密码或密码短语;没有人需要记住它。如果有人能记住这个值,系统就不够安全。在本章结束时,我会给你一个例子。

乍一看,基于 cookie 的会话引擎可能看起来是一个不错的选择。Django 使用 HMAC 函数对每个请求的会话状态进行身份验证和完整性验证。不幸的是,这个选项有许多缺点,其中一些是有风险的:

  • Cookie 大小限制

  • 未经授权的会话状态访问

  • 重放攻击

  • 远程代码执行攻击

Cookie 大小限制

文件系统和数据库用于存储大量数据;而 cookie 则不是。RFC 6265 要求 HTTP 客户端支持“每个 cookie 至少 4096 字节”(tools.ietf.org/html/rfc6265#section-5.3)。HTTP 客户端可以支持更大的 cookie,但不是必须的。因此,序列化的基于 cookie 的 Django 会话应保持在 4 KB 以下的大小。

未经授权的会话状态访问

基于 cookie 的会话引擎对传出的会话状态进行哈希处理;它不加密会话状态。这保证了完整性,但不保证机密性。因此,会话状态对恶意用户通过浏览器是容易获取的。如果会话包含用户不应该访问的信息,则系统容易受到攻击。

假设爱丽丝和伊芙都是 social.bob.com 的用户,这是一个社交媒体网站。爱丽丝因为伊芙在前一章执行了中间人攻击而对她感到愤怒,所以她屏蔽了她。与其他社交媒体网站不同的是,social.bob.com 不会通知伊芙她已被屏蔽。social.bob.com 将这些信息存储在基于 cookie 的会话状态中。

伊芙使用以下代码查看谁已经屏蔽了她。首先,她使用 requests 包进行程序化身份验证。(你在前一章学习了 requests 包)。接下来,她从会话 ID cookie 中提取、解码和反序列化自己的会话状态。反序列化的会话状态显示爱丽丝已经屏蔽了伊芙(用粗体字体表示):

>>> import base64
>>> import json
>>> import requests
>>> 
>>> credentials = {
...     'username': 'eve',
...     'password': 'evil', }
>>> response = requests.post(                               # ❶
...     'https:/./social.bob.com/login/',                    # ❶
...     data=credentials, )                                 # ❶
>>> sessionid = response.cookies['sessionid']               # ❷
>>> decoded = base64.b64decode(sessionid.split(':')[0])     # ❷
>>> json.loads(decoded)                                     # ❷
{'name': 'Eve', 'username': 'eve', 'blocked_by': ['alice']} # ❸

❶ 伊芙登录到鲍勃的社交媒体网站。

❷ 伊芙提取、解码和反序列化会话状态。

❸ 伊芙看到爱丽丝已经屏蔽了她。

重放攻击

基于 cookie 的会话引擎使用 HMAC 函数对传入的会话状态进行身份验证。这告诉服务器负载的原始作者是谁。这不能告诉服务器接收到的负载是否是负载的最新版本。换句话说,浏览器不能通过修改会话 ID cookie 来逃避,但浏览器可以重放其较旧版本。攻击者可以利用这种限制进行重放攻击

假设 ecommerce.alice.com 配置了基于 cookie 的会话引擎。该网站为每个新用户提供一次性折扣。会话状态中的一个布尔值表示用户的折扣资格。恶意用户玛洛丽首次访问该网站。作为新用户,她有资格获得折扣,她的会话状态反映了这一点。她保存了自己会话状态的本地副本。然后,她进行了第一次购买,获得了折扣,网站在捕获付款时更新了她的会话状态。她不再有资格获得折扣。后来,玛洛丽在后续购买请求中重放了她的会话状态副本,以获得额外的未经授权的折扣。玛洛丽成功执行了重放攻击。

重放攻击是利用在无效上下文中重复有效输入来破坏系统的任何利用。如果系统无法区分重放的输入和普通输入,则任何系统都容易受到重放攻击的影响。区分重放的输入和普通输入是困难的,因为在某一时间点,重放的输入曾经是普通输入。

这些攻击不仅限于电子商务系统。重放攻击已被用于伪造自动取款机(ATM)交易,解锁车辆,打开车库门,并绕过语音识别身份验证。

远程代码执行攻击

将基于 cookie 的会话与 PickleSerializer 结合使用是一条很危险的道路。如果攻击者能够访问 SECRET_KEY 设置,这种配置组合可能会被严重利用。

警告 远程代码执行攻击是残酷的。永远不要将基于 cookie 的会话与 PickleSerializer 结合使用;风险太大了。这种组合之所以不受欢迎是有充分理由的。

假设 vulnerable.alice.com 使用 PickleSerializer 对基于 cookie 的会话进行序列化。Mallory,一个对 vulnerable.alice.com 不满的前雇员,记住了 SECRET_KEY。她执行了对 vulnerable.alice.com 的攻击,计划如下:

  1. 编写恶意代码

  2. 使用 HMAC 函数和 SECRET_KEY 对恶意代码进行哈希

  3. 将恶意代码和哈希值作为会话 cookie 发送给 vulnerable.alice.com

  4. 坐下来,看着 vulnerable.alice.com 执行 Mallory 的恶意代码

首先,Mallory 编写恶意的 Python 代码。她的目标是欺骗 vulnerable.alice.com 执行这段代码。她安装 Django,创建 PickleSerializer,并将恶意代码序列化为二进制格式。

接下来,Mallory 对序列化的恶意代码进行哈希。她以与服务器哈希会话状态相同的方式进行,使用 HMAC 函数和 SECRET_KEY。Mallory 现在拥有恶意代码的有效哈希值。

最后,Mallory 将序列化的恶意代码与哈希值配对,伪装成基于 cookie 的会话状态。她将有效负载作为会话 cookie 发送到 vulnerable.alice.com 的请求头中。不幸的是,服务器成功验证了 cookie;毕竟,恶意代码是使用服务器相同的 SECRET_KEY 进行哈希的。在验证了 cookie 后,服务器使用 PickleSerializer 反序列化会话状态,无意中执行了恶意脚本。Mallory 成功执行了一次 远程代码执行攻击。图 7.3 说明了 Mallory 的攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7.3 Mallory 使用被篡改的 SECRET_KEY 执行远程代码执行攻击。

以下示例演示了 Mallory 如何从交互式 Django shell 执行远程代码执行攻击。在这次攻击中,Mallory 通过调用 sys.exit 函数欺骗 vulnerable.alice.com 自杀。Mallory 在 PickleSerializer 反序列化她的代码时调用 sys.exit。Mallory 使用 Django 的 signing 模块对恶意代码进行序列化和哈希,就像基于 cookie 的会话引擎一样。最后,她使用 requests 包发送请求。请求没有响应;接收方(用粗体字体标记)就这样死了:

$ python manage.py shell
>>> import sys
>>> from django.contrib.sessions.serializers import PickleSerializer
>>> from django.core import signing
>>> import requests
>>> 
>>> class MaliciousCode:
...     def __reduce__(self):                                              # ❶
...         return sys.exit, ()                                            # ❷
... 
>>> session_state = {'malicious_code': MaliciousCode(), }
>>> sessionid = signing.dumps(                                             # ❸
...     session_state,                                                     # ❸
...     salt='django.contrib.sessions.backends.signed_cookies',            # ❸
...     serializer=PickleSerializer)                                       # ❸
>>> 
>>> session = requests.Session()
>>> session.cookies['sessionid'] = sessionid
>>> session.get('https:/./vulnerable.alice.com/')                           # ❹
Starting new HTTPS connection (1): vulnerable.com
http.client.RemoteDisconnected: Remote end closed connection without response# ❺

❶ Pickle 将此方法称为反序列化。

❷ Django 用这行代码自杀。

❸ Django 的签名模块序列化并哈希 Mallory 的恶意代码。

❹ 发送请求

❺ 收不到响应

SESSION_ENGINE设置为django.contrib.sessions.backends.signed_cookies配置 Django 使用基于 cookie 的会话引擎。

摘要

  • 服务器使用Set-Cookie响应头在浏览器上设置会话 ID。

  • 浏览器使用Cookie请求头将会话 ID 发送给服务器。

  • 使用SecureDomainMax-Age指令来抵抗在线攻击。

  • Django 本地支持五种存储会话状态的方式。

  • Django 本地支持六种缓存数据的方式。

  • 重放攻击可以滥用基于 cookie 的会话。

  • 远程代码执行攻击可以滥用 pickle 序列化。

  • Django 使用SECRET_KEY设置进行键控哈希,而不是加密。

第八章:用户身份验证

本章涵盖

  • 注册和激活新用户账户

  • 安装和创建 Django 应用程序

  • 登录和退出项目

  • 访问用户个人资料信息

  • 测试身份验证

身份验证和授权类似于用户和组。在本章中,你将通过创建用户来学习身份验证;在后面的章节中,你将通过创建组来学习授权。

注意 在撰写本文时,破损的身份验证 在 OWASP 十大安全风险中排名第 2 位(owasp.org/www-project-top-ten/)。什么是 OWASP 十大安全风险?它是一个旨在提高人们对网络应用程序面临的最关键安全挑战的认识的参考资料。开放网络应用安全项目(OWASP)是一个致力于提高软件安全性的非营利组织。OWASP 通过开源项目、会议和全球数百个地方分会促进安全标准和最佳实践的采纳。

你将通过向之前创建的 Django 项目添加一个新的用户注册工作流程来开始本章。Bob 使用这个工作流程为自己创建并激活一个账户。接下来,你将创建一个身份验证工作流程。Bob 使用这个工作流程来登录、访问他的个人资料信息和退出。HTTP 会话管理,来自上一章,也会出现。最后,你将编写测试来验证这些功能。

8.1 用户注册

在本节中,你将利用django-registration,一个 Django 扩展库,来创建一个用户注册工作流程。在此过程中,你将学习 Django Web 开发的基本构建模块。Bob 使用你的用户注册工作流程为自己创建并激活一���账户。这一节为你和 Bob 准备了下一节,在那里你将为他构建一个身份验证工作流程。

用户注册工作流程是一个两步过程;你可能已经体验过了:

  1. Bob 创建 了他的账户。

  2. Bob 激活 了他的账户。

Bob 通过请求用户注册表单进入用户注册工作流程。他提交这个表单,包括用户名、电子邮件地址和密码。服务器创建一个未激活的账户,将他重定向到注册确认页面,并发送给他一个账户激活的电子邮件。

Bob 目前无法登录他的账户,因为账户尚未激活。他必须验证他的电子邮件地址以激活账户。这可以防止 Mallory 使用 Bob 的电子邮件地址创建账户,保护你和 Bob;你将知道电子邮件地址是有效的,而 Bob 不会收到你的未经请求的电子邮件。

Bob 的电子邮件包含一个链接,他点击以确认他的电子邮件地址。这个链接将 Bob 带回服务器,然后激活他的账户。图 8.1 描述了这个典型的工作流程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.1 典型的用户注册工作流程,包括电子邮件确认

在你开始编写代码之前,我将定义一些 Django Web 开发的基本构建模块。你即将创建的工作流由三个基本构建模块组成:

  • 视图

  • 模型

  • 模板

Django 用一个对象表示每个传入的 HTTP 请求。该对象的属性映射到请求的属性,比如 URL 和 Cookie。Django 将每个请求映射到一个视图—一个用 Python 编写的请求处理程序。视图可以由类或函数实现;我在本书的示例中使用类。Django 调用视图,将请求对象传递给它。视图负责创建并返回响应对象。响应对象表示出站的 HTTP 响应,携带数据如内容和响应头。

模型是一个对象关系映射类。与视图一样,模型是用 Python 编写的。模型弥合了应用程序的面向对象世界与存储数据的关系数据库之间的差距。模型类类似于数据库表。模型类的属性类似于数据库表列。模型对象类似于数据库表中的行。视图使用模型来创建、读取、更新和删除数据库记录。

模板代表了请求的响应。与视图和模型不同,模板主要是用 HTML 和简单的模板语法编写的。视图通常使用模板来组合静态和动态内容生成响应。图 8.2 描述了视图、模型和模板之间的关系。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.2 Django 应用服务器使用模型-视图-模板架构来处理请求。

这种架构通常被称为模型-视图-模板MVT)。如果你已经熟悉模型-视图-控制器(MVC)架构,这可能会有点令人困惑。这些架构对于模型的称呼是一致的:模型是一个对象关系映射层。但是对于视图的称呼则不一致。MVT 视图大致等同于 MVC 控制器;MVC 视图大致等同于 MVT 模板。表 8.1 比较了两种架构的词汇。

表 8.1 MVT 术语与 MVC 术语对照

MVT 术语 MVC 术语 描述
模型 模型 对象关系映射层
视图 控制器 负责逻辑和协调的请求处理程序
模板 视图 响应内容的生成

在本书中,我使用 MVT 术语。你即将构建的用户注册工作流由视图、模型和模板组成。你不需要编写视图或模型;这项工作已经由django-registration扩展库为你完成。

通过将django-registration安装为Django 应用来利用它在你的Django 项目中。那么应用和项目有什么区别呢?这两个术语经常会令人困惑,可以理解:

  • Django 项目—这是一个配置文件集合,如 settings.py 和 urls.py,并且一个或多个 Django 应用程序。我在第六章用 django-admin 脚本向你展示了如何生成 Django 项目。

  • Django 应用程序—这是 Django 项目的模块化组件。每个组件负责一组离散的功能,如用户注册。多个项目可以使用相同的 Django 应用程序。一个 Django 应用程序通常不会变得足够大,以至于被视为一个应用程序。

在你的虚拟环境中,使用以下命令安装django-registration

$ pipenv install django-registration

接下来,打开你的settings模块,并添加下面加粗显示的代码行。这将django-registration添加到INSTALLED_APPS设置中。此设置是一个表示 Django 项目中 Django 应用程序的列表。确保不要删除任何现有的应用程序:

INSTALLED_APPS = [
    ...
    'django.contrib.staticfiles',
    'django_registration',         # ❶
]

❶ 安装 django-registration 库

接下来,在 Django 根目录中运行以下命令。这个命令执行所有需要的数据库修改以适应django-registration

$ python manage.py migrate

接下来,在 Django 根目录中打开 urls.py。在文件开头添加一个include函数的导入,如列表 8.1 中加粗显示的。在导入下面是一个名为 urlpatterns 的列表。Django 使用这个列表将入站请求的 URL 映射到视图。将以下 URL 路径条目添加到 urlpatterns,也用加粗显示,不要删除任何现有的 URL 路径条目。

列表 8.1 将视图映射到 URL 路径

from django.contrib import admin
from django.urls import path, include                                # ❶

urlpatterns = [
    path('admin/', admin.site.urls),
 path('accounts/',          include('django_registration.backends.activation.urls')),   # ❷
]

❶ 添加 include 导入

❷ 将 django-registration 视图映射到 URL 路径

添加这行代码会将五个 URL 路径映射到django-registration视图。表 8.2 说明了哪些 URL 模式映射到哪些视图。

表 8.2 URL 路径到用户注册视图的映射

URL 路径 django-registration 视图
/accounts/activate/complete/ TemplateView
/accounts/activate/<activation_key>/ 激活视图
/accounts/register/ 注册视图
/accounts/register/complete/ TemplateView
/accounts/register/closed/ TemplateView

这些 URL 路径中的三个映射到TemplateView类。TemplateView不执行任何逻辑,只是简单地呈现模板。在下一节中,你将创建这些模板。

8.1.1 模板

每个生成的 Django 项目都配置有一个完全功能的模板引擎。模板引擎通过合并动态和静态内容将模板转换为响应。图 8.3 描述了一个模板引擎在 HTML 中生成一个有序列表。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.3 模板引擎将静态 HTML 和动态内容结合在一起。

就像 Django 的每个其他主要子系统一样,模板引擎在settings模块中配置。打开 Django 根目录中的settings模块。在此模块的顶部,添加对os模块的导入,如下面代码中所示。在此导入下方,找到TEMPLATES设置,一个模板引擎列表。找到第一个且唯一的模板引擎的DIRS键。DIRS告诉模板引擎在搜索模板文件时要使用哪些目录。将以下条目添加到DIRS中,同样显示为粗体,告诉模板引擎在名为 templates 的目录中查找模板文件,位于项目根目录下方:

import os                                                 # ❶

...

TEMPLATES = [
    {
        ...
        'DIRS': [os.path.join(BASE_DIR, 'templates')],    # ❷
        ...
    }
]

❶ 导入 os 模块

❷ 告诉模板引擎在哪里查找

在项目根目录下方,创建一个名为 templates 的子目录。在 templates 目录下,创建一个名为 django_registration 的子目录。这是django-registration视���期望您的模板存在的地方。您的用户注册工作流程将使用以下模板,按照 Bob 看到的顺序显示:

  • registration_form.html

  • registration_complete.html

  • activation_email_subject.txt

  • activation_email_body.txt

  • activation_complete.html

在 django_registration 目录下,创建一个名为 registration_form.html 的文件,其中包含列表 8.2 中的代码。此模板呈现 Bob 看到的第一件事,一个新的用户注册表单。忽略csrf_token标签;我将在第十六章中介绍这个。form.as_ p变量将呈现带标签的表单字段。

列表 8.2 一个新的用户注册表单

<html>
    <body>

        <form method='POST'>
          {% csrf_token %}           <!-- ❶ -->
          {{ form.as_p }}            <!-- ❷ -->
          <button type='submit'>Register</button>
        </form>

    </body>
</html>

❶ 必要,但将在另一章节中介绍

❷ 动态呈现为用户注册表单字段

接下来,在同一目录中创建一个名为 registration_complete.html 的文件,并将以下 HTML 添加到其中。此模板在 Bob 成功注册后呈现一个简单的确认页面:

<html>
    <body>
        <p>
            Registration is complete.
            Check your email to activate your account.
        </p>
    </body>
</html>

在同一目录中创建一个名为 activation_email_subject.txt 的文件。添加以下代码行,生成账户激活邮件的主题行。site变量将呈现为主机名;对于您来说,这将是localhost

Activate your account at {{ site }}

接下来,在同一目录中创建一个名为 activation_email_body.txt 的文件,并将以下代码行添加到其中。此模板代表账户激活邮件的正文:

Hello {{ user.username }},

Go to https://{{ site }}/accounts/activate/{{ activation_key }}/ 
to activate your account.

最后,在创建一个名为 activation_complete.html 的文件,并将以下 HTML 添加到其中。这是 Bob 在工作流程中看到的最后一件事:

<html>
    <body>
        <p>Account activation completed!</p>
    </body>
</html>

在此工作流程中,您的系统将向 Bob 的电子邮件地址发送一封电子邮件。在开发环境中设置电子邮件服务器将是一个很大的不便。此外,您实际上并不拥有 Bob 的电子邮件地址。打开设置文件,并添加以下代码以覆盖此行为。这将配置 Django 将出站电子邮件重定向到您的控制台,为您提供一种轻松访问用户注册链接的方式,而不会产生运行完全功能邮件服务器的开销:

if DEBUG:
    EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

将以下代码行添加到settings模块中。这个设置代表 Bob 有多少天时间来激活他的账户:

ACCOUNT_ACTIVATION_DAYS = 3

好了,你已经完成了用户注册工作流程的编写。Bob 现在将使用它来创建和激活他的账户。

8.1.2 Bob 注册他的账户

重新启动服务器,并将浏览器指向 https:/./localhost:8000/accounts/regis ter/。你看到的用户注册表单包含几个必填字段:用户名、电子邮件、密码和密码确认。按照图 8.4 中显示的表单填写表单,为 Bob 设置一个密码,并提交表单。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.4 Bob 为自己注册了一个账户,提交了一个用户名、他的电子邮件地址和一个密码。

提交用户注册表单为 Bob 创建了一个账户。Bob 目前无法登录这个账户,因为账户尚未激活。他必须验证自己的电子邮件地址以激活账户。这可以防止 Mallory 使用 Bob 的电子邮件地址创建账户;Bob 不会收到未经请求的电子邮件,而你将知道该电子邮件地址是有效的。

在创建账户后,你将被重定向到注册确认页面。该页面通知你检查你的电子邮件。之前你配置 Django 将出站邮件重定向到你的控制台。在你的控制台中查找 Bob 的电子邮件。

在 Bob 的电子邮件中找到账户激活的 URL。注意 URL 后缀是一个激活令牌。这个令牌不仅仅是一串随机的字符和数字;它包含一个 URL 编码的时间戳和一个带键的哈希值。服务器通过使用 HMAC 函数对用户名和账户创建时间进行哈希来创建这个令牌(你在第三章学习过 HMAC 函数)。HMAC 函数的密钥是SECRET_KEY。图 8.5 说明了这个过程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.5 Bob 提交用户注册表单并收到账户激活邮件;账户激活令牌是一个带键哈希的应用。

从你的控制台复制并粘贴账户激活邮件到你的浏览器。这将把账户激活令牌发送回服务器。服务器现在从 URL 中提取用户名和时间戳,并重新计算哈希值。如果重新计算的哈希值与传入的哈希值不匹配,服务器就知道令牌已被篡改;账户激活将失败。如果两个哈希值匹配,服务器就知道它是令牌的作者;Bob 的账户被激活。

激活 Bob 的账户后,你将被重定向到一个简单的确认页面。Bob 的账户已经被创建和激活;你已经完成了你的第一个工作流程。在下一节中,你将创建另一个工作流程,让 Bob 访问他的新账户。

8.2 用户认证

在本节中,你将为 Bob 构建第二个工作流程。此工作流程允许 Bob 在访问敏感个人信息之前证明他的身份。Bob 通过请求和提交登录表单开始此工作流程。服务器将 Bob 重定向到一个简单的个人资料页面。Bob 登出,服务器将他重定向回登录表单。图 8.6 说明了这个工作流程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.6 在此认证工作流程中,Bob 登录,访问他的个人资料信息,然后登出。

与用户注册工作流程一样,认证工作流程由视图、模型和模板组成。这次,Django 已经为你完成了大部分工作。Django 本地支持许多内置视图、模型和模板。这些组件支持常见的站点功能,如登录、登出、更改密码和重置密码。在下一节中,你将利用两个内置 Django 视图。

8.2.1 内置 Django 视图

要利用 Django 的内置视图,打开 Django 根目录中的 urls.py。将以下 URL 路径条目,显示为粗体,添加到 urlpatterns;不要删除任何现有的 URL 路径条目:

urlpatterns = [
   ...
 path('accounts/', include('django.contrib.auth.urls')),     # ❶
]

❶ 将 URL 路径映射到内置 Django 视图

添加这行代码将八个 URL 路径映射到内置视图。表 8.3 显示了哪些 URL 模式映射到哪些视图类。在本章中,你将使用前两个视图,LoginViewLogoutView。后续章节将使用其他视图。

表 8.3 将 URL 路径映射到视图

URL 路径 Django 视图
accounts/login/ LoginView
accounts/logout/ LogoutView
accounts/password_change/ PasswordChangeView
accounts/password_change/done/ PasswordChangeDoneView
accounts/password_reset/ PasswordResetView
accounts/password_reset/done/ PasswordResetDoneView
accounts/reset/// PasswordResetConfirmView
accounts/reset/done/ PasswordResetCompleteView

许多 Django 项目都使用这些视图进行生产。这些视图之所以受欢迎,有两个主要原因。首先,你可以在不重复造轮子的情况下更快地将代码推向生产。其次,更重要的是,这些组件通过遵循最佳实践来保护你和你的用户。

在下一节中,你将创建和配置你自己的视图。你的视图将存在于一个新的 Django 应用程序中。这个应用程序允许 Bob 访问他的个人信息。

8.2.2 创建一个 Django 应用程序

在之前,你生成了一个 Django 项目;在本节中,你将生成一个 Django 应用程序。从项目根目录运行以下命令来创建一个新应用程序。该命令在一个名为 profile_info 的新目录中生成一个 Django 应用程序:

$ python manage.py startapp profile_info

图 8.7 显示了新应用程序的目录结构。注意,为应用程序特定的模型、测试和视图生成了一个单独的模块。在本章中,你将修改 viewstests 模块。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.7 新 Django 应用程序的目录结构

打开views模块,并将列表 8.3 中的代码添加到其中。ProfileView类通过请求访问用户对象。此对象是由 Django 定义和创建的内置模型。Django 在调用视图之前自动创建用户对象并将其添加到请求中。如果用户未经身份验证,ProfileView将以 401 状态响应。此状态通知客户端未经授权访问配置文件信息。如果用户已经经过身份验证,ProfileView将以用户的配置文件信息响应。

列表 8.3 将视图添加到您的应用程序

from django.http import HttpResponse
from django.shortcuts import render
from django.views.generic import View

class ProfileView(View):

    def get(self, request):
        user = request.user                      # ❶
        if not user.is_authenticated:            # ❷
            return HttpResponse(status=401)      # ❷
        return render(request, 'profile.html')   # ❸

❶ 以编程方式访问用户对象

❷ 拒绝未经身份验证的用户

❸ 渲染一个响应

在新的应用程序目录(而不是项目根目录)下,添加一个名为 urls.py 的新文件,并使用以下内容。此文件将 URL 路径映射到特定于应用程序的视图:

from django.urls import path
from profile_info import views

urlpatterns = [
   path('profile/', views.ProfileView.as_view(), name='profile'),
]

在项目根目录(而不是应用程序目录)中,重新打开 urls.py 并添加一个新的 URL 路径条目,如下所示。此 URL 路径条目将 ProfileView 映射到 /accounts/profile/。保留 urlpatterns 中的所有现有 URL 路径条目不变:

urlpatterns = [
   ...
 path('accounts/', include('profile_info.urls')),
]

到目前为止,您已经重用了 Django 的内置视图并创建了自己的视图,ProfileView。现在是为您的视图创建模板的时候了。在 templates 目录下创建一个名为 registration 的子目录。创建并打开一个名为 login.html 的文件,位于 registration 下。默认情况下,LoginView 在此处查找登录表单。

将以下 HTML 添加到 login.html;Bob 将使用此表单提交他的身份验证凭据。模板表达式 {{ form.as_p }} 为用户名和密码分别呈现一个带有标签的输入字段。与用户注册表单一样,请忽略 csrf_token 语法;这将在第十六章中介绍:

<html>
    <body>

        <form method='POST'>
          {% csrf_token %}                      <!-- ❶ -->
          {{ form.as_p }}                       <!-- ❷ -->
          <button type='submit'>Login</button>
        </form>

    </body>
</html>

❶ 必要的,但将在另一章节中讨论

❷ 动态呈现为用户名和密码表单字段

创建并打开一个名为 profile.html 的文件,位于 templates 目录下。将以下 HTML 添加到 profile.html;此模板将呈现 Bob 的配置文件信息和注销链接。此模板中的 {{ user }} 语法引用了由 ProfileView 访问的同一用户模型对象。最后一个段落包含一个名为 url 的内置模板标签。此标签将查找并呈现映射到 LogoutView 的 URL 路径:

<html>
    <body>

        <p>
            Hello {{ user.username }},                <!-- ❶ -->
            your email is {{ user.email }}.           <!-- ❶ -->
        </p>
        <p>
            <a href="{% url 'logout' %}">Logout</a>   # ❷
        </p>

    </body>
</html>

❶ 通过模型对象渲染配置文件信息,来自数据库

❷ 动态生成注销链接

现在是时候以 Bob 的身份登录了。在开始下一节之前,您应该做两件事。首先,确保所有更改都已写入磁盘。其次,重新启动服务器。

8.2.3 Bob 登录并退出他的帐户

将浏览器指向 https:/./localhost:8000/accounts/login/ 并以 Bob 的身份登录。成功登录后,LoginView 将向浏览器发送一个响应,其中包含两个重要的细节:

  • Set-Cookie 响应头

  • 状态码为 302

Set-Cookie响应头将会将会话 ID 传递给浏览器。(你在上一章学习了这个头部。)Bob 的浏览器将保存一个本地副本的会话 ID,并在后续请求中将其发送回服务器。

服务器将浏览器重定向到/accounts/profile/,状态码为 302。在表单提交后进行重定向是最佳实践。这可以防止用户意外提交相同的表单两次。

重定向请求在您的自定义应用中映射到ProfileViewProfileView使用 profile.html 生成包含 Bob 的个人资料信息和注销链接的响应。

注销

默认情况下,LogoutView呈现一个通用的注销页面。要覆盖此行为,请打开settings模块并添加以下代码行。这将配置LogoutView在用户注销时将浏览器重定向到登录页面:

LOGOUT_REDIRECT_URL = '/accounts/login/'

重新启动服务器并点击个人资料页面上的注销链接。这将发送一个请求到/accounts/logout/。Django 将这个请求映射到LogoutView

LoginView一样,LogoutView响应一个Set-Cookie响应头和一个 302 状态码。Set-Cookie头将会话 ID 设置为空字符串,使会话无效。302 状态码将浏览器重定向到登录页面。Bob 现在已经登录和退出了他的账户,您已经完成了第二个工作流程。

多因素身份验证

不幸的是,密码有时会落入错误的手中。因此,许多组织要求额外的身份验证形式,这个功能被称为多因素身份验证MFA)。您可能已经使用过 MFA。启用 MFA 的账户通常除了用户名和密码挑战外还受到以下一种或多种因素的保护:

  • 一次性密码(OTP)

  • 钥匙扣,门禁卡或智能卡

  • 生物特征,如指纹或面部识别

在撰写本书时,很遗憾我无法找到一个令人信服的 Python MFA 库。希望在下一版出版之前能有所改变。不过我确实推荐 MFA,所以如果你选择采用它,这里是一些该做和不该做的事项清单:

  • 抵制自己动手构建的冲动。这个警告类似于“不要自己编写加密算法。”安全是复杂的,自定义安全代码容易出错。

  • 避免通过短信或语音邮件发送 OTP。这适用于您构建的系统和您使用的系统。尽管很常见,但这些形式的身份验证是不安全的,因为电话网络不安全。

  • 避免问类似“你母亲的婚前姓是什么?”或“你三年级时最好的朋友是谁?”这样的问题。有些人称之为安全问题,但我称之为不安全问题。想象一下,攻击者只需找到受害者的社交媒体账户就能轻松推断出这些问题的答案。

在本节中,您编写了支持网站最基本功能的代码。现在是时候优化一些这些代码了。

8.3 简洁地要求身份验证

安全的网站禁止匿名访问受限资源。当请求到达时没有有效的会话 ID,网站通常会用错误代码或重定向来响应。Django 支持使用名为LoginRequiredMixin的类来支持此行为。当您的视图继承自LoginRequiredMixin时,无需验证当前用户是否已经通过身份验证;LoginRequiredMixin会为您执行此操作。

profile_info目录中,重新打开views.py文件,并将LoginRequiredMixin添加到ProfileView。这会将来自匿名用户的请求重定向到您的登录页面。接下来,删除任何用于程序化验证请求的代码;这些代码现在已经是多余的。您的类应该像这里显示的一样;LoginRequiredMixin和删除的代码以粗体字显示。

清单 8.4 简洁地禁止匿名访问

from django.contrib.auth.mixins import LoginRequiredMixin    # ❶
from django.http import HttpResponse                         # ❷
from django.shortcuts import render
from django.views.generic import View

class ProfileView(LoginRequiredMixin, View):                 # ❸

    def get(self, request):
        user = request.user                                  # ❹
        if not user.is_authenticated:                        # ❹
            return HttpResponse(status=401)                  # ❹
        return render(request, 'profile.html')

❶ 添加此导入。

❷ 删除此导入。

❸ 添加LoginRequiredMixin

❹ 删除这些行代码。

login_required装饰器是函数式视图的等效物。以下代码示例说明了如何使用login_required装饰器禁止匿名访问函数式视图:

from django.contrib.auth.decorators import login_required

@login_required               # ❶
def profile_view(request):
   ...
   return render(request, 'profile.html')

❶ 等同于LoginRequiredMixin

您的应用程序现在支持用户身份验证。有人说认证会使测试变得困难。在一些 Web 应用程序框架中,这可能是真的,但在接下来的章节中,您将了解为什么 Django 不是其中之一。

8.4 测试身份验证

安全性和测试有一个共同点:程序员经常低估了这两者的重要性。通常,在代码库年轻时,这两个领域都没有得到足够的关注。然后,系统的长期健康状态就会受到影响。

系统的每个新功能都应该配有测试。Django 通过为每个新的 Django 应用程序生成一个tests模块来鼓励测试。这个模块是您编写测试类的地方。测试类或TestCase的责任是为一组离散功能定义测试。TestCase类由测试方法组成。测试方法旨在通过执行单个功能并执行断言来维护代码库的质量。

身份验证对测试不构成障碍。具有真实密码的实际用户可以从测试中以编程方式登录和退出您的 Django 项目。在profile_info目录下,打开tests.py文件,并添加清单 8.5 中的代码。TestAuthentication类演示了如何测试本章中所做的一切。test_authenticated_workflow方法首先为 Bob 创建一个用户模型。然后,它以他的身份登录,访问他的个人资料页面,然后将其注销。

清单 8.5 测试用户身份验证

from django.contrib.auth import get_user_model
from django.test import TestCase

class TestAuthentication(TestCase):

    def test_authenticated_workflow(self):
        passphrase = 'wool reselect resurface annuity'                   # ❶
        get_user_model().objects.create_user('bob', password=passphrase) # ❶

        self.client.login(username='bob', password=passphrase)           # ❷
        self.assertIn('sessionid', self.client.cookies)                  # ❷

        response = self.client.get(                                      # ❸
            '/accounts/profile/',                                        # ❸
            secure=True)                                                 # ❹
        self.assertEqual(200, response.status_code)                      # ❺
        self.assertContains(response, 'bob')                             # ❺

        self.client.logout()                                             # ❻
        self.assertNotIn('sessionid', self.client.cookies)               # ❻

❶ 为 Bob 创建一个测试用户帐户

❷ Bob 登录。

❸ 访问 Bob 的个人资料页面

❹ 模拟 HTTPS

❺ 验证响应

❻ 验证 Bob 已注销

接下来,添加test_prohibit_anonymous_access方法,如列表 8.6 所示。该方法尝试匿名访问个人资料页面。测试响应以确保用户被重定向到登录页面。

列表 8.6 测试匿名访问限制

class TestAuthentication(TestCase):

...

    def test_prohibit_anonymous_access(self):
        response = self.client.get('/accounts/profile/', secure=True)   # ❶
        self.assertEqual(302, response.status_code)                     # ❷
        self.assertIn('/accounts/login/', response['Location'])         # ❷

❶ 尝试匿名访问

❷ 验证响应

从项目根目录运行以下命令。这会执行 Django 测试运行器。测试运行器会自动找到并执行这两个测试;两个测试都通过了:

$ python manage.py test
System check identified no issues (0 silenced).
..
--------------------------------------------------------------------
Ran 2 tests in 0.294s
OK

在这一章中,你学会了如何构建任何系统中最重要的一些功能。你知道如何创建和激活账户;你知道如何让用户登录和退出他们的账户。在接下来的章节中,你将进一步扩展这些知识,涉及的主题包括密码管理、授权、OAuth 2.0 和社交登录。

摘要

  • 使用两步用户注册工作流程验证用户的电子邮件地址。

  • 视图、模型和模板是 Django Web 开发的构建模块。

  • 不要重复造轮子;使用内置的 Django 组件对用户进行认证。

  • 禁止匿名访问受限资源。

  • 认证不是对未经测试功能的借口。

第九章:用户密码管理

本章内容

  • 更改、验证和重置用户密码

  • 使用加盐哈希抵抗突破

  • 使用密钥派生函数抵抗暴力攻击

  • 迁移哈希密码

在之前的章节中,你已经了解了哈希和认证; 在本章中,你将了解这些主题的交集。 Bob 在本章中使用了两个新的工作流程:密码更改工作流程和密码重置工作流程。 数据认证再次出现。 你将盐化哈希和密钥派生函数结合起来作为防范突破和暴力攻击的防御层。 在此过程中,我将向你展示如何选择和执行密码策略。 最后,我将向你展示如何从一种密码哈希策略迁移到另一种密码哈希策略。

9.1 密码更改工作流程

在前一章中,你将 URL 路径映射到一组内置 Django 视图。 你使用了其中的两个视图,LoginViewLogoutView,构建了认证工作流程。 在本节中,我将向你展示另一个由另外两个视图组成的工作流程:PasswordChangeViewPasswordChangeDoneView

你很幸运;你的项目已经在使用内置视图进行此工作流程。 你在前一章已经完成了这项工作。 如果服务器尚未运行,请启动服务器,然后作为 Bob 重新登录,并将浏览器指向 localhost:8000/admin/password _change/。 之前,你将此 URL 映射到 PasswordChangeView,一个用于更改用户密码的视图,该表单包含三个必填字段,如图 9.1 所示:

  • 用户的密码

  • 新密码

  • 新密码确认

注意新密码字段旁边的四个输入约束。 这些约束代表项目的密码策略。 这是一组旨在防止用户选择弱密码的规则。 PasswordChangeView 在提交表单时执行此策略。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.1 内置密码更改表单强制执行四个约束的密码策略。

Django 项目的密码策略由 AUTH_PASSWORD_VALIDATORS 设置定义。 此设置是用于确保密码强度的密码验证器列表。 每个密码验证器强制执行单个约束。 此设置默认为空列表,但每个生成的 Django 项目都配置有四个明智的内置验证器。 以下清单说明了默认密码策略; 这段代码已经出现在项目的 settings 模块中。

清单 9.1 默认密码策略

AUTH_PASSWORD_VALIDATORS = [
    {
      'NAME': 'django.contrib.auth...UserAttributeSimilarityValidator',
    },
    {
      'NAME': 'django.contrib.auth...MinimumLengthValidator',
    },
    {
      'NAME': 'django.contrib.auth...CommonPasswordValidator',
    },
    {
      'NAME': 'django.contrib.auth...NumericPasswordValidator',
    },
]

UserAttributeSimilarityValidator 拒绝任何类似于用户名、名字、姓氏或电子邮件的密码。 这可以防止 Mallory 猜测像 alice12345bob@bob.com 这样的密码。

此验证器包含两个可选字段:user_attributesmax_similarityuser_attributes 选项修改验证器检查的用户属性。max_similarity 选项修改验证器的严格程度。默认值为 0.7;降低此数字会使验证器更加严格。以下列表演示了如何配置 UserAttributeSimilarityValidator 来严格测试三个自定义属性。

列表 9.2 验证密码相似性

{
   'NAME': 'django.contrib.auth...UserAttributeSimilarityValidator',
   'OPTIONS': {
       'user_attributes': ('custom', 'attribute', 'names'),
       'max_similarity': 0.6,      # ❶
   }
}

❶ 默认值为 0.7

MinimumLengthValidator,如列表 9.3 所示,拒绝任何太短的密码。这可以防止 Mallory 通过诸如 b06 这样的密码暴力破解受密码保护的帐户。默认情况下,此验证器拒绝少于八个字符的任何密码。此验证器包含一个可选的 min_length 字段,以强制执行更长的密码。

列表 9.3 验证密码长度

{
   'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
   'OPTIONS': {
       'min_length': 12,     # ❶
   }
}

❶ 默认值为 8。

CommonPasswordValidator 拒绝在 20,000 个常见密码列表中找到的任何密码;请参见列表 9.4。这可以防止 Mallory 破解受密码保护的帐户,例如 passwordqwerty。此验证器包含一个可选的 password_list_path 字段,以覆盖常见密码列表。

列表 9.4 禁止常见密码

{
   'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
   'OPTIONS': {
       'password_list_path': '/path/to/more-common-passwords.txt.gz',
   }
}

NumericPasswordValidator,顾名思义,拒绝纯数字密码。在下一节中,我将向您展示如何通过自定义密码验证器加强密码策略。

9.1.1 自定义密码验证

在项目的 profile_info 目录下创建一个名为 validators.py 的文件。在此文件中,添加列表 9.5 中的代码。PassphraseValidator 确保密码是一个由四个单词组成的密码短语。您在第三章学习了有关密码短语的知识。PassphraseValidator 通过将字典文件加载到内存中来初始化自身。get_help_text 方法传达约束;Django 将此消息传递给用户界面。

列表 9.5 自定义密码验证器

from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

class PassphraseValidator:

    def __init__(self, dictionary_file='/usr/share/dict/words'):
        self.min_words = 4
        with open(dictionary_file) as f:                                 # ❶
            self.words = set(word.strip() for word in f)                 # ❶

    def get_help_text(self):
        return _('Your password must contain %s words' % self.min_words) # ❷

❶ 将字典文件加载到内存中

❷ 将约束传达给用户

接下来,在 PassphraseValidator 中添加列表 9.6 中的方法。validate 方法验证每个密码的两个属性。密码必须由四个单词组成,并且字典必须包含每个单词。如果密码不符合这两个条件,validate 方法会引发 ValidationError,拒绝密码。然后 Django 重新渲染带有 ValidationError 消息的表单。

列表 9.6 validate 方法

class PassphraseValidator:

...

    def validate(self, password, user=None):
        tokens = password.split(' ')

        if len(tokens) < self.min_words:                                   # ❶
            too_short = _('This password needs %s words' % self.min_words) # ❶
            raise ValidationError(too_short, code='too_short')             # ❶

        if not all(token in self.words for token in tokens):               # ❷
            not_passphrase = _('This password is not a passphrase')        # ❷
            raise ValidationError(not_passphrase, code='not_passphrase')   # ❷

❶ 确保每个密码由四个单词组成

❷ 确保每个单词有效

默认情况下,PassphraseValidator使用许多标准 Linux 发行版中附带的字典文件。非 Linux 用户可以从网上下载替代品(www.karamasoft.com/UltimateSpell/Dictionary.aspx)。PassphraseValidator可以使用可选字段dictionary_file来适应替代字典文件。此选项表示覆盖字典文件的路径。

一个类似PassphraseValidator的自定义密码验证器配置方式与本机密码验证器相同。打开settings模块,将AUTH_PASSWORD_VALIDATORS中的所有四个本机密码验证器替换为PassphraseValidator

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'profile_info.validators.PassphraseValidator',
        'OPTIONS': {
            'dictionary_file': '/path/to/dictionary.txt.gz',     # ❶
        }
    },
]

❶ 可选地覆盖字典路径

重新启动您的 Django 服务器,并刷新页面/ accounts / password_change /。请注意,新密码字段的所有四个输入约束都被一个约束替换:Your password must contain 4 words(图 9.2)。这与您从get_help_text方法返回的消息相同。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.2 需要口令的内置密码更改表单

最后,为 Bob 选择一个新的口令并提交表单。为什么要选择一个口令?一般来说:

  • Bob记住一个口令比记住一个常规密码更容易

  • Mallory猜测一个口令比猜测一个常规密码更难

提交表单后,服务器将您重定向到一个简单的模板,确认 Bob 的密码更改。在下一节中,我将解释 Bob 的密码是如何存储的。

9.2 密码存储

每个身份验证系统都存储着您密码的表示。当您登录时,必须根据用户名和密码的挑战来重现此密码。系统将您重现的密码与存储的表示进行比较,以验证您的身份。

组织以许多方式表示密码。一些方式比其他方式更安全。让我们看看三种方法:

  • 明文

  • 密文

  • 哈希值

明文 是存储用户密码的最严重的方式。在这种情况下,系统存储密码的文字副本。存储的密码与用户登录时用户复制的密码直接进行比较。这是一个可怕的做法,因为如果攻击者未经授权地访问密码存储,他将可以访问每个用户的帐户。这可能是来自组织外部的攻击者,也可能是系统管理员等员工。

明文密码存储

幸运的是,明文密码存储很少见。不幸的是,一些新闻机构通过轰动的标题制造了一个假象,即这种现象很常见。

例如,在 2019 年初,安全领域出现了一波标题,比如“Facebook 承认以明文形式存储密码”。任何看过标题后面内容的人都知道 Facebook 并不是故意以明文形式存储密码;Facebook 是在意外记录它们。

这是不可原谅的,但并非标题所宣传的那样。如果您在互联网上搜索“以明文形式存储密码”,您会发现关于雅虎和谷歌的安全事件的类似耸人听闻的标题。

将密码存储为密文并没有比将其存储为明文好多少。在这种情况下,系统加密每个密码并存储密文。当用户登录时,系统加密再现密码并将密文与存储中的密文进行比较。图 9.3 说明了这个可怕的想法。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.3 如何不存储密码

存储加密密码是一条很滑的坡。这意味着如果攻击者未经授权地访问密码存储和密钥,系统管理员通常都拥有这两者,那么攻击者就可以访问每个用户的账户。因此,加密密码对于恶意系统管理员或者可以操纵系统管理员的攻击者来说是一个容易的目标。

2013 年,超过 3800 万 Adobe 用户的加密密码被泄露并公开。这些密码是用 ECB 模式中的 3DES 加密的。(你在第四章学习了 3DES 和 ECB 模式。)一个月内,数百万这些密码被黑客和密码分析师逆向工程,或者破解

任何现代身份验证系统都不会存储您的密码;它会对您的密码进行哈希。当您登录时,系统会将您再现密码的哈希值与存储中的哈希值进行比较。如果两个值匹配,您就通过了身份验证。如果两个值不匹配,您必须再试一次。图 9.4 说明了这个过程的简化版本。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.4 基于哈希的密码验证的简化示例

密码管理是加密哈希函数属性的一个很好的现实世界示例。与加密算法不同,哈希函数是单向的;密码易于验证但难以恢复。碰撞抗性的重要性是显而易见的;如果两个密码与匹配的哈希值发生碰撞,任何一个密码都可以用来访问同一个账户。

一个哈希函数本身是否适合用于哈希密码?答案是否定的。2012 年,超过 600 万 LinkedIn 密码的哈希值被泄露并发布到一个俄罗斯黑客论坛。1 当时,LinkedIn 正在用 SHA1 对密码进行哈希,这是你在第二章学习过的一个哈希函数。两周内,超过 90%的密码被破解。

这些密码是如何被破解的呢?假设现在是 2012 年,Malory 想要破解最近发布的哈希值。她下载了包含被泄露的用户名和 SHA1 哈希值的表 9.1 数据集。

表 9.1 领英的摘要密码存储

username hash_value
alice 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
bob 6eb5f4e39660b2ead133b19b6996b99a017e91ff
charlie 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8

Malory 可以使用多种工具:

  • 常见密码列表

  • 哈希函数确定性

  • 彩虹表

首先,Malory 可以避免对每个可能的密码进行哈希,只需对最常见的密码进行哈希。之前,你了解了 Django 如何使用常见密码列表来执行密码策略。具有讽刺意味的是,Malory 可以使用相同的列表来破解没有此防御层的站点的密码。

其次,你有没有注意到 Alice 和 Charlie 的哈希值是相同的?Malory 不能立即确定任何人的密码,但是通过最小的努力,她知道 Alice 和 Charlie 使用相同的密码。

最后但并非最不重要的,Malory 可以尝试运气,使用 彩虹表。这是一个非常庞大的消息表,映射到预先计算的哈希值。这允许 Malory 快速找到哈希值映射到哪条消息(密码),而不必采用暴力破解;她可以用空间换时间。换句话说,她可以支付获取彩虹表的存储和传输成本,而不是支付暴力破解的计算开销。例如,project-rainbowcrack.com 上的 SHA1 彩虹表大小为 690 GB。

所有三个用户的密码都显示在表 9.2 中,这是一个极为简化的彩虹表。注意,Bob 使用的密码比 Alice 和 Charlie 的密码强得多。

表 9.2 Malory 下载的一个简化的 SHA1 彩虹表

hash_value sha1_password
5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 password
6eb5f4e39660b2ead133b19b6996b99a017e91ff +y;kns:]+7Y]

显然,单独使用哈希函数不适合用于密码哈希。在接下来的两个部分中,我会展示几种抵抗像 Malory 这样的攻击者的方法。

9.2.1 加盐哈希

加盐 是一种通过两个或更多相同消息计算出不同哈希值的方法。 是一串随机的字节,作为输入附加到消息中,输入到哈希函数中。每个消息都与一个唯一的盐值配对。图 9.5 展示了加盐哈希。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.5 对消息进行加盐会产生不同的哈希值。

在许多方面,盐就像哈希值,而初始化向量就像加密。在第四章中你学过 IVs。这里是一个比较:

  • 盐值个性化哈希值;IV 个性化密文。

  • 如果盐值丢失,加盐的哈希值就毫无用处;如果 IV 丢失,密文也毫无用处。

  • 盐值或 IV 与哈希值或密文一起未经混淆地存储。

  • 盐值或 IV 任何一个都不应该被重用。

警告:许多程序员混淆密钥,但这是两个完全不同的概念。盐和密钥的处理方式不同,产生不同的效果。盐值不是秘密,应该用于哈希一个且仅一个消息。密钥是用来保密的,可以用于哈希一个或多个消息。盐值用于区分相同消息的哈希值;密钥绝不应该用于此目的。

盐处理是对付像 Mallory 这样的黑客的有效对策。通过使每个哈希值个性化,Alice 和 Charlie 的相同密码哈希成不同的哈希值。这使得 Mallory 失去了线索:她不再知道 Alice 和 Charlie 有相同的密码。更重要的是,Mallory 无法使用彩虹表来破解加盐哈希值。因为彩虹表中没有加盐哈希值,因为彩虹表作者无法预先预测盐值。

以下代码演示了使用 BLAKE2 进行盐处理。(你在第二章学到了 BLAKE2。)这段代码对同一消息进行了两次哈希处理。每个消息都使用一个唯一的 16 字节盐进行哈希处理,从而产生一个唯一的哈希值:

>>> from hashlib import blake2b
>>> import secrets
>>> 
>>> message = b'same message'
>>> 
>>> sodium = secrets.token_bytes(16)       # ❶
>>> chloride = secrets.token_bytes(16)     # ❶
>>> 
>>> x = blake2b(message, salt=sodium)      # ❷
>>> y = blake2b(message, salt=chloride)    # ❷
>>> 
>>> x.digest() == y.digest()               # ❸
False                                      # ❸

❶ 生成两个随机的 16 字节盐值

❷ 相同的消息,不同的盐值

❸ 不同的哈希值

尽管 BLAKE2 内置支持盐处理,但不适合用于密码哈希,其他常规的加密哈希函数也是如此。这些函数的主要限制是反直觉的:这些函数太快了。哈希函数越快,通过暴力破解密码的成本就越低。这使得像 Mallory 这样的人更便宜地破解密码。

警告:BLAKE2 出现在本节是为了教学目的。它绝不能用于密码哈希。它太快了。

密码哈希是您实际上想要追求低效率的少数情况之一。快速是坏事;慢速是好事。常规哈希函数不是这项工作的正确工具。在下一节中,我将向您介绍一类设计上慢速的函数。

9.2.2 密钥派生函数

密钥 派生函数 (KDFs) 在计算机科学中占据着一个有趣的位置,因为它们是过度消耗资源的仅有的有效用例之一。这些函数在故意消耗大量计算资源、内存或两者的同时对数据进行哈希处理。因此,KDFs 已经取代了常规哈希函数成为哈希密码的最安全方式。资源消耗越高,使用暴力破解密码的成本就越高。

像哈希函数一样,KDF 接受一条消息并生成一个哈希值。该消息称为初始密钥,哈希值称为派生密钥。在本书中,我不使用初始密钥派生密钥这两个术语,以避免向您提供不必要的词汇。KDF 还接受一个盐。就像之前看到的 BLAKE2 一样,

salt 个性化每个哈希值。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.6 密钥派生函数接受一条消息、盐和至少一个配置参数。

与常规哈希函数不同,KDF 接受至少一个配置参数,旨在调整资源消耗。KDF 不仅运行缓慢;你告诉它要多慢。图 9.6 说明了 KDF 的输入和输出。

KDFs 通过其消耗的资源种类进行区分。所有的 KDF 都被设计为计算密集型;其中一些被设计为内存密集型。在本节中,我将研究其中的两种:

  • 基于密码的密钥派生函数 2

  • Argon2

基于密码的密钥派生函数 2PBKDF2)是一种流行的基于密码的 KDF。这可以说是 Python 中最广泛使用的 KDF,因为 Django 默认使用它来哈希密码。PBKDF2 被设计为包装并迭代调用哈希函数。迭代次数和哈希函数都是可配置的。在现实世界中,PBKDF2 通常包装一个 HMAC 函数,而 HMAC 函数又经常包装 SHA-256。图 9.7 描述了一个 PBKDF2 包装 HMAC-SHA256 的实例。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.7 SHA-256 被 HMAC 包装,HMAC 被 PBKDF2 包装

创建一个名为 pbkdf2.py 的文件,并将列表 9.7 中的代码添加到其中。此脚本为 PBKDF2 建立了一个简单的性能基准。

它开始通过解析命令行中的迭代次数。这个数字通过告诉 PBKDF2 调整它调用 HMAC-SHA256 的次数来调整 PBKDF2。接下来,脚本定义了一个名为 test 的函数;此函数包装了 Python 的 hashlib 模块中的 pbkdf2_hmac 函数。pbkdf2_hmac 函数期望一个底层哈希函数的名称、一条消息、一个盐和迭代次数。最后,脚本使用 timeit 模块记录运行测试方法 10 次所需的秒数。

列表 9.7 对 PBKDF2 包装 HMAC-SHA256 的单个调用

import hashlib
import secrets
import sys
import timeit

iterations = int(sys.argv[1])                                         # ❶

def test():
    message = b'password'
    salt = secrets.token_bytes(16)
    hash_value = hashlib.pbkdf2_hmac('sha256',
                                     message,
                                     salt,
                                     iterations)                      # ❷
    print(hash_value.hex())

if __name__ == '__main__':
    seconds = timeit.timeit('test()', number=10, globals=globals())   # ❸
    print('Seconds elapsed: %s' % seconds)

❶ 参数化迭代次数

❷ 调整资源消耗

❸ 运行测试方法 10 次

运行以下命令,以粗体字体显示,以执行具有 260,000 次迭代次数的脚本。在撰写本文时,Django 在使用 PBKDF2 哈希密码时默认使用此数字。输出的最后一行,也以粗体显示,是运行 PBKDF2 10 次所需的秒数:

$ python pbkdf2.py 260000
685a8d0d9a6278ac8bc5f854d657dde7765e0110f145a07d8c58c003815ae7af
fd723c866b6bf1ce1b2b26b2240fae97366dd2e03a6ffc3587b7d041685edcdc
5f9cd0766420329df6886441352f5b5f9ca30ed4497fded3ed6b667ce5c095d2
175f2ed65029003a3d26e592df0c9ef0e9e1f60a37ad336b1c099f34d933366d
1725595f4d288f0fed27885149e61ec1d74eb107ee3418a7c27d1f29dfe5b025
0bf1335ce901bca7d15ab777ef393f705f33e14f4bfa8213ca4da4041ad1e8b1
c25a06da375adec19ea08c8fe394355dced2eb172c89bd6b4ce3fecf0749aff9
a308ecca199b25f00b9c3348ad477c93735fbe3754148955e4cafc8853a4e879
3e8be1f54f07b41f82c92fbdd2f9a68d5cf5f6ee12727ecf491c59d1e723bb34
135fa69ae5c5a5832ad1fda34ff8fcd7408b6b274de621361148a6e80671d240
Seconds elapsed: 2.962819952

接下来,在命令行的末尾添加一个 0 并再次运行脚本。请注意响应时间的急剧增加,如下所示(粗体显示):

$ python pbkdf2.py 2600000
00f095ff2df1cf4d546c79a1b490616b589a8b5f8361c9c8faee94f11703bd51
37b401970f4cab9f954841a571e4d9d087390f4d731314b666ca0bc4b7af88c2
99132b50107e37478c67e4baa29db155d613619b242208fed81f6dde4d15c4e7
65dc4bba85811e59f00a405ba293958d1a55df12dd2bb6235b821edf95ff5ace
7d9d1fd8b21080d5d2870241026d34420657c4ac85af274982c650beaecddb7b
2842560f0eb8e4905c73656171fbdb3141775705f359af72b1c9bfce38569aba
246906cab4b52bcb41eb1fd583347575cee76b91450703431fe48478be52ff82
e6cd24aa5efdf0f417d352355eefb5b56333389e8890a43e287393445acf640e
d5f463c5e116a3209c92253a8adde121e49a57281b64f449cf0e89fc4c9af133
0a52b3fca5a77f6cb601ff9e82b88aac210ffdc0f2ed6ec40b09cedab79287d8
Seconds elapsed: 28.934859217

当 Bob 登录 Django 项目时,他必须等待 PBKDF2 返回一次。如果 Mallory 尝试破解 Bob 的密码,她必须一次又一次地等待它返回,直到她生成了 Bob 的任何密码。如果 Bob 选择了一个密码短语,这个任务可能需要比 Mallory 活着的时间更长。

类似 Mallory 这样的攻击者经常使用 图形处理单元GPUs)来将暴力破解攻击的时间减少数个数量级。GPU 是专门的处理器,最初设计用于渲染图形。与 CPU 类似,GPU 使用多个核心处理数据。CPU 核心比 GPU 核心更快,但是 GPU 可以比 CPU 拥有更多的核心。这使 GPU 能够在许多可并行化的子任务中表现出色。此类任务包括机器学习、比特币挖掘,以及——你猜对了——密码破解。密码学家对这种威胁作出了回应,创建了一代新的 KDF,旨在抵抗这种类型的攻击。

2013 年,一群密码学家和安全从业者宣布了一个新的密码哈希竞赛(PHC)。其目标是选择并标准化一个能够抵抗现代破解技术的密码哈希算法(password-hashing.net)。两年后,名为 Argon2 的基于密码的 KDF 赢得了 PHC。

Argon2 既是内存密集型又是计算密集型。这意味着一个有抱负的密码破解者必须获取大量的内存以及大量的计算资源。Argon2 因其抵抗 FPGA 和 GPU 驱动的破解而受到赞扬。

Argon2 的主力是 BLAKE2。这是具有让人惊讶的缓慢速度的 Argon2 的讽刺。底层是什么?一个以速度著称的哈希函数。

注意:对于新项目,请使用 Argon2。PBKDF2 是一个比平均水平更好的 KDF,但不是最适合这项工作的工具。稍后我将向你展示如何将 Django 项目从 PBKDF2 迁移到 Argon2。

在下一节中,我将向你展示如何在 Django 中配置密码哈希。这使你可以加固 PBKDF2 或将其替换为 Argon2。

9.3 配置密码哈希

Django 的密码哈希是高度可扩展的。通常情况下,通过 settings 模块进行配置。PASSWORD_HASHERS 设置是一个密码哈希函数列表。默认值是四个密码哈希函数实现的列表。这些密码哈希器中的每一个都包装了一个 KDF。前三个应该看起来很熟悉:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.PBKDF2PasswordHasher',
   'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
   'django.contrib.auth.hashers.Argon2PasswordHasher',
   'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]

Django 在密码列表中使用第一个密码哈希函数来对新密码进行哈希。这发生在你的账户创建时以及你更改密码时。哈希值存储在数据库中,可以用于验证未来的认证尝试。

列表中的任何密码哈希器都可以根据先前存储的哈希值验证认证尝试。例如,使用前面示例配置的项目将使用 PBKDF2 对新密码或更改的密码进行哈希,但它可以验证先前由 PBKDF2SHA1、Argon2 或 BCryptSHA256 哈希的密码。

每次用户成功登录时,Django 会检查他们的密码是否是使用列表中的第一个密码哈希器哈希的。如果不是,则会重新使用第一个密码哈希器对密码进行哈希,并将哈希值存储在数据库中。

9.3.1 本地密码哈希器

Django 原生支持 10 个密码哈希器。MD5PasswordHasherSHA1PasswordHasher及其非盐值对应项都是不安全的。这些组件已用粗体显示。Django 保留这些密码哈希器以向后兼容旧系统:

  • django.contrib.auth.hashers.PBKDF2PasswordHasher

  • django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher

  • django.contrib.auth.hashers.Argon2PasswordHasher

  • django.contrib.auth.hashers.BCryptSHA256PasswordHasher

  • django.contrib.auth.hashers.BCryptPasswordHasher

  • **django.contrib.auth.hashers.SHA1PasswordHasher**

  • **django.contrib.auth.hashers.MD5PasswordHasher**

  • **django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher**

  • **django.contrib.auth.hashers.UnsaltedMD5PasswordHasher**

  • django.contrib.auth.hashers.CryptPasswordHasher

警告:使用SHA1PasswordHasherMD5PasswordHasherUnsaltedSHA1PasswordHasherUnsaltedMD5PasswordHasher配置 Django 项目是不安全的。因为这些组件生成的密码易于破解,底层哈希函数速度快且加密弱。本章后面,我将向你展示如何解决这个问题。

在编写本文时,Django 默认使用具有 260,000 次迭代的PBKDF2PasswordHasher。随着每个新版本的发布,Django 开发团队会增加迭代次数。希望自行增加此值的 Python 程序员可以使用自定义密码哈希器。如果系统不幸卡在旧版本的 Django 上,则这是有用的。

9.3.2 自定义密码哈希器

当扩展本地密码哈希器时,配置自定义密码哈希器非常简单。请看以下代码中的TwoFoldPBKDF2PasswordHasher。此类继承自PBKDF2PasswordHasher,并将迭代次数增加了两倍。请记住,这样的配置更改并非没有代价。根据设计,此更改也会增加登录延迟:

from django.contrib.auth.hashers import PBKDF2PasswordHasher

class TwoFoldPBKDF2PasswordHasher(PBKDF2PasswordHasher):

    iterations = PBKDF2PasswordHasher.iterations * 2      # ❶

❶ 将迭代次数加倍

通过PASSWORD_HASHERS配置自定义密码哈希器,就像本地密码哈希器一样:

PASSWORD_HASHERS = [
    'profile_info.hashers.TwoFoldPBKDF2PasswordHasher',
]

TwoFoldPBKDF2PasswordHasher可以验证先前由PBKDF2PasswordHasher计算的哈希值的认证尝试,因为底层的 KDF 是相同的。这意味着在现有生产系统上可以安全地进行这样的更改。当用户进行身份验证时,Django 会升级先前存储的哈希值。

9.3.3 Argon2 密码哈希

每个新的 Django 项目都应该使用 Argon2 进行密码哈希。如果在系统推送到生产环境之前进行此更改,这将只花费你几秒钟的时间。如果想要在用户为自己创建账户之后再进行此更改,工作量将会大幅增加。本节介绍了简单的方法;下一节将介绍困难的方法。

配置 Django 使用 Argon2 很容易。首先,确保Argon2PasswordHasherPASSWORD_HASHERS中的第一个且唯一的密码哈希器。接下来,在虚拟环境中运行以下命令。这将安装argon2-cffi软件包,为Argon2PasswordHasher提供一个 Argon2 实现。

$ pipenv install django[argon2]

警告:在已经处于生产状态的系统上将所有默认密码哈希器替换为Argon2PasswordHasher是不明智的。这样做会阻止现有用户登录。

如果系统已经处于生产状态,则Argon2PasswordHasher将无法单独验证现有用户的未来身份验证尝试;旧用户账户将变得不可访问。在这种情况下,Argon2PasswordHasher必须是PASSWORD_HASHERS的首选,并且传统的密码哈希器应该是尾部。这样配置 Django 可以使用 Argon2 对新用户的密码进行哈希。Django 还会在用户登录时将现有用户的密码升级为 Argon2。

警告:Django 仅在用户进行身份验证时才升级现有的密码哈希值。如果每个用户在短时间内都进行身份验证,则这不是问题,但通常情况下并非如此。

更强的密码哈希器提供的安全性直到用户升级后登录才会被用户意识到。对于一些用户,这可能是几秒钟;对于其他用户,永远不会发生。在他们登录之前,原始哈希值将保持不变(可能是脆弱的)存储在密码存储中。下一节将解释如何将所有用户迁移到升级后的密码哈希器。

9.3.4 迁移密码哈希器

在 2012 年 6 月,在 LinkedIn 宣布泄露的同一周,超过 150 万个 eharmony 密码的未加盐哈希值被泄露并发布。您可以在 defuse.ca/files/eharmony-hashes.txt 上查看。当时,eharmony 使用的是 MD5 进行密码哈希,这是你在第二章学到的一种不安全的哈希函数。根据一个破解者 (mng.bz/jBPe) 的说法:

如果 eharmony 在它们的哈希中使用了盐,就像它们应该的那样,我就不能运行这次攻击了。事实上,盐会迫使我对每个哈希值分别进行字典攻击,这将花费我超过 31 年的时间。

让我们考虑 eharmony 如何缓解这个问题。假设 Alice 在 eharmony 的第一天上班。她继承了一个具有以下配置的现有系统:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

这个系统的作者因使用 UnsaltedMD5PasswordHasher 而被解雇。现在轮到 Alice 负责将系统迁移到 Argon2PasswordHasher 而不会出现任何停机。该系统有 150 万用户,因此她不能强制每个用户都重新登录。可以理解的是,产品经理不想重置每个帐户的密码。Alice 意识到前进的唯一方法是对密码进行两次哈希,一次使用 UnsaltedMD5PasswordHasher,再一次使用 Argon2PasswordHasher。Alice 的游戏计划是 添加-迁移-删除:

  1. 添加 Argon2PasswordHasher

  2. 迁移哈希值

  3. 删除 UnsaltedMD5PasswordHasher

首先,Alice 将 Argon2PasswordHasher 添加到 PASSWORD_HASHERS 中。这将问题限制在那些最近没有登录的现有用户身上。引入 Argon2PasswordHasher 是简单的一部分;摆脱 UnsaltedMD5PasswordHasher 则是困难的一部分。Alice 将 UnsaltedMD5PasswordHasher 保留在列表中以确保现有用户可以访问他们的帐户:

PASSWORD_HASHERS = [
 'django.contrib.auth.hashers.Argon2PasswordHasher',       # ❶
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

❶ 将 Argon2PasswordHasher 添加到列表的开头

接下来,Alice 必须迁移哈希值;这是大部分工作。她不能只是用 Argon2 重新哈希密码,所以她必须将它们双重哈希。换句话说,她计划从数据库中读取每个 MD5 哈希值,并将其传递到 Argon2 中;Argon2 的输出,另一个哈希值,然后将替换数据库中的原始哈希值。Argon2 需要盐并且比 MD5 慢得多;这意味着像 Mallory 这样的破解者需要超过 31 年才能破解这些密码。图 9.8 说明了 Alice 的迁移计划。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.8 用 MD5 哈希一次,然后用 Argon2 哈希

Alice 不能仅仅修改生产认证系统的哈希值而不影响用户。Argon2PasswordHasherUnsaltedMD5PasswordHasher 都不知道如何处理新的哈希值;用户将无法登录。在 Alice 修改哈希值之前,她必须首先编写并安装一个能够解释新哈希值的自定义密码哈希器。

Alice 编写了 UnsaltedMD5ToArgon2PasswordHasher,如列表 9.8 所示。这个密码哈希器弥合了 Argon2PasswordHasherUnsaltedMD5PasswordHasher 之间的差距。和所有密码哈希器一样,这个哈希器实现了两个方法:encode 和 verify。当你设置密码时,Django 调用 encode 方法;这个方法负责对密码进行哈希。当你登录时,Django 调用 verify 方法;这个方法负责比较数据库中的原始哈希值和重现密码的哈希值。

列表 9.8 使用自定义密码哈希器迁移哈希值

from django.contrib.auth.hashers import (
    Argon2PasswordHasher,
    UnsaltedMD5PasswordHasher,
)

class UnsaltedMD5ToArgon2PasswordHasher(Argon2PasswordHasher):

    algorithm = '%s->%s' % (UnsaltedMD5PasswordHasher.algorithm,
                            Argon2PasswordHasher.algorithm)

    def encode(self, password, salt):                  # ❶
        md5_hash = self.get_md5_hash(password)         # ❷
        return self.encode_md5_hash(md5_hash, salt)    # ❷

    def verify(self, password, encoded):               # ❸
        md5_hash = self.get_md5_hash(password)         # ❹
        return super().verify(md5_hash, encoded)       # ❹

    def encode_md5_hash(self, md5_hash, salt):
        return super().encode(md5_hash, salt)

    def get_md5_hash(self, password):
        hasher = UnsaltedMD5PasswordHasher()
        return hasher.encode(password, hasher.salt())

❶ 当你设置密码时由 Django 调用

❷ 使用 MD5 和 Argon2 进行哈希

❸ 当你登录时由 Django 调用

❹ 比较哈希值

爱丽丝在PASSWORD_HASHERS中添加了UnsaltedMD5ToArgon2PasswordHasher,如下面代码中加粗显示的部分所示。这没有立即效果,因为尚未修改任何密码哈希值;每个用户的密码仍然使用 MD5 或 Argon2 哈希:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.Argon2PasswordHasher',
 'django_app.hashers.UnsaltedMD5ToArgon2PasswordHasher',
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

爱丽丝现在终于有能力检索每个 MD5 哈希值,用 Argon2 哈希,并将其存储回数据库。爱丽丝使用 Django 的 迁移 执行计划的这部分。迁移让 Django 程序员可以在纯 Python 中协调数据库更改。通常,迁移会修改数据库模式;爱丽丝的迁移只会修改数据。

列表 9.9 展示了爱丽丝的迁移过程。它首先加载每个带有 MD5 哈希密码的账户的User模型对象。对于每个用户,MD5 哈希值会被 Argon2 哈希。然后将 Argon2 哈希值写入数据库。

列表 9.9 用于双重哈希的数据迁移

from django.db import migrations
from django.db.models.functions import Length
from django_app.hashers import UnsaltedMD5ToArgon2PasswordHasher

def forwards_func(apps, schema_editor):
   User = apps.get_model('auth', 'User')                         # ❶
   unmigrated_users = User.objects.annotate(                     # ❷
       text_len=Length('password')).filter(text_len=32)          # ❷

   hasher = UnsaltedMD5ToArgon2PasswordHasher()
   for user in unmigrated_users:
       md5_hash = user.password
       salt = hasher.salt()
       user.password = hasher.encode_md5_hash(md5_hash, salt)    # ❸
       user.save(update_fields=['password'])                     # ❹

class Migration(migrations.Migration):

   dependencies = [
       ('auth', '0011_update_proxy_permissions'),                # ❺
   ]

   operations = [
       migrations.RunPython(forwards_func),
   ]

❶ 引用了用户模型

❷ 检索具有 MD5 哈希密码的用户

❸ 用 Argon2 哈希每个 MD5 哈希值

❹ 保存双重哈希值

❺ 确保此代码在密码表创建后运行

爱丽丝知道这个操作将花费不止几分钟;Argon2 是故意设计得慢。与此同时,在生产环境中,UnsaltedMD5ToArgon2PasswordHasher 用于验证这些用户。最终,每个密码都会在没有停机时间的情况下迁移;这打破了对UnsaltedMD5PasswordHasher的依赖。

最后,爱丽丝从PASSWORD_HASHERS中删除了UnsaltedMD5PasswordHasher。她还确保由它创建的哈希值被从所有现有生产数据库的备份副本中删除或废弃:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.Argon2PasswordHasher',
   'django_app.hashers.UnsaltedMD5ToArgon2PasswordHasher',
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

像大多数添加-迁移-删除工作一样,第一步和最后一步是最容易的。添加-迁移-删除不仅适用于密码迁移。这种思维方式对于任何类型的迁移工作(例如,将 URL 更改为服务,切换库,重命名数据库列)都是有用的。

到目前为止,你已经学到了很多关于密码管理的知识。你已经将一个密码更改工作流程组合成了两个内置视图。你了解密码在存储中的表示方式,并知道如何安全地对其进行哈希。在下一节中,我将向你展示另一个基于密码的工作流程,由另外四个内置视图组成���

9.4 密码重置工作流程

鲍勃忘记了他的密码。在这一部分,你将帮助他通过另一个工作流程重置密码。你很幸运;这次你不必编写任何代码。在上一章中,当你将八个 URL 路径映射到内置的 Django 视图时,你已经完成了这项工作。密码重置工作流程由这些视图中的最后四个组成:

  • PasswordResetView

  • PasswordResetDoneView

  • PasswordResetConfirmView

  • PasswordResetCompleteView

Bob 通过未经身份验证的请求进入密码重置页面的工作流程。该页面呈现一个表单。他输入了他的电子邮件,提交表单,然后收到了一封带有密码重置链接的电子邮件。Bob 点击链接,进入一个页面,在那里他重置了密码。图 9.9 说明了这个工作流程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.9 密码重置工作流程

退出站点并重新启动您的 Django 服务器。将浏览器指向密码重置页面 https:/./localhost:8000/accounts/password_reset/。按设计,此页面可供未经身份验证的用户访问。此页面有一个表单,一个字段:用户的电子邮件地址。输入bob@bob.com并提交表单。

密码重置页面的表单提交由PasswordResetView处理。如果与账户关联的入站电子邮件地址,将向该地址发送带有密码重置链接的电子邮件。如果电子邮件地址未与账户关联,此视图将不发送任何内容。这可以防止恶意的匿名用户使用您的服务器向某人发送未经请求的电子邮件。

密码重置 URL 包含用户的 ID 和一个令牌。这个令牌不仅仅是一串随机的字符和数字;它是一个带键哈希值。PasswordResetView使用 HMAC 函数生成这个哈希值。消息是一些用户字段,如 ID 和last_login。密钥是SECRET_KEY设置。图 9.10 说明了这个过程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.10 Bob 提交密码重置请求并收到密码重置令牌;该令牌是一个带键哈希值。

在上一章中,您配置了 Django 将电子邮件重定向到控制台。从控制台复制并粘贴 Bob 的密码重置 URL 到另一个浏览器选项卡中。这将传递密码重置令牌和用户的 ID 回服务器。服务器使用用户 ID 重建令牌。然后重建的令牌与入站密码重置令牌进行比较。如果两个令牌匹配,服务器知道它是令牌的作者;Bob 被允许更改他的密码。如果令牌不匹配,服务器知道入站密码重置令牌是伪造的或被篡改的。这可以防止像 Mallory 这样的人为别人的帐户重置密码。

密码重置令牌不可重复使用。如果 Bob 想再次重置密码,他必须重新开始并完成工作流程。这减轻了 Mallory 在 Bob 收到密码重置电子邮件后访问 Bob 的电子邮件账户的风险。在这种情况下,Mallory 仍然可以伤害 Bob,但她无法使用旧的和被遗忘的密码重置电子邮件更改 Bob 的密码。

密码重置令牌有一个过期时间。这也减轻了 Mallory 访问 Bob 的密码重置电子邮件的风险。默认的密码重置超时时间为三天。这对于一个社交媒体网站来说是合理的,但对于导弹制导系统来说是不合适的。只有你可以确定你构建的系统的适当值。

使用PASSWORD_RESET_TIMEOUT设置来配置密码重置的过期时间(以秒为单位)。该设置弃用了PASSWORD_RESET_TIMEOUT_DAYS,对于某些系统来说,这种设置太粗糙了。

在前几章中,您学到了很多关于哈希和认证的知识。在本章中,您了解了这两个主题之间的关系。更改和重置密码是任何系统的基本功能;两者都严重依赖哈希。到目前为止,您学到的关于认证的知识为下一章的主题做好了准备,即授权。

总结

  • 不要重复造轮子;使用内置的 Django 组件更改和重置用户密码。

  • 使用密码验证强制和微调您的密码策略。

  • 用盐哈希抵御暴力破解攻击。

  • 不要使用常规哈希函数对密码进行哈希;始终使用密钥派生函数,最好选择 Argon2。

  • 使用 Django 数据迁移迁移遗留密码哈希值。

  • 密码重置工作流是数据认证和键控哈希的又一应用。


  1. 在 2016 年,LinkedIn 承认这个数字实际上超过了 1.7 亿。

第十章:授权

本章内容包括

  • 创建超级用户和权限

  • 管理组成员

  • 使用 Django 强制应用程序级别的授权

  • 测试授权逻辑

认证和授权往往容易混淆。认证 关系到用户是谁;授权 关系到用户可以做什么。认证和授权通常分别称为 authnauthz。认证是授权的先决条件。在本章中,我涵盖了与应用程序开发相关的授权,也称为 访问控制。在下一章中,我将继续介绍 OAuth 2,一种标准化的授权协议。

注:在撰写本文时,破坏授权是 OWASP 十大关键安全风险清单上的第五项(owasp.org/www-project-top-ten/)。

你将从应用程序级别的权限授权开始本章。权限 是授权的最原子形式。它授权一个人或一组人只能执行一件事情。接下来,你将为 Alice 创建一个超级用户帐户。然后你将以 Alice 的身份登录 Django 管理控制台,在那里你将管理用户和组权限。之后,我将向你展示几种应用权限和组来控制谁可以访问受保护的资源。

10.1 应用程序级授权

在这一部分,你将创建一个名为messaging的新 Django 应用程序。该应用程序使你接触到 Django 授权、权限的最基本元素。要创建新的消息应用程序,请在项目根目录中运行以下命令。此命令将在一个名为 messaging 的新目录中生成一个 Django 应用程序:

$ python manage.py startapp messaging

生成的应用程序的目录结构如图 10.1 所示。在这个练习中,你将在models模块中添加一个类,并通过对migrations包进行一些添加来多次修改数据库。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.1 新 Django 应用程序 messaging 的目录结构

现在你需要在 Django 项目中注册你的 Django 应用程序。打开settings模块,找到INSTALLED_APPS列表。添加你在这里看到的加粗字体的行。确保不要改变之前安装的所有其他应用程序:

INSTALLED_APPS = [
    ...
 'messaging',
]

接下来,打开 models.py 并将以下模型类定义放入其中。AuthenticatedMessage表示一个消息和一个具有两个属性的哈希值。在第十四章中,Alice 和 Bob 将使用此类进行安全通信:

from django.db.models import Model, CharField

class AuthenticatedMessage(Model):
    message = CharField(max_length=100)
    hash_value = CharField(max_length=64)

正如所有模型一样,AuthenticatedMessage必须映射到一个数据库表。表是通过 Django 迁移创建的。(你在上一章学习过迁移。)映射由 Django 内置的 ORM 框架在运行时处理。

运行以下命令为你的模型类生成一个迁移脚本。此命令将自动检测新模型类并在迁移目录下创建一个新的迁移脚本,显示为粗体字体:

$ python manage.py makemigrations messaging
Migrations for 'messaging':
 messaging/migrations/0001_initial.py      # ❶
    - Create model AuthenticatedMessage

❶ 新的迁移脚本

最后,通过运行以下命令执行你的迁移脚本,显示为粗体:

$ python manage.py migrate
Running migrations:
  Applying messaging.0001_initial... OK

运行你的迁移脚本不仅会创建一个新的数据库表,还会在后台创建四个新的权限。下一节将解释这些权限存在的方式和原因。

10.1.1 权限

Django 使用内置模型 Permission 来表示权限。Permission 模型是 Django 授权的最基本元素。每个用户可以关联零到多个权限。权限分为两类:

  • 由 Django 自动创建的默认权限

  • 由你创建的自定义权限

Django 会自动为每个新模型创建四个默认权限。当运行迁移时,这些权限在后台创建。这些权限允许用户创建、读取、更新和删除模型。在 Django shell 中执行以下代码,观察AuthenticatedMessage模型的所有四个默认权限,显示为粗体:

$ python manage.py shell
>>> from django.contrib.auth.models import Permission
>>> 
>>> permissions = Permission.objects.filter(
...     content_type__app_label='messaging',
...     content_type__model='authenticatedmessage')
>>> [p.codename for p in permissions]
['add_authenticatedmessage', 'change_authenticatedmessage', 
'delete_authenticatedmessage', 'view_authenticatedmessage']

随着项目的发展,通常会需要自定义权限。通过将一个内部Meta类添加到你的模型中来声明这些权限。打开你的models模块,并向AuthenticatedMessage添加以下Meta类,显示为粗体,Meta类的permissions属性定义了两个自定义权限。这些权限指定了哪些用户可以发送和接收消息:

class AuthenticatedMessage(Model):       # ❶
    message = CharField(max_length=100)
    mac = CharField(max_length=64)

 class Meta:                          # ❷
 permissions = [
 ('send_authenticatedmessage', 'Can send msgs'),
 ('receive_authenticatedmessage', 'Can receive msgs'),
 ]

❶ 你的模型类

❷ 你的模型 Meta 类

与默认权限类似,自定义权限在迁移期间会自动创建。使用以下命令生成一个新的迁移脚本。如粗体字体的输出所示,此命令会在迁移目录下生成一个新的脚本:

$ python manage.py makemigrations messaging --name=add_permissions
Migrations for 'messaging':
 messaging/migrations/0002_add_permissions.py      # ❶
    - Change Meta options on authenticatedmessage

❶ 新的迁移脚本

接下来,使用以下命令执行你的迁移脚本:

$ python manage.py migrate
Running migrations:
  Applying messaging.0002_add_permissions... OK

现在,你已经向你的项目添加了一个应用、一个模型、一个数据库表和六个权限。在下一节中,你将为 Alice 创建一个账户,以她的身份登录,并将这些新权限授予 Bob。

10.1.2 用户和组管理

在本节中,你将创建一个超级用户 Alice。超级用户是具有执行所有操作权限的特殊管理用户;这些用户拥有所有权限。作为 Alice,你将访问 Django 内置的管理控制台。默认情况下,该控制台在每个生成的 Django 项目中都是启用的。管理控制台的简要介绍将向你介绍 Django 如何实现应用级授权。

如果您的 Django 项目能够提供静态内容,则管理控制台更易于使用且更好看。Django 可以自行通过 HTTP 完成此操作,但 Gunicorn 不设计通过 HTTPS 完成此操作。这个问题很容易通过 WhiteNoise 解决,它是一个专门设计用于有效地提供静态内容并最小化设置复杂性的软件包(如图 10.2 所示)。管理控制台(以及项目的其余部分)将使用 WhiteNoise 正确地向您的浏览器提供 JavaScript、样式表和图像。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.2 一个 Django 应用服务器使用 WhiteNoise 提供静态资源。

在虚拟环境中运行以下 pipenv 命令来安装 WhiteNoise:

$ pipenv install whitenoise

现在,您需要通过中间件在 Django 中激活 WhiteNoise。什么是中间件?中间件是 Django 中的一个轻量级子系统,位于每个入站请求和您的视图之间的中间,以及您的视图和每个出站响应之间的中间。从这个位置上,中间件应用前后处理逻辑。

中间件逻辑由一组中间件组件实现。每个组件都是一个独特的小型处理挂钩,负责执行特定任务。例如,内置的 AuthenticationMiddleware 类负责将入站 HTTP 会话 ID 映射到用户。我在后面的章节中介绍的一些中间件组件负责管理与安全相关的响应头。在本节中添加的组件 WhiteNoiseMiddleware 负责提供静态资源。

与 Django 的每个其他子系统一样,中间件在 settings 模块中进行配置。打开您的 settings 模块并找到 MIDDLEWARE 设置。该设置是一个中间件组件类名称列表。如下代码中加粗显示的那样,将 WhiteNoiseMiddleware 添加到 MIDDLEWARE 中。确保此组件紧随 SecurityMiddleware 之后,并位于所有其他内容之前。不要移除任何现有的中间件组件:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',   # ❶
    'whitenoise.middleware.WhiteNoiseMiddleware',      # ❷
    ...
]

❶ 确保 SecurityMiddleware 保持在第一位。

❷ 将 WhiteNoise 添加到您的项目中

警告:每个生成的 Django 项目都使用 SecurityMiddleware 作为第一个 MIDDLEWARE 组件进行初始化。SecurityMiddleware 实现了一些先前介绍过的安全特性,如 Strict-Transport-Security 响应头和 HTTPS 重定向。如果将其他中间件组件放在 SecurityMiddleware 前面,这些安全特性就会受到影响。

重新启动您的服务器,并将浏览器指向 https:/./localhost:8000/admin/ 的管理控制台登录页面。登录页面应该会显示如图 10.3. 所示。如果您的浏览器以没有样式的相同表单呈现,则表示 WhiteNoise 尚未安装。如果 MIDDLEWARE 配置错误或服务器未重新启动,则会发生这种情况。管理控制台仍将在没有 WhiteNoise 的情况下工作;它只是看起来不太好而已。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.3 Django 的管理登录页面

管理控制台登录页面需要具有超级用户或工作人员身份的用户的身份验证凭据;Django 不允许常规最终用户登录管理控制台。

从项目根目录运行以下命令以创建超级用户。此命令在您的数据库中创建一个超级用户;它将提示您输入新超级用户的密码:

$ python manage.py createsuperuser \
         --username=alice --email=alice@alice.com

作为 Alice 登录管理控制台。作为超级用户,您可以从管理登录页面管理组和用户。单击组旁边的添加,导航到新的组输入表单。

小组

提供了一种将一组权限与一组用户关联起来的方法。一个组可以与零到多个权限以及零到多个用户关联。与组关联的每个权限都隐式授予该组的每个用户。

新的组输入表单,如图 10.4 所示,需要组名称和可选权限。请花一分钟观察可用权限。注意它们分成了四组。每个批次代表数据库表的默认权限,控制谁可以创建、读取、更新和删除行。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.4 新的组输入表单接受组名称和多个组权限。

浏览可用权限选择器,并找到您为消息应用程序创建的权限。与其他批次不同,这个批次有六个元素:四个默认权限和两个自定义权限。

在名称字段中输入observersobservers组旨在对每个表具有只读访问权限。选择包含文本“Can view”的每个可用权限。通过单击保存提交表单。

提交表单后,您将被带到列出所有组的页面。通过单击左侧边栏中的“用户”导航到列出所有用户的类似页面。当前,此页面仅列出 Alice 和 Bob。通过单击其名称,导航到 Bob 的用户详细信息页面。向下滚动用户详细信息页面,直到找到两个相邻的组和权限部分。在此部分中,如图 10.5 所示,将 Bob 分配到observers组,并为他赋予消息应用程序的所有六个权限。滚动到底部,然后单击保存。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.5 作为管理员分配组和权限

小组成员资格和权限不需要手动管理;相反,您可以通过编程方式进行管理。列表 10.1 展示了如何通过User模型的两个属性授予和撤销权限。小组成员资格通过groups属性授予和撤销。user_permissions属性允许向用户添加或删除权限。

列表 10.1 编程方式管理组和权限

from django.contrib.auth.models import User
from django.contrib.auth.models import Group, Permission

bob = User.objects.get(username='bob')                                  # ❶
observers = Group.objects.get(name='observers')                         # ❶
can_send = Permission.objects.get(codename='send_authenticatedmessage') # ❶

bob.groups.add(observers)                                               # ❷
bob.user_permissions.add(can_send)                                      # ❸

bob.groups.remove(observers)                                            # ❹
bob.user_permissions.remove(can_send)                                   # ❺

❶ 检索模型实体

❷ 将 Bob 添加到一个组

❸ 为 Bob 添加权限

❹ 将 Bob 从一个组中移除

❺ 从 Bob 中移除一个权限

到目前为止,你已经了解了组和权限是如何工作的。你知道它们是什么,如何创建它们,以及如何将它们应用到用户身上。但它们在实际应用中是什么样子呢?在接下来的部分,你将开始使用组和权限来解决问题。

10.2 强制授权

授权的整个目的是防止用户做他们不应该做的事情。这适用于系统内的操作,比如阅读敏感信息,以及系统外的操作,比如指挥飞行交通。在 Django 中有两种实施授权的方式:低级的困难方式和高级的简单方式。在本节中,我将先展示困难的方式。之后,我将向你展示如何测试你的系统是否正确地执行了授权。

10.2.1 低级的困难方式

User 模型提供了几种设计用于程序化权限检查的低级方法。下面的代码展示了 has_perm 方法,它允许你访问默认权限和自定义权限。在这个例子中,Bob 不被允许创建其他用户,但允许接收消息:

>>> from django.contrib.auth.models import User
>>> bob = User.objects.get(username='bob')
>>> bob.has_perm('auth.add_user')                            # ❶
False                                                        # ❶
>>> bob.has_perm('messaging.receive_authenticatedmessage')   # ❷
True                                                         # ❷

❶ Bob 无法添加用户。

❷ Bob 可以接收消息。

对于超级用户,has_perm 方法将始终返回 True

>>> alice = User.objects.get(username='alice')
>>> alice.is_superuser                         # ❶
True                                           # ❶
>>> alice.has_perm('auth.add_user')
True

❶ Alice 可以做任何事情。

has_perms 方法提供了一种方便的方式来一次检查多个权限:

>>> bob.has_perms(['auth.add_user',                              # ❶
...                'messaging.receive_authenticatedmessage'])    # ❶
False                                                            # ❶
>>> 
>>> bob.has_perms(['messaging.send_authenticatedmessage',        # ❷
...                'messaging.receive_authenticatedmessage'])    # ❷
True                                                             # ❷

❶ Bob 无法添加用户和接收消息。

❷ Bob 可以发送和接收消息。

低级 API 并没有错,但你应该尽量避免使用它,原因有两个:

  • 低级权限检查需要比我后面介绍的方法更多的代码行。

  • 更重要的是,以这种方式检查权限容易出错。例如,如果你查询这个 API 关于一个不存在的权限,它将简单地返回 False

>>> bob.has_perm('banana')
False

这是另一个陷阱。权限是一次从数据库中批量获取并缓存的。这带来了一个危险的折衷。一方面,has_permhas_perms 在每次调用时不会触发数据库查询。另一方面,当你在将权限应用到用户之后立即检查权限时,你必须小心。下面的代码片段演示了为什么。在这个例子中,一个权限被从 Bob 那里拿走了。不幸的是,本地权限状态没有被更新:

>>> perm = 'messaging.send_authenticatedmessage'    # ❶
>>> bob.has_perm(perm)                              # ❶
True                                                # ❶
>>> 
>>> can_send = Permission.objects.get(              # ❷
...     codename='send_authenticatedmessage')       # ❷
>>> bob.user_permissions.remove(can_send)           # ❷
>>> 
>>> bob.has_perm(perm)                              # ❸
True                                                # ❸

❶ Bob 从权限开始。

❷ Bob 失去了权限。

❸ 本地副本无效。

继续使用同一个例子,当在 User 对象上调用 refresh_from_db 方法时会发生什么?本地权限状态仍然没有被更新。为了获取最新状态的副本,必须重新从数据库加载一个新的 User 模型:

>>> bob.refresh_from_db()                     # ❶
>>> bob.has_perm(perm)                        # ❶
True                                          # ❶
>>> 
>>> reloaded = User.objects.get(id=bob.id)    # ❷
>>> reloaded.has_perm(perm)                   # ❷
False                                         # ❷

❶ 本地副本仍然无效。

❷ 重新加载的模型对象有效。

这是第三个陷阱。列表 10.2 定义了一个视图。这个视图在渲染敏感信息之前执行授权检查。它有两个错误。你能发现其中任何一个吗?

列表 10.2 如何不强制授权

from django.shortcuts import render
from django.views import View

class UserView(View):

    def get(self, request):
        assert request.user.has_perm('auth.view_user')   # ❶
        ...
        return render(request, 'sensitive_info.html')    # ❷

❶ 检查权限

❷ 渲染敏感信息

第一个错误在哪里?与许多编程语言一样,Python 有一个 assert 语句。该语句评估一个条件,如果条件为 False,则会引发一个 AssertionError。在这个例子中,条件是一个权限检查。在开发和测试环境中,assert 语句非常有用,但是当 Python 使用 -O 选项调用时,它们会产生一种虚假的安全感。(此选项代表 优化。)作为一种优化,Python 解释器会移除所有 assert 语句。在控制台中键入以下两个命令,自己看一下:

$ python -c 'assert 1 == 2'               # ❶
Traceback (most recent call last):        # ❶
  File "<string>", line 1, in <module>    # ❶
AssertionError                            # ❶
$ python -Oc 'assert 1 == 2'              # ❷

❶ 引发 AssertionError

❷ 不引发任何内容

警告 assert 语句是调试程序的一种好方法,但不应用于执行权限检查。除了权限检查之外,assert 语句也不应用于一般应用程序逻辑。这包括所有安全检查。-O 标志在开发或测试环境中很少使用;它经常在生产中使用。

第二个错误在哪里?假设断言实际上是在您的生产环境中执行的。与任何错误一样,服务器会将 AssertionError 转换为状态码 500. 根据 HTTP 规范的定义,此代码指定为内部服务器错误(tools.ietf.org/html/rfc7231)。您的服务器现在阻止未经授权的请求,但未生成有意义的 HTTP 状态码。一个出于善意的客户端现在收到这个代码,并错误地得出根本问题是服务器端的结论。

未经授权的请求的正确状态码是 403. 服务器发送状态码 403 以指定资源为禁止。此状态码在本章中出现了两次,从下一节开始。

10.2.2 高级简单方法

现在我将向您展示简单的方法。这种方法更清洁,您不必担心任何上述的陷阱。Django 预装了几个专为授权而设计的内置 mixin 和装饰器。使用以下高级工具比使用一堆 if 语句更清洁:

  • PermissionRequiredMixin

  • @permission_required

PermissionRequiredMixin 强制执行各个视图的授权。此类自动检查与每个传入请求关联的用户的权限。您可以使用 permission_required 属性指定要检查的权限。此属性可以是表示一个权限的字符串,也可以是表示多个权限的字符串可迭代对象。

在第 10.3 节的视图中继承自 PermissionRequiredMixin,如粗体字所示。permission_required 属性,也以粗体字显示,确保在处理请求之前用户必须具有查看经过身份验证的消息的权限。

在 PermissionRequiredMixin 中进行授权的第 10.3 节

from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import JsonResponse

class AuthenticatedMessageView(PermissionRequiredMixin, View):     # ❶
 permission_required = 'messaging.view_authenticatedmessage'    # ❷

    def get(self, request):
         ...
         return JsonResponse(data)

❶ 确保权限已检查

❷ 声明要检查的权限

PermissionRequiredMixin 对匿名请求作出响应,将浏览器重定向到登录页面。如预期,对未经授权的请求作出状态码为 403 的响应。

@permission_required 装饰器是 PermissionRequiredMixin 的功能等效物。列表 10.4 演示了 @permission_ required 装饰器的授权,它显示在粗体中,对基于函数的视图进行了授权。与前一个示例类似,此代码确保用户必须具有权限查看已认证消息才能处理请求。

10.4 列表使用 @permission_required 进行授权

from django.contrib.auth.decorators import permission_required
from django.http import JsonResponse

@permission_required('messaging.view_authenticatedmessage', raise_exception=True)                      # ❶
def authenticated_message_view(request):        # ❷
    ...                                         # ❷
    return JsonResponse(data)                   # ❷

❶ 在处理请求之前检查权限

❷ 基于函数的视图

有时您需要使用比简单的权限检查更复杂的逻辑来保护资源。以下一对内置实用程序旨在使用任意 Python 强制授权;它们在其他方面的行为类似于 PermissionRequiredMixin@permission_required 装饰器:

  • UserPassesTestMixin

  • @user_passes_test

在粗体显示的列表 10.5 中,UserPassesTestMixin 保护了使用 Python 中任意逻辑的视图。此实用程序为每个请求调用 test_func 方法。此方法的返回值确定了是否允许该请求。在此示例中,用户必须具有新账户或为 Alice。

10.5 列表使用 UserPassesTestMixin 进行授权

from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import JsonResponse

class UserPassesTestView(UserPassesTestMixin, View):

    def test_func(self):                                                # ❶
        user = self.request.user                                        # ❶
        return user.date_joined.year > 2020 or user.username == 'alice' # ❶

    def get(self, request):
        ...
        return JsonResponse(data)

❶ 任意授权逻辑

在粗体显示的列表 10.6 中,@user_passes_test 装饰器是 UserPassesTestMixin 的功能等效物。与 UserPassesTestMixin 不同,@user _passes_test 装饰器对未经授权的请求作出将浏览器重定向到登录页面的响应。在此示例中,用户必须具有来自 alice.com 的电子邮件地址或名为 bob 的名字。

10.6 列表使用 @user_passes_test 进行授权

from django.contrib.auth.decorators import user_passes_test
from django.http import JsonResponse

def test_func(user):                                                     # ❶
    return user.email.endswith('@alice.com') or user.first_name == 'bob' # ❶

@user_passes_test(test_func)
def user_passes_test_view(request):                                      # ❷
    ...                                                                  # ❷
    return JsonResponse(data)                                            # ❷

❶ 任意授权逻辑

❷ 基于函数的视图

10.2.3 条件渲染

通常,向用户显示他们无权执行的操作是不可取的。例如,如果 Bob 没有权限删除其他用户,您希望避免用一个删除用户的链接或按钮误导他。解决方案是有条件地呈现控件:您将其从用户那里隐藏,或以禁用状态显示给他们。

基于授权的条件渲染内置于默认的 Django 模板引擎中。您通过 perms 变量访问当前用户的权限。以下模板代码说明了如何在当前用户被允许发送消息时有条件地呈现链接。perms 变量已用粗体标出:

{% if perms.messaging.send_authenticatedmessage %}
    <a href='/authenticated_message_form/'>Send Message</a>
{% endif %}

或者,您可以使用此技术将控件呈现为已禁用状态。以下控件对任何人都可见;仅对被允许创建新用户的人启用:

<input type='submit'
       {% if not perms.auth.add_user %} disabled {% endif %}
       value='Add User'/>

警告:永远不要让条件渲染成为一种虚假的安全感。它永远不会取代服务器端的授权检查。这适用于服务器端和客户端的条件渲染。

不要被这个功能所误导。条件渲染是改善用户体验的好方法,但它并不是执行授权的有效方法。控件是隐藏还是禁用都无关紧要;这两种情况都不能阻止用户向服务器发送恶意请求。授权必须在服务器端执行;其他任何事情都不重要。

10.2.4 测试授权

在第八章中,你了解到认证对于测试来说不是障碍;这也适用于授权。清单 10.7 展示了如何验证你的系统是否正确地保护了受保护的资源。

TestAuthorization 的设置方法创建并验证了一个名为 Charlie 的新用户。测试方法从断言 Charlie 被禁止查看消息开始,显示为粗体。(你之前学过服务器用状态码 403 来传达这一信息。)然后,测试方法验证了在授予 Charlie 权限后他可以查看消息;网络服务器用状态码 200 来传达这一信息,也显示为粗体。

清单 10.7 测试授权

from django.contrib.auth.models import User, Permission

class TestAuthorization(TestCase):

    def setUp(self):
        passphrase = 'fraying unwary division crevice'     # ❶
        self.charlie = User.objects.create_user(           # ❶
            'charlie', password=passphrase)                # ❶
        self.client.login(
            username=self.charlie.username, password=passphrase)

    def test_authorize_by_permission(self):
        url = '/messaging/authenticated_message/'
        response = self.client.get(url, secure=True)       # ❷
 self.assertEqual(403, response.status_code)        # ❷

        permission = Permission.objects.get(               # ❸
            codename='view_authenticatedmessage')          # ❸
        self.charlie.user_permissions.add(permission)      # ❸

        response = self.client.get(url, secure=True)       # ❹
 self.assertEqual(200, response.status_code)        # ❹

❶ 为 Charlie 创建账户

❷ 断言无法访问

❸ 授予权限

❹ 断言可以访问

在前一节中,你学会了如何授予权限;在本节中,你学会了如何执行权限。我认为可以肯定地说,这个主题不像本书中的其他一些材料那么复杂。例如,TLS 握手和密钥派生函数要复杂得多。尽管授权看起来很简单,但令人惊讶的是,有相当高的组织都做错了。在下一节中,我会向你展示一个规则,以避免这种情况。

10.3 反模式和最佳实践

2020 年 7 月,一小群攻击者成功进入了 Twitter 的一个内部管理系统。攻击者通过这个系统重置了 130 个知名 Twitter 账户的密码。埃隆·马斯克、乔·拜登、比尔·盖茨等许多公众人物的账户受到了影响。其中一些被劫持的账户随后被用于针对数百万 Twitter 用户进行比特币诈骗,获得了约 12 万美元的收入。

根据两名前 Twitter 员工的说法,超过 1000 名员工和承包商可以访问受损的内部管理系统(mng.bz/9NDr)。尽管 Twitter 拒绝就此数字发表评论,但我可以肯定地说这并不会使他们比大多数组织更糟糕。大多数组织至少有一个糟糕的内部工具,允许太多权限被授予太多用户。

这种反模式,即每个人都可以做任何事情,源于组织未能应用最小权限原则。正如第一章所指出的,PLP 表明用户或系统只应被赋予执行其职责所需的最低权限。越少越好;要保守行事。

相反,一些组织有太多的权限和太多的群组。这些系统更安全,但行政和技术维护成本是高得令人难以承受的。一个组织如何平衡?一般来说,你应该偏爱以下两个经验法则:

  • 通过组成员资格授予权限。

  • 通过独立的独立权限强制执行授权。

这种方法可以减少技术成本,因为每次一个群体增加或减少用户或职责时,你的代码都不需要改变。行政成本保持低廉,但前提是每个群体都以有意义的方式定义。作为一个经验法则,创建模拟实际现实世界组织角色的群体。如果你的用户属于“销售代表”或“后端运营经理”这样的类别,你的系统可能只需要用一个组来模拟他们。在为群体命名时不要创造性;只需使用他们自己称呼的名字。

授权是任何安全系统的重要组成部分。你知道如何授予、强制执行和测试它。在本章中,你了解了在应用程序开发中应用的这个主题。在下一章中,我将继续讲述这个主题,介绍 OAuth 2,一个授权协议。这个协议允许用户授权第三方访问受保护的资源。

概要

  • 认证与你是谁有关;授权与你能做什么有关。

  • 用户、组和权限是授权的构建模块。

  • WhiteNoise 是一种简单而高效的静态资源服务方式。

  • Django 的管理控制台使超级用户能够管理用户。

  • 更倾向于使用高级授权 API 而不是低级 API。

  • 通常情况下,通过独立权限来强制执行授权;通过组成员资格授予权限。