Django中的密码管理

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

参见

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

Django 如何存储密码

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

The password attribute of a User object is a string in this format:

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

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

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

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

Django chooses the algorithm to use by consulting the PASSWORD_HASHERS setting. This is a list of hashing algorithm classes that this Django installation supports.

For storing passwords, Django will use the first hasher in PASSWORD_HASHERS. To store new passwords with a different algorithm, put your preferred algorithm first in PASSWORD_HASHERS.

For verifying passwords, Django will find the hasher in the list that matches the algorithm name in the stored password. If a stored password names an algorithm not found in PASSWORD_HASHERS, trying to verify it will raise ValueError.

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.contrib.auth.hashers.ScryptPasswordHasher",
]

这意味着 Django 除了使用 PBKDF2 来存储所有密码,也支持使用 PBKDF2SHA1 、argon2 和 bcrypt 来检测已存储的密码。

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

在 Django 中使用 Argon2

Argon2 is the winner of the 2015 Password Hashing Competition, a community organized open competition to select a next generation hashing algorithm. It's designed not to be easier to compute on custom hardware than it is to compute on an ordinary CPU. The default variant for the Argon2 password hasher is Argon2id.

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

To use Argon2id as your default storage algorithm, do the following:

  1. Install the argon2-cffi package. This can be done by running python -m pip install django[argon2], which is equivalent to python -m pip install argon2-cffi (along with any version requirement from Django's setup.cfg).

  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.contrib.auth.hashers.ScryptPasswordHasher",
    ]
    

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

在 Django 中使用 bcrypt

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

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

  1. Install the bcrypt package. This can be done by running python -m pip install django[bcrypt], which is equivalent to python -m pip install bcrypt (along with any version requirement from Django's setup.cfg).

  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.contrib.auth.hashers.ScryptPasswordHasher",
    ]
    

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

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

在 Django 中使用 scrypt

scrypt is similar to PBKDF2 and bcrypt in utilizing a set number of iterations to slow down brute-force attacks. However, because PBKDF2 and bcrypt do not require a lot of memory, attackers with sufficient resources can launch large-scale parallel attacks in order to speed up the attacking process. scrypt is specifically designed to use more memory compared to other password-based key derivation functions in order to limit the amount of parallelism an attacker can use, see RFC 7914 for more details.

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

  1. Modify PASSWORD_HASHERS to list ScryptPasswordHasher first. That is, in your settings file:

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

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

备注

scrypt 需要 OpenSSL 1.1及更高版本。

增加盐的熵值

大多数密码哈希值包括一个盐,与他们的密码哈希值一起,以防止彩虹表攻击。盐本身是一个随机值,它增加了彩虹表的大小和成本,目前在 BasePasswordHasher 中的 salt_entropy 值设置为 128 比特。随着计算和存储成本的降低,这个值应该被提高。当实现你自己的密码散列器时,你可以自由地覆盖这个值,以便为你的密码散列器使用一个理想的熵值。salt_entropy 是以比特为单位。

实现细节

由于盐值的存储方法,salt_entropy 值实际上是一个最小值。例如,一个 128 的值将提供一个实际包含 131 位熵的盐。

增加工作因子

PBKDF2 和 bcrypt

The PBKDF2 and bcrypt algorithms use a number of iterations or rounds of hashing. This deliberately slows down attackers, making attacks against hashed passwords harder. However, as computing power increases, the number of iterations needs to be increased. We've chosen a reasonable default (and will increase it with each release of Django), but you may wish to tune it up or down, depending on your security needs and available processing power. To do so, you'll subclass the appropriate algorithm and override the iterations parameter (use the rounds parameter when subclassing a bcrypt hasher). For example, to increase the number of iterations used by the default PBKDF2 algorithm:

  1. Create a subclass of 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.contrib.auth.hashers.ScryptPasswordHasher",
    ]
    

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

备注

bcrypt rounds is a logarithmic work factor, e.g. 12 rounds means 2 ** 12 iterations.

Argon2

Argon2 has the following attributes that can be customized:

  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`` 。

scrypt

scrypt has the following attributes that can be customized:

  1. work_factor 控制哈希的次数。
  2. block_size
  3. parallelism controls how many threads will run in parallel.
  4. maxmem limits the maximum size of memory that can be used during the computation of the hash. Defaults to 0, which means the default limitation from the OpenSSL library.

