Django中的密码管理

密码管理通常不应该被重新再设计,Django 努力提供了一个安全且灵活的管理用户密码的工具。这篇文档描述了 Django 如何存储密码,如何配置存储哈希,和一些使用哈希密码的工具。

参见

即使用户使用了很强壮的密码,攻击者还是可以窃听他们的网络链接。用户使用 HTTPS 可以避免通过纯 HTTP 链接发送密码(或其他一些敏感数据),因为它们很容易被密码嗅探。

Django 如何存储密码

Django 提供灵活的密码存储系统,默认使用 PBKDF2。

User 对象的 password 属性是如下这种格式:

<algorithm>$<iterations>$<salt>$<hash>

这些是用来存储用户密码的插件,以美元符号分隔,包括:哈希算法,算法迭代次数(工作因子),随机 Salt 和最终的密码哈希值。该算法是 Django 可以使用的单向哈希或密码存储算法中的一种;见下文。迭代描述了算法在哈希上运行的次数。Salt 是所使用的随机种子,哈希是单向函数的结果。

默认情况下,Django 使用带有 SHA256 哈希的 PBKDF2 算法,它是 NIST 推荐的密码延展机制。它足够安全,需要大量的运算时间才能破解,这对大部分用户来说足够了。

但是,根据你的需求,你可以选择不同的算法,甚至使用自定义的算法来匹配特定的安全场景。再次强调,大部分用户没必要这么做,如果你不确定的话,很可能并不需要。如果你坚持要做,请继续阅读:

Django 通过查阅 PASSWORD_HASHERS 的设置来选择算法。这是一个 Django 支持的哈希算法类列表,第一个条目( settings.PASSWORD_HASHERS[0] )将被用来存储密码,其他条目都是有效的哈希函数,可用来检测已存密码。这意味着如果你想使用不同算法,你需要修改 PASSWORD_HASHERS ,在列表中首选列出你的算法。

PASSWORD_HASHERS 的默认值是:

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 和 bcrypt 来检测已存储的密码。

接下来的部分描述了高级用户修改这个配置的几个常见方法。

在 Django 中使用 Argon2

Argon2 是2015年哈希密码竞赛的获胜者,这是一个社区为选择下一代哈希算法而主办的公开竞赛。它被设计成在定制硬件上计算比普通CPU上计算要容易。

Argon2 并不是 Django 的默认首选,因为它依赖第三方库。尽管哈希密码竞赛主办方建议立即使用 Argon2 ,而不是 Django 提供的其他算法。

使用 Argon2 作为你的默认存储算法,需要以下步骤:

  1. 安装 argon2-cffi 库。通过 pip install django[argon2] 安装,相当于 pip install argon2-cffi (以及 Django 的 setup.py 的任何版本要求)。

  2. 修改 PASSWORD_HASHERS 配置,把 Argon2PasswordHasher 放在首位。如下:

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

    如果你需要 Django 升级密码( upgrade passwords ),请保留或添加这个列表中的任何条目。

在 Django 中使用 bcrypt

Bcrypt 是一个非常流行的密码存储算法,尤其是为长期密码存储设计。Django 默认不使用它,因为它需要使用第三方库,但由于很多人想使用它,Django 只需要很少的努力就能支持 bcrypt 。

使用 Bcrypt 作为你的默认存储算法,需要以下步骤:

  1. 安装 bcrypt 库。通过 pip install django[bcrypt] 安装,相当于 pip install bcrypt (以及 Django 的 setup.py 的任何版本要求)。

  2. 修改 PASSWORD_HASHERS 配置,把 BCryptSHA256PasswordHasher 放在首位。如下:

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

    如果你需要 Django 升级密码( upgrade passwords ),请保留或添加这个列表中的任何条目。

现在 Django 将使用 Bcrypt 作为默认存储算法。

增加工作因子

PBKDF2 和 bcrypt

PBKDF2 和 bcrypt 算法使用一些迭代或几轮哈希。这样有意降低了攻击者的速度,使得破解密码变得更困难。然而,随着算力的增加,迭代的次数也需要增加。我们已经选择了合理的默认配置(也会对 Django 的每个版本加入),但你可能希望根据你的安全需求和可支配的能力来调高或调低它。为此,你将子类化合适的算法并覆盖 iterations 参数。比如,增加默认 PBKDF2 算法使用的一些迭代:

  1. 创建 django.contrib.auth.hashers.PBKDF2PasswordHasher 的子类:

    from django.contrib.auth.hashers import PBKDF2PasswordHasher
    
    class MyPBKDF2PasswordHasher(PBKDF2PasswordHasher):
        """
        A subclass of PBKDF2PasswordHasher that uses 100 times more iterations.
        """
        iterations = PBKDF2PasswordHasher.iterations * 100
    

    在你的项目某些位置中保存。比如,你可以放在类似 myproject/hashers.py 里。

  2. PASSWORD_HASHERS 中把新哈希放在首位:

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

