Fixed #26033 -- Added Argon2 password hasher.
This commit is contained in:
parent
74670498e9
commit
b4250ea04a
|
@ -500,6 +500,7 @@ PASSWORD_RESET_TIMEOUT_DAYS = 3
|
||||||
PASSWORD_HASHERS = [
|
PASSWORD_HASHERS = [
|
||||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||||
|
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
||||||
]
|
]
|
||||||
|
|
|
@ -297,6 +297,79 @@ class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
|
||||||
digest = hashlib.sha1
|
digest = hashlib.sha1
|
||||||
|
|
||||||
|
|
||||||
|
class Argon2PasswordHasher(BasePasswordHasher):
|
||||||
|
"""
|
||||||
|
Secure password hashing using the argon2 algorithm.
|
||||||
|
|
||||||
|
This is the winner of the Password Hashing Competition 2013-2015
|
||||||
|
(https://password-hashing.net). It requires the argon2-cffi library which
|
||||||
|
depends on native C code and might cause portability issues.
|
||||||
|
"""
|
||||||
|
algorithm = 'argon2'
|
||||||
|
library = 'argon2'
|
||||||
|
|
||||||
|
time_cost = 2
|
||||||
|
memory_cost = 512
|
||||||
|
parallelism = 2
|
||||||
|
|
||||||
|
def encode(self, password, salt):
|
||||||
|
argon2 = self._load_library()
|
||||||
|
data = argon2.low_level.hash_secret(
|
||||||
|
force_bytes(password),
|
||||||
|
force_bytes(salt),
|
||||||
|
time_cost=self.time_cost,
|
||||||
|
memory_cost=self.memory_cost,
|
||||||
|
parallelism=self.parallelism,
|
||||||
|
hash_len=argon2.DEFAULT_HASH_LENGTH,
|
||||||
|
type=argon2.low_level.Type.I,
|
||||||
|
)
|
||||||
|
return self.algorithm + data.decode('utf-8')
|
||||||
|
|
||||||
|
def verify(self, password, encoded):
|
||||||
|
argon2 = self._load_library()
|
||||||
|
algorithm, data = encoded.split('$', 1)
|
||||||
|
assert algorithm == self.algorithm
|
||||||
|
try:
|
||||||
|
return argon2.low_level.verify_secret(
|
||||||
|
force_bytes('$' + data),
|
||||||
|
force_bytes(password),
|
||||||
|
type=argon2.low_level.Type.I,
|
||||||
|
)
|
||||||
|
except argon2.exceptions.VerificationError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def safe_summary(self, encoded):
|
||||||
|
algorithm, variety, raw_pars, salt, data = encoded.split('$', 5)
|
||||||
|
pars = dict(bit.split('=', 1) for bit in raw_pars.split(','))
|
||||||
|
assert algorithm == self.algorithm
|
||||||
|
assert len(pars) == 3 and 't' in pars and 'm' in pars and 'p' in pars
|
||||||
|
return OrderedDict([
|
||||||
|
(_('algorithm'), algorithm),
|
||||||
|
(_('variety'), variety),
|
||||||
|
(_('memory cost'), int(pars['m'])),
|
||||||
|
(_('time cost'), int(pars['t'])),
|
||||||
|
(_('parallelism'), int(pars['p'])),
|
||||||
|
(_('salt'), mask_hash(salt)),
|
||||||
|
(_('hash'), mask_hash(data)),
|
||||||
|
])
|
||||||
|
|
||||||
|
def must_update(self, encoded):
|
||||||
|
algorithm, variety, raw_pars, salt, data = encoded.split('$', 5)
|
||||||
|
pars = dict([bit.split('=', 1) for bit in raw_pars.split(',')])
|
||||||
|
assert algorithm == self.algorithm
|
||||||
|
assert len(pars) == 3 and 't' in pars and 'm' in pars and 'p' in pars
|
||||||
|
return (
|
||||||
|
self.time_cost != int(pars['t']) or
|
||||||
|
self.memory_cost != int(pars['m']) or
|
||||||
|
self.parallelism != int(pars['p'])
|
||||||
|
)
|
||||||
|
|
||||||
|
def harden_runtime(self, password, encoded):
|
||||||
|
# The runtime for Argon2 is too complicated to implement a sensible
|
||||||
|
# hardening algorithm.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BCryptSHA256PasswordHasher(BasePasswordHasher):
|
class BCryptSHA256PasswordHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
Secure password hashing using the bcrypt algorithm (recommended)
|
Secure password hashing using the bcrypt algorithm (recommended)
|
||||||
|
|
|
@ -137,6 +137,7 @@ Running all the tests
|
||||||
If you want to run the full suite of tests, you'll need to install a number of
|
If you want to run the full suite of tests, you'll need to install a number of
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
||||||
|
* argon2-cffi_ 16.0.0+
|
||||||
* bcrypt_
|
* bcrypt_
|
||||||
* docutils_
|
* docutils_
|
||||||
* enum34_ (Python 2 only)
|
* enum34_ (Python 2 only)
|
||||||
|
@ -171,6 +172,7 @@ and install the Geospatial libraries</ref/contrib/gis/install/index>`.
|
||||||
Each of these dependencies is optional. If you're missing any of them, the
|
Each of these dependencies is optional. If you're missing any of them, the
|
||||||
associated tests will be skipped.
|
associated tests will be skipped.
|
||||||
|
|
||||||
|
.. _argon2-cffi: https://pypi.python.org/pypi/argon2_cffi
|
||||||
.. _bcrypt: https://pypi.python.org/pypi/bcrypt
|
.. _bcrypt: https://pypi.python.org/pypi/bcrypt
|
||||||
.. _docutils: https://pypi.python.org/pypi/docutils
|
.. _docutils: https://pypi.python.org/pypi/docutils
|
||||||
.. _enum34: https://pypi.python.org/pypi/enum34
|
.. _enum34: https://pypi.python.org/pypi/enum34
|
||||||
|
|
|
@ -2684,6 +2684,7 @@ Default::
|
||||||
[
|
[
|
||||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||||
|
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
||||||
]
|
]
|
||||||
|
@ -2702,6 +2703,8 @@ Default::
|
||||||
to strengthen the hashes in your database. If that's not feasible, add this
|
to strengthen the hashes in your database. If that's not feasible, add this
|
||||||
setting to your project and add back any hashers that you need.
|
setting to your project and add back any hashers that you need.
|
||||||
|
|
||||||
|
Also, the ``Argon2PasswordHasher`` was added.
|
||||||
|
|
||||||
.. setting:: AUTH_PASSWORD_VALIDATORS
|
.. setting:: AUTH_PASSWORD_VALIDATORS
|
||||||
|
|
||||||
``AUTH_PASSWORD_VALIDATORS``
|
``AUTH_PASSWORD_VALIDATORS``
|
||||||
|
|
|
@ -70,6 +70,10 @@ Minor features
|
||||||
:mod:`django.contrib.auth`
|
:mod:`django.contrib.auth`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
* Added support for the :ref:`Argon2 password hash <argon2_usage>`. It's
|
||||||
|
recommended over PBKDF2, however, it's not the default as it requires a
|
||||||
|
third-party library.
|
||||||
|
|
||||||
* The default iteration count for the PBKDF2 password hasher has been increased
|
* The default iteration count for the PBKDF2 password hasher has been increased
|
||||||
by 25%. This backwards compatible change will not affect users who have
|
by 25%. This backwards compatible change will not affect users who have
|
||||||
subclassed ``django.contrib.auth.hashers.PBKDF2PasswordHasher`` to change the
|
subclassed ``django.contrib.auth.hashers.PBKDF2PasswordHasher`` to change the
|
||||||
|
|
|
@ -60,16 +60,53 @@ The default for :setting:`PASSWORD_HASHERS` is::
|
||||||
PASSWORD_HASHERS = [
|
PASSWORD_HASHERS = [
|
||||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||||
|
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
||||||
]
|
]
|
||||||
|
|
||||||
This means that Django will use PBKDF2_ to store all passwords but will support
|
This means that Django will use PBKDF2_ to store all passwords but will support
|
||||||
checking passwords stored with PBKDF2SHA1 and bcrypt_.
|
checking passwords stored with PBKDF2SHA1, argon2_, and bcrypt_.
|
||||||
|
|
||||||
The next few sections describe a couple of common ways advanced users may want
|
The next few sections describe a couple of common ways advanced users may want
|
||||||
to modify this setting.
|
to modify this setting.
|
||||||
|
|
||||||
|
.. _argon2_usage:
|
||||||
|
|
||||||
|
Using Argon2 with Django
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
.. versionadded:: 1.10
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Argon2_ is not the default for Django because it requires a third-party
|
||||||
|
library. The Password Hashing Competition panel, however, recommends immediate
|
||||||
|
use of Argon2 rather than the other algorithms supported by Django.
|
||||||
|
|
||||||
|
To use Argon2 as your default storage algorithm, do the following:
|
||||||
|
|
||||||
|
1. Install the `argon2-cffi library`_. This can be done by running ``pip
|
||||||
|
install django[argon2]`` or by downloading the library and installing it
|
||||||
|
with ``python setup.py install``.
|
||||||
|
|
||||||
|
2. Modify :setting:`PASSWORD_HASHERS` to list ``Argon2PasswordHasher`` first.
|
||||||
|
That is, in your settings file, you'd put::
|
||||||
|
|
||||||
|
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.BCryptPasswordHasher',
|
||||||
|
]
|
||||||
|
|
||||||
|
Keep and/or add any entries in this list if you need Django to :ref:`upgrade
|
||||||
|
passwords <password-upgrades>`.
|
||||||
|
|
||||||
.. _bcrypt_usage:
|
.. _bcrypt_usage:
|
||||||
|
|
||||||
Using ``bcrypt`` with Django
|
Using ``bcrypt`` with Django
|
||||||
|
@ -94,6 +131,7 @@ To use Bcrypt as your default storage algorithm, do the following:
|
||||||
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||||
|
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||||
]
|
]
|
||||||
|
|
||||||
Keep and/or add any entries in this list if you need Django to :ref:`upgrade
|
Keep and/or add any entries in this list if you need Django to :ref:`upgrade
|
||||||
|
@ -132,6 +170,9 @@ algorithm.
|
||||||
Increasing the work factor
|
Increasing the work factor
|
||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
|
PBKDF2 and bcrypt
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
The PBKDF2 and bcrypt algorithms use a number of iterations or rounds of
|
The PBKDF2 and bcrypt algorithms use a number of iterations or rounds of
|
||||||
hashing. This deliberately slows down attackers, making attacks against hashed
|
hashing. This deliberately slows down attackers, making attacks against hashed
|
||||||
passwords harder. However, as computing power increases, the number of
|
passwords harder. However, as computing power increases, the number of
|
||||||
|
@ -161,6 +202,7 @@ default PBKDF2 algorithm:
|
||||||
'myproject.hashers.MyPBKDF2PasswordHasher',
|
'myproject.hashers.MyPBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||||
|
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
||||||
]
|
]
|
||||||
|
@ -168,6 +210,28 @@ default PBKDF2 algorithm:
|
||||||
That's it -- now your Django install will use more iterations when it
|
That's it -- now your Django install will use more iterations when it
|
||||||
stores passwords using PBKDF2.
|
stores passwords using PBKDF2.
|
||||||
|
|
||||||
|
Argon2
|
||||||
|
~~~~~~
|
||||||
|
|
||||||
|
Argon2 has three attributes that can be customized:
|
||||||
|
|
||||||
|
#. ``time_cost`` controls the number of iterations within the hash.
|
||||||
|
#. ``memory_cost`` controls the size of memory that must be used during the
|
||||||
|
computation of the hash.
|
||||||
|
#. ``parallelism`` controls how many CPUs the computation of the hash can be
|
||||||
|
parallelized on.
|
||||||
|
|
||||||
|
The default values of these attributes are probably fine for you. If you
|
||||||
|
determine that the password hash is too fast or too slow, you can tweak it as
|
||||||
|
follows:
|
||||||
|
|
||||||
|
#. Choose ``parallelism`` to be the number of threads you can
|
||||||
|
spare computing the hash.
|
||||||
|
#. Choose ``memory_cost`` to be the KiB of memory you can spare.
|
||||||
|
#. Adjust ``time_cost`` and measure the time hashing a password takes.
|
||||||
|
Pick a ``time_cost`` that takes an acceptable time for you.
|
||||||
|
If ``time_cost`` set to 1 is unacceptably slow, lower ``memory_cost``.
|
||||||
|
|
||||||
.. _password-upgrades:
|
.. _password-upgrades:
|
||||||
|
|
||||||
Password upgrading
|
Password upgrading
|
||||||
|
@ -286,6 +350,9 @@ Include any other hashers that your site uses in this list.
|
||||||
.. _nist: http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf
|
.. _nist: http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf
|
||||||
.. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt
|
.. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt
|
||||||
.. _`bcrypt library`: https://pypi.python.org/pypi/bcrypt/
|
.. _`bcrypt library`: https://pypi.python.org/pypi/bcrypt/
|
||||||
|
.. _`argon2-cffi library`: https://pypi.python.org/pypi/argon2_cffi/
|
||||||
|
.. _argon2: https://en.wikipedia.org/wiki/Argon2
|
||||||
|
.. _`Password Hashing Competition`: https://password-hashing.net
|
||||||
|
|
||||||
.. _auth-included-hashers:
|
.. _auth-included-hashers:
|
||||||
|
|
||||||
|
@ -297,6 +364,7 @@ The full list of hashers included in Django is::
|
||||||
[
|
[
|
||||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||||
|
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||||
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
'django.contrib.auth.hashers.BCryptPasswordHasher',
|
||||||
'django.contrib.auth.hashers.SHA1PasswordHasher',
|
'django.contrib.auth.hashers.SHA1PasswordHasher',
|
||||||
|
@ -310,6 +378,7 @@ The corresponding algorithm names are:
|
||||||
|
|
||||||
* ``pbkdf2_sha256``
|
* ``pbkdf2_sha256``
|
||||||
* ``pbkdf2_sha1``
|
* ``pbkdf2_sha1``
|
||||||
|
* ``argon2``
|
||||||
* ``bcrypt_sha256``
|
* ``bcrypt_sha256``
|
||||||
* ``bcrypt``
|
* ``bcrypt``
|
||||||
* ``sha1``
|
* ``sha1``
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -49,6 +49,7 @@ setup(
|
||||||
]},
|
]},
|
||||||
extras_require={
|
extras_require={
|
||||||
"bcrypt": ["bcrypt"],
|
"bcrypt": ["bcrypt"],
|
||||||
|
"argon2": ["argon2-cffi >= 16.0.0"],
|
||||||
},
|
},
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
|
|
@ -25,6 +25,11 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
bcrypt = None
|
bcrypt = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import argon2
|
||||||
|
except ImportError:
|
||||||
|
argon2 = None
|
||||||
|
|
||||||
|
|
||||||
class PBKDF2SingleIterationHasher(PBKDF2PasswordHasher):
|
class PBKDF2SingleIterationHasher(PBKDF2PasswordHasher):
|
||||||
iterations = 1
|
iterations = 1
|
||||||
|
@ -434,3 +439,58 @@ class TestUtilsHashPass(SimpleTestCase):
|
||||||
with six.assertRaisesRegex(self, ValueError,
|
with six.assertRaisesRegex(self, ValueError,
|
||||||
"Couldn't load 'PlainHasher' algorithm library: No module named '?plain'?"):
|
"Couldn't load 'PlainHasher' algorithm library: No module named '?plain'?"):
|
||||||
PlainHasher()._load_library()
|
PlainHasher()._load_library()
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnless(argon2, "argon2-cffi not installed")
|
||||||
|
@override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS)
|
||||||
|
class TestUtilsHashPassArgon2(SimpleTestCase):
|
||||||
|
|
||||||
|
def test_argon2(self):
|
||||||
|
encoded = make_password('lètmein', hasher='argon2')
|
||||||
|
self.assertTrue(is_password_usable(encoded))
|
||||||
|
self.assertTrue(encoded.startswith('argon2$'))
|
||||||
|
self.assertTrue(check_password('lètmein', encoded))
|
||||||
|
self.assertFalse(check_password('lètmeinz', encoded))
|
||||||
|
self.assertEqual(identify_hasher(encoded).algorithm, 'argon2')
|
||||||
|
# Blank passwords
|
||||||
|
blank_encoded = make_password('', hasher='argon2')
|
||||||
|
self.assertTrue(blank_encoded.startswith('argon2$'))
|
||||||
|
self.assertTrue(is_password_usable(blank_encoded))
|
||||||
|
self.assertTrue(check_password('', blank_encoded))
|
||||||
|
self.assertFalse(check_password(' ', blank_encoded))
|
||||||
|
|
||||||
|
def test_argon2_upgrade(self):
|
||||||
|
self._test_argon2_upgrade('time_cost', 'time cost', 1)
|
||||||
|
self._test_argon2_upgrade('memory_cost', 'memory cost', 16)
|
||||||
|
self._test_argon2_upgrade('parallelism', 'parallelism', 1)
|
||||||
|
|
||||||
|
def _test_argon2_upgrade(self, attr, summary_key, new_value):
|
||||||
|
hasher = get_hasher('argon2')
|
||||||
|
self.assertEqual('argon2', hasher.algorithm)
|
||||||
|
self.assertNotEqual(getattr(hasher, attr), new_value)
|
||||||
|
|
||||||
|
old_value = getattr(hasher, attr)
|
||||||
|
try:
|
||||||
|
# Generate hash with attr set to 1
|
||||||
|
setattr(hasher, attr, new_value)
|
||||||
|
encoded = make_password('letmein', hasher='argon2')
|
||||||
|
attr_value = hasher.safe_summary(encoded)[summary_key]
|
||||||
|
self.assertEqual(attr_value, new_value)
|
||||||
|
|
||||||
|
state = {'upgraded': False}
|
||||||
|
|
||||||
|
def setter(password):
|
||||||
|
state['upgraded'] = True
|
||||||
|
|
||||||
|
# Check that no upgrade is triggered.
|
||||||
|
self.assertTrue(check_password('letmein', encoded, setter, 'argon2'))
|
||||||
|
self.assertFalse(state['upgraded'])
|
||||||
|
|
||||||
|
# Revert to the old rounds count and ...
|
||||||
|
setattr(hasher, attr, old_value)
|
||||||
|
|
||||||
|
# ... check if the password would get updated to the new count.
|
||||||
|
self.assertTrue(check_password('letmein', encoded, setter, 'argon2'))
|
||||||
|
self.assertTrue(state['upgraded'])
|
||||||
|
finally:
|
||||||
|
setattr(hasher, attr, old_value)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
argon2-cffi >= 16.0.0
|
||||||
bcrypt
|
bcrypt
|
||||||
docutils
|
docutils
|
||||||
geoip2
|
geoip2
|
||||||
|
|
Loading…
Reference in New Issue