We've chosen reasonable defaults, but you may wish to tune it up or down, depending on your security needs and available processing power.

Estimating memory usage

scrypt_的最低内存需求是:

work_factor * 2 * block_size * 64

so you may need to tweak maxmem when changing the work_factor or block_size values.

密码升级

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

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

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

无需登录的密码升级

If you have an existing database with an older, weak hash such as MD5, you might want to upgrade those hashes yourself instead of waiting for the upgrade to happen when a user logs in (which may never happen if a user doesn't return to your site). In this case, you can use a "wrapped" password hasher.

For this example, we'll migrate a collection of MD5 hashes to use PBKDF2(MD5(password)) and add the corresponding password hasher for checking if a user entered the correct password on login. We assume we're using the built-in User model and that our project has an accounts app. You can modify the pattern to work with any algorithm or with a custom user model.

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

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


class PBKDF2WrappedMD5PasswordHasher(PBKDF2PasswordHasher):
    algorithm = "pbkdf2_wrapped_md5"

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

    def encode(self, password, salt, iterations=None):
        _, _, md5_hash = MD5PasswordHasher().encode(password, salt).split("$", 2)
        return self.encode_md5_hash(md5_hash, salt, iterations)

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

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

from ..hashers import PBKDF2WrappedMD5PasswordHasher


def forwards_func(apps, schema_editor):
    User = apps.get_model("auth", "User")
    users = User.objects.filter(password__startswith="md5$")
    hasher = PBKDF2WrappedMD5PasswordHasher()
    for user in users:
        algorithm, salt, md5_hash = user.password.split("$", 2)
        user.password = hasher.encode_md5_hash(md5_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.PBKDF2WrappedMD5PasswordHasher",
]

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

已包含的哈希

在 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.ScryptPasswordHasher",
    "django.contrib.auth.hashers.MD5PasswordHasher",
]

相应的算法名是:

  • pbkdf2_sha256
  • pbkdf2_sha1
  • argon2
  • bcrypt_sha256
  • bcrypt
  • scrypt
  • md5

编写你自己的哈希

如果你编写自己的密码哈希包含工作因子,比如迭代数量。你应该实现一个 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, setter=None, preferred='default')
acheck_password(password, encoded, asetter=None, preferred='default')

Asynchronous version: acheck_password()

If you'd like to manually authenticate a user by comparing a plain-text password to the hashed password in the database, use the convenience function check_password(). It takes two mandatory arguments: the plain-text password to check, and the full value of a user's password field in the database to check against. It returns True if they match, False otherwise. Optionally, you can pass a callable setter that takes the password and will be called when you need to regenerate it. You can also pass preferred to change a hashing algorithm if you don't want to use the default (first entry of PASSWORD_HASHERS setting). See 已包含的哈希 for the algorithm name of each hasher.

Changed in Django 5.0:

acheck_password() method was added.

make_password(password, salt=None, hasher='default')

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

is_password_usable(encoded_password)

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

密码验证

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

Each password validator must provide a help text to explain the requirements to the user, validate a given password and return an error message if it does not meet the requirements, and optionally define a callback to be notified when the password for a user has been changed. Validators can also have optional settings to fine tune their behavior.

验证由 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)

Validates that the password is of a minimum length. The minimum length can be customized with the min_length parameter.

class UserAttributeSimilarityValidator(user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7)

Validates that the password is sufficiently different from certain attributes of the user.

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

The maximum allowed similarity of passwords can be set on a scale of 0.1 to 1.0 with the max_similarity parameter. This is compared to the result of difflib.SequenceMatcher.quick_ratio(). A value of 0.1 rejects passwords unless they are substantially different from the user_attributes, whereas a value of 1.0 rejects only passwords that are identical to an attribute's value.

Changed in Django 2.2.26:

The max_similarity parameter was limited to a minimum value of 0.1.

class CommonPasswordValidator(password_list_path=DEFAULT_PASSWORD_LIST_PATH)

Validates that the password is not a common password. This converts the password to lowercase (to do a case-insensitive comparison) and checks it against a list of 20,000 common password created by Royce Williams.

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

Changed in Django 4.2:

The list of 20,000 common passwords was updated to the most recent version.

class NumericPasswordValidator

Validate that the password is not entirely numeric.

集成检查

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) ,在密码修改成功后调用。比如说用来防止密码重用。但是,如果你决定存储用户之前的密码,则不应该以明文形式存储。