现在 Django 使用 PBKDF2 存储密码时将会多次迭代。

Argon2

Argon2 有三个可以自定义的属性:

  1. time_cost 控制哈希的次数。
  2. memory_cost 控制被用来计算哈希时的内存大小。
  3. parallelism 控制并行计算哈希的 CPU 数量。

这三个属性的默认值足够适合你。如果你确定密码哈希过快或过慢,可以按如下方式调整它:

  1. 选择 parallelism 你可以节省计算哈希的线程数。
  2. 选择 memory_cost 你可以节省内存的 KiB 。
  3. 调整 time_cost 和估计哈希一个密码所需的时间。挑选出你可以接受的 time_cost 。如果设置为1的 time_cost 慢的无法接受,则调低 memory_cost

memory_cost 说明

argon2 命令行工具和一些其他的库解释了 memory_cost 参数不同于 Django 使用的值。换算公式是``memory_cost == 2 ** memory_cost_commandline`` 。

密码升级

当用户登录时,如果用户的密码使用首选算法以外的算法保存,Django 会自动升级这个算法成为首选算法。这意味着旧的 Django 安装会在用户登录时自动得到更多的安全,并且当它们创建时你可以切换到新的更好的存储算法。

然而,Django 只会使用 PASSWORD_HASHERS 提到的算法升级密码,因此当你升级到新系统时你要确保你从没有删除过这个列表的条目。如果你删除过,那么使用的没有列出的算法的用户将不会升级。当增加(或减少) PBKDF2 迭代的次数或 bcrypt 的轮次,哈希过的密码将被更新。

注意,如果数据库内的所有密码没有在默认哈希算法里编码,则由于非默认算法的密码编码的用户登录请求持续时间和不存在用户(运行过默认哈希)的登录请求持续时间的不同,你可能会受到用户枚举时间攻击。你可以使用升级旧密码的哈希值来缓解此问题。

无需登录的密码升级

如果数据库拥有老旧低效的哈希算法,比如 MD5 或 SHA1,那么你也许希望自己升级哈希而不是等待用户登录后升级(如果一个用户不再登录站点,密码就不会升级了)。在这里,你可以包装一下密码哈希。

比如,你想迁移一个 SHA1 哈希集合来使用 PBKDF2(SHA1(password)) ,并添加相应的密码哈希来检查用户在登陆时是否输入了正确的密码。我们假设我们正在使用内建的 User 模型,并有一个 accounts app 。你可以修改模式以使用任何算法或自定义的模型。

首先,我们添加一个自定义的哈希:

accounts/hashers.py
from django.contrib.auth.hashers import (
    PBKDF2PasswordHasher, SHA1PasswordHasher,
)


class PBKDF2WrappedSHA1PasswordHasher(PBKDF2PasswordHasher):
    algorithm = 'pbkdf2_wrapped_sha1'

    def encode_sha1_hash(self, sha1_hash, salt, iterations=None):
        return super().encode(sha1_hash, salt, iterations)

    def encode(self, password, salt, iterations=None):
        _, _, sha1_hash = SHA1PasswordHasher().encode(password, salt).split('$', 2)
        return self.encode_sha1_hash(sha1_hash, salt, iterations)

数据迁移可能类似于这样:

accounts/migrations/0002_migrate_sha1_passwords.py
from django.db import migrations

from ..hashers import PBKDF2WrappedSHA1PasswordHasher


def forwards_func(apps, schema_editor):
    User = apps.get_model('auth', 'User')
    users = User.objects.filter(password__startswith='sha1$')
    hasher = PBKDF2WrappedSHA1PasswordHasher()
    for user in users:
        algorithm, salt, sha1_hash = user.password.split('$', 2)
        user.password = hasher.encode_sha1_hash(sha1_hash, salt)
        user.save(update_fields=['password'])


class Migration(migrations.Migration):

    dependencies = [
        ('accounts', '0001_initial'),
        # replace this with the latest migration in contrib.auth
        ('auth', '####_migration_name'),
    ]

    operations = [
        migrations.RunPython(forwards_func),
    ]

注意,迁移将对上千名用户花费大约数十分钟,这取决于你的硬件速度。

最后,我们在 PASSWORD_HASHERS 中添加配置:

mysite/settings.py
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
    'accounts.hashers.PBKDF2WrappedSHA1PasswordHasher',
]

包含你的站点使用的此列表中的其他算法。

已包含的哈希

在 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',
]

相应的算法名是:

  • pbkdf2_sha256
  • pbkdf2_sha1
  • argon2
  • bcrypt_sha256
  • bcrypt
  • sha1
  • md5
  • unsalted_sha1
  • unsalted_md5
  • crypt

编写你自己的哈希

如果你编写自己的密码哈希包含工作因子,比如迭代数量。你应该实现一个 harden_runtime(self, password, encoded) 方法来消除编码密码时提供的工作因子和默认的哈希工作因子之间的运行时间差。这样可以防止用户枚举时间攻击,因为旧的迭代次数中对密码编码的用户与不存在的用户(运行默认哈希的默认迭代次数)在登录时存在差异。

以 PDKDF2 为例,如果编码包含20000次迭代,并且默认哈希迭代是30000,那么该方法应该通过另外的10000次迭代的 PBKDF2 运行密码。

如果你的哈希没有工作因子,可以将该方法实现为 no-op (pass) 。

手动管理用户的密码

The django.contrib.auth.hashers module provides a set of functions to create and validate hashed passwords. You can use them independently from the User model.

check_password(password, encoded)[源代码]

如果你想通过对比纯文本密码和数据库中的哈希密码来验证用户,可以使用 check_password() 快捷函数。它需要2个参数:要检查的纯文本密码和要检查的数据库中用户密码字段的值。如果匹配成功,返回 True ,否则返回 False

make_password(password, salt=None, hasher='default')[源代码]

通过此应用的格式创建一个哈希密码。它需要一个必需的参数:纯文本密码。或者,如果你不想使用默认配置( PASSWORD_HASHERS 配置的首个条目 ),那么可以提供 salt 和 使用的哈希算法。有关每个哈希的算法名,可查看 已包含的哈希 。如果密码参数是 None ,将返回一个不可用的密码(永远不会被 check_password() 通过的密码)。

is_password_usable(encoded_password)[源代码]

如果密码是 User.set_unusable_password() 的结果,则返回 False

Changed in Django 2.1:

在旧版本中,如果密码是 None 或空字符串或使用不在 PASSWORD_HASHERS 中的哈希,那么也会返回 False 。这个行为被认为是一个 bug ,因为它阻止有这样密码的用户通过请求重置密码。

密码验证

用户经常会选择弱密码。为了缓解这个问题,Django 提供可插拔的密码验证。你可以同时配置多个密码验证。Django 已经包含了一些验证,但你也可以很方便的编写你自己的验证。

每个密码验证器必须提供给用户提供帮助文案以向用户解释要求,验证密码并在不符合要求时返回错误信息,并且可选择接受已经设置过的密码。验证器也可以使用可选设置来微调它们的行为。

验证由 AUTH_PASSWORD_VALIDATORS 控制。默认的设置是一个空列表,意味着没有可应用的验证器。在使用默认的 startproject 创建新项目中,启用了一个简单的验证器设置。

默认情况下,验证器在重置或修改密码的表单中使用,也可以在 createsuperuserchangepassword 命令中使用。验证器不能应用在模型层,比如 User.objects.create_user()create_superuser() ,因为我们假设开发者(非用户)会在模型层与 Django 进行交互,也因为模型验证不会在创建模型时自动运行。

注解

密码验证器可以防止使用很多类型的弱密码。但是,密码通过所有的验证器并不能保证它就是强密码。这里有很多因素削弱即便最先进的密码验证程序也检测不到的密码。

启用密码验证

AUTH_PASSWORD_VALIDATORS 中设置密码验证:

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

这个例子启用了所有包含的验证器:

  • UserAttributeSimilarityValidator 检查密码和一组用户属性集合之间的相似性。
  • MinimumLengthValidator 用来简单检查密码是否符合最小长度。这个验证器可以自定义设置:它现在需要最短9位字符,而不是默认的8个字符。
  • CommonPasswordValidator 检查密码是否在常用密码列表中。默认情况下,它会与列表中的2000个常用密码作比较。
  • NumericPasswordValidator 检查密码是否是完全是数字的。

对于 UserAttributeSimilarityValidatorCommonPasswordValidator 我们可以简单的使用这个例子中的默认配置。NumericPasswordValidator 不需要设置。

帮助文本和来自密码验证器的任何错误信息始终按照 AUTH_PASSWORD_VALIDATORS 列出的顺序返回。

已包含的验证器

Django 包含了四种验证器:

class MinimumLengthValidator(min_length=8)[源代码]

验证密码是否符合最小长度。最小长度可以在 min_length 参数中自定义。

class UserAttributeSimilarityValidator(user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7)[源代码]

验证密码是否与用户的某些属性有很大的区别。

user_attributes 参数应该是可比较的用户属性名的可迭代参数。如果没有提供这个参数,默认使用:'username', 'first_name', 'last_name', 'email' 。不存在的属性会被忽略。

不合格密码的最小相似度被设置为0到1这个区间。设置为0会拒绝所有密码,而设置为1只会拒绝与属性值相同的密码。

class CommonPasswordValidator(password_list_path=DEFAULT_PASSWORD_LIST_PATH)[源代码]

验证密码是否是常用密码。先转换密码为小写字母(做一个不区分大小写的比较),然后根据 Royce Williams 创建的2000个常用密码的列表进行检查。

password_list_path 用来设置自定义的常用密码列表文件的路径。这个文件应该每行包含一个小写密码,并且文件是纯文本或 gzip 压缩过的。

Changed in Django 2.1:

在旧版本中使用的是1000个常用密码列表。

class NumericPasswordValidator[源代码]

检查密码是否完全是数字。

集成检查

django.contrib.auth.password_validation 包含一些你可以在表单或其他地方调用的函数,用来集成密码检查。如果你使用自定义表单来进行密码设置或者你有允许密码设置的 API 调用,此功能会很有用。

validate_password(password, user=None, password_validators=None)[源代码]

验证密码。如果所有验证器验证密码有效,则返回 None 。如果一个或多个验证器拒绝此密码,将会引发 ValidationError 和验证器的错误信息。

user 对象是可选的:如果不提供用户对象,一些验证器将不能执行验证,并将接受所有密码。

password_changed(password, user=None, password_validators=None)[源代码]

通知所有验证器密码已经更改。这可以由验证器使用,例如防止密码重用。一旦密码更改成功,则调用此方法。

对于 AbstractBaseUser 子类,当调用 set_password() 是会将密码字段标记为 “dirty” ,这会在用户保存后调用 password_changed()

password_validators_help_texts(password_validators=None)[源代码]

返回一个所有验证器帮助文案的列表。这些向用户解释了密码要求。

password_validators_help_text_html(password_validators=None)

返回一个``<ul>`` ,包含所有帮助文案的 HTML 字符串。这在表单中添加密码验证时有帮助,因为你可以直接将输出传递到表单字段的 help_text 参数。

get_password_validators(validator_config)[源代码]

返回一个基于 validator_config 的验证器对象的集合。默认情况下,所有函数使用 AUTH_PASSWORD_VALIDATORS 定义的验证器,但通过一个验证器替代集合来调用此函数,然后向其他函数传递的密码验证器参数传递结果,将使用你自定义的验证器集合。当你有一个应用于大多数场景的通用的验证器集合时,需要一个自定义的集合来用于特殊情况。当你始终使用同一个验证器集合时,则不需要这个函数,因为默认使用是 AUTH_PASSWORD_VALIDATORS 的配置。

validator_config 的结构和 AUTH_PASSWORD_VALIDATORS 的结构相同。这个函数的返回值可以传递给上述函数列表的``password_validators`` 参数。

注意,如果将密码传递给其中一个函数,应该始终是明文密码,而不是哈希过的密码。

编写自定义的验证器

如果 Django 内置的验证器不满足你的需求,你可以编写自定义的验证器。验证器是相当简单的类。它们必须实现两个方法:

  • validate(self, password, user=None) :验证密码。如果密码有效,返回 None ,否则引发 ValidationError 错误。你必须能够处理 userNone 的情况,如果这样会让验证器无法运行,只需返回 None 即可。
  • get_help_text() :提供一个帮助文本向用户解释密码要求。

验证器的 AUTH_PASSWORD_VALIDATORS 中, OPTIONS 里的任何条目将会传递到构造器中。所有构造器参数应该有一个默认值。

这里是一个验证器的基本示例,其中包含一个可选的设置:

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

class MinimumLengthValidator:
    def __init__(self, min_length=8):
        self.min_length = min_length

    def validate(self, password, user=None):
        if len(password) < self.min_length:
            raise ValidationError(
                _("This password must contain at least %(min_length)d characters."),
                code='password_too_short',
                params={'min_length': self.min_length},
            )

    def get_help_text(self):
        return _(
            "Your password must contain at least %(min_length)d characters."
            % {'min_length': self.min_length}
        )

你也可以实现 password_changed(password, user=None) ,在密码修改成功后调用。比如说用来防止密码重用。但是,如果你决定存储用户之前的密码,则不应该以明文形式存储。