[3.1.x] Fixed #31842 -- Added DEFAULT_HASHING_ALGORITHM transitional setting.
It's a transitional setting helpful in migrating multiple instance of
the same project to Django 3.1+.
Thanks Markus Holtermann for the report and review, Florian
Apolloner for the implementation idea and review, and Carlton Gibson
for the review.
Backport of d907371ef9
from master.
This commit is contained in:
parent
acb7866b1f
commit
9857352655
|
@ -27,6 +27,12 @@ PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG = (
|
|||
'PASSWORD_RESET_TIMEOUT instead.'
|
||||
)
|
||||
|
||||
DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG = (
|
||||
'The DEFAULT_HASHING_ALGORITHM transitional setting is deprecated. '
|
||||
'Support for it and tokens, cookies, sessions, and signatures that use '
|
||||
'SHA-1 hashing algorithm will be removed in Django 4.0.'
|
||||
)
|
||||
|
||||
|
||||
class SettingsReference(str):
|
||||
"""
|
||||
|
@ -198,6 +204,9 @@ class Settings:
|
|||
setattr(self, 'PASSWORD_RESET_TIMEOUT', self.PASSWORD_RESET_TIMEOUT_DAYS * 60 * 60 * 24)
|
||||
warnings.warn(PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG, RemovedInDjango40Warning)
|
||||
|
||||
if self.is_overridden('DEFAULT_HASHING_ALGORITHM'):
|
||||
warnings.warn(DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG, RemovedInDjango40Warning)
|
||||
|
||||
if hasattr(time, 'tzset') and self.TIME_ZONE:
|
||||
# When we can, attempt to validate the timezone. If we can't find
|
||||
# this file, no check happens and it's harmless.
|
||||
|
@ -244,6 +253,8 @@ class UserSettingsHolder:
|
|||
if name == 'PASSWORD_RESET_TIMEOUT_DAYS':
|
||||
setattr(self, 'PASSWORD_RESET_TIMEOUT', value * 60 * 60 * 24)
|
||||
warnings.warn(PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG, RemovedInDjango40Warning)
|
||||
if name == 'DEFAULT_HASHING_ALGORITHM':
|
||||
warnings.warn(DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG, RemovedInDjango40Warning)
|
||||
super().__setattr__(name, value)
|
||||
|
||||
def __delattr__(self, name):
|
||||
|
|
|
@ -436,6 +436,12 @@ WSGI_APPLICATION = None
|
|||
# you may be opening yourself up to a security risk.
|
||||
SECURE_PROXY_SSL_HEADER = None
|
||||
|
||||
# Default hashing algorithm to use for encoding cookies, password reset tokens
|
||||
# in the admin site, user sessions, and signatures. It's a transitional setting
|
||||
# helpful in migrating multiple instance of the same project to Django 3.1+.
|
||||
# Algorithm must be 'sha1' or 'sha256'.
|
||||
DEFAULT_HASHING_ALGORITHM = 'sha256'
|
||||
|
||||
##############
|
||||
# MIDDLEWARE #
|
||||
##############
|
||||
|
|
|
@ -4,6 +4,7 @@ not in INSTALLED_APPS.
|
|||
"""
|
||||
import unicodedata
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.contrib.auth.hashers import (
|
||||
check_password, is_password_usable, make_password,
|
||||
|
@ -130,7 +131,14 @@ class AbstractBaseUser(models.Model):
|
|||
Return an HMAC of the password field.
|
||||
"""
|
||||
key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
|
||||
return salted_hmac(key_salt, self.password, algorithm='sha256').hexdigest()
|
||||
return salted_hmac(
|
||||
key_salt,
|
||||
self.password,
|
||||
# RemovedInDjango40Warning: when the deprecation ends, replace
|
||||
# with:
|
||||
# algorithm='sha256',
|
||||
algorithm=settings.DEFAULT_HASHING_ALGORITHM,
|
||||
).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def get_email_field_name(cls):
|
||||
|
|
|
@ -11,9 +11,14 @@ class PasswordResetTokenGenerator:
|
|||
reset mechanism.
|
||||
"""
|
||||
key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
|
||||
algorithm = 'sha256'
|
||||
algorithm = None
|
||||
secret = settings.SECRET_KEY
|
||||
|
||||
def __init__(self):
|
||||
# RemovedInDjango40Warning: when the deprecation ends, replace with:
|
||||
# self.algorithm = self.algorithm or 'sha256'
|
||||
self.algorithm = self.algorithm or settings.DEFAULT_HASHING_ALGORITHM
|
||||
|
||||
def make_token(self, user):
|
||||
"""
|
||||
Return a token that can be used once to do a password reset
|
||||
|
|
|
@ -115,6 +115,11 @@ E023 = Error(
|
|||
id='security.E023',
|
||||
)
|
||||
|
||||
E100 = Error(
|
||||
"DEFAULT_HASHING_ALGORITHM must be 'sha1' or 'sha256'.",
|
||||
id='security.E100',
|
||||
)
|
||||
|
||||
|
||||
def _security_middleware():
|
||||
return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE
|
||||
|
@ -223,3 +228,11 @@ def check_referrer_policy(app_configs, **kwargs):
|
|||
if not values <= REFERRER_POLICY_VALUES:
|
||||
return [E023]
|
||||
return []
|
||||
|
||||
|
||||
# RemovedInDjango40Warning
|
||||
@register(Tags.security)
|
||||
def check_default_hashing_algorithm(app_configs, **kwargs):
|
||||
if settings.DEFAULT_HASHING_ALGORITHM not in {'sha1', 'sha256'}:
|
||||
return [E100]
|
||||
return []
|
||||
|
|
|
@ -147,7 +147,7 @@ class Signer:
|
|||
# RemovedInDjango40Warning.
|
||||
legacy_algorithm = 'sha1'
|
||||
|
||||
def __init__(self, key=None, sep=':', salt=None, algorithm='sha256'):
|
||||
def __init__(self, key=None, sep=':', salt=None, algorithm=None):
|
||||
self.key = key or settings.SECRET_KEY
|
||||
self.sep = sep
|
||||
if _SEP_UNSAFE.match(self.sep):
|
||||
|
@ -156,7 +156,9 @@ class Signer:
|
|||
'only A-z0-9-_=)' % sep,
|
||||
)
|
||||
self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
|
||||
self.algorithm = algorithm
|
||||
# RemovedInDjango40Warning: when the deprecation ends, replace with:
|
||||
# self.algorithm = algorithm or 'sha256'
|
||||
self.algorithm = algorithm or settings.DEFAULT_HASHING_ALGORITHM
|
||||
|
||||
def signature(self, value):
|
||||
return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm)
|
||||
|
|
|
@ -99,6 +99,8 @@ details on these changes.
|
|||
|
||||
* The ``{% ifequal %}`` and ``{% ifnotequal %}`` template tags will be removed.
|
||||
|
||||
* The ``DEFAULT_HASHING_ALGORITHM`` transitional setting will be removed.
|
||||
|
||||
.. _deprecation-removed-in-3.1:
|
||||
|
||||
3.1
|
||||
|
|
|
@ -474,6 +474,12 @@ The following checks are run if you use the :option:`check --deploy` option:
|
|||
* **security.E023**: You have set the :setting:`SECURE_REFERRER_POLICY` setting
|
||||
to an invalid value.
|
||||
|
||||
The following checks verify that your security-related settings are correctly
|
||||
configured:
|
||||
|
||||
* **security.E100**: :setting:`DEFAULT_HASHING_ALGORITHM` must be ``'sha1'`` or
|
||||
``'sha256'``.
|
||||
|
||||
Signals
|
||||
-------
|
||||
|
||||
|
|
|
@ -1295,6 +1295,27 @@ Default email address to use for various automated correspondence from the
|
|||
site manager(s). This doesn't include error messages sent to :setting:`ADMINS`
|
||||
and :setting:`MANAGERS`; for that, see :setting:`SERVER_EMAIL`.
|
||||
|
||||
.. setting:: DEFAULT_HASHING_ALGORITHM
|
||||
|
||||
``DEFAULT_HASHING_ALGORITHM``
|
||||
-----------------------------
|
||||
|
||||
.. versionadded:: 3.1
|
||||
|
||||
Default: ``'sha256'``
|
||||
|
||||
Default hashing algorithm to use for encoding cookies, password reset tokens in
|
||||
the admin site, user sessions, and signatures created by
|
||||
:class:`django.core.signing.Signer` and :meth:`django.core.signing.dumps`.
|
||||
Algorithm must be ``'sha1'`` or ``'sha256'``. See
|
||||
:ref:`release notes <default-hashing-algorithm-usage>` for usage details.
|
||||
|
||||
.. deprecated:: 3.1
|
||||
|
||||
This transitional setting is deprecated. Support for it and tokens,
|
||||
cookies, sessions, and signatures that use SHA-1 hashing algorithm will be
|
||||
removed in Django 4.0.
|
||||
|
||||
.. setting:: DEFAULT_INDEX_TABLESPACE
|
||||
|
||||
``DEFAULT_INDEX_TABLESPACE``
|
||||
|
|
|
@ -96,6 +96,27 @@ and generate and apply a database migration. For now, the old fields and
|
|||
transforms are left as a reference to the new ones and are :ref:`deprecated as
|
||||
of this release <deprecated-jsonfield>`.
|
||||
|
||||
.. _default-hashing-algorithm-usage:
|
||||
|
||||
``DEFAULT_HASHING_ALGORITHM`` settings
|
||||
--------------------------------------
|
||||
|
||||
The new :setting:`DEFAULT_HASHING_ALGORITHM` transitional setting allows
|
||||
specifying the default hashing algorithm to use for encoding cookies, password
|
||||
reset tokens in the admin site, user sessions, and signatures created by
|
||||
:class:`django.core.signing.Signer` and :meth:`django.core.signing.dumps`.
|
||||
|
||||
Support for SHA-256 was added in Django 3.1. If you are upgrading multiple
|
||||
instances of the same project to Django 3.1, you should set
|
||||
:setting:`DEFAULT_HASHING_ALGORITHM` to ``'sha1'`` during the transition, in
|
||||
order to allow compatibility with the older versions of Django. Once the
|
||||
transition to 3.1 is complete you can stop overriding
|
||||
:setting:`DEFAULT_HASHING_ALGORITHM`.
|
||||
|
||||
This setting is deprecated as of this release, because support for tokens,
|
||||
cookies, sessions, and signatures that use SHA-1 algorithm will be removed in
|
||||
Django 4.0.
|
||||
|
||||
Minor features
|
||||
--------------
|
||||
|
||||
|
@ -794,6 +815,8 @@ Miscellaneous
|
|||
<django.template.backends.django.DjangoTemplates>` option in
|
||||
:setting:`OPTIONS <TEMPLATES-OPTIONS>`.
|
||||
|
||||
* ``DEFAULT_HASHING_ALGORITHM`` transitional setting is deprecated.
|
||||
|
||||
.. _removed-features-3.1:
|
||||
|
||||
Features removed in 3.1
|
||||
|
|
|
@ -81,13 +81,13 @@ generate signatures. You can use a different secret by passing it to the
|
|||
>>> value
|
||||
'My string:EkfQJafvGyiofrdGnuthdxImIJw'
|
||||
|
||||
.. class:: Signer(key=None, sep=':', salt=None, algorithm='sha256')
|
||||
.. class:: Signer(key=None, sep=':', salt=None, algorithm=None)
|
||||
|
||||
Returns a signer which uses ``key`` to generate signatures and ``sep`` to
|
||||
separate values. ``sep`` cannot be in the :rfc:`URL safe base64 alphabet
|
||||
<4648#section-5>`. This alphabet contains alphanumeric characters, hyphens,
|
||||
and underscores. ``algorithm`` must be an algorithm supported by
|
||||
:py:mod:`hashlib`.
|
||||
:py:mod:`hashlib`, it defaults to ``'sha256'``.
|
||||
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
|
|
|
@ -2,7 +2,9 @@ from django.contrib.auth import HASH_SESSION_KEY
|
|||
from django.contrib.auth.middleware import AuthenticationMiddleware
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test.utils import ignore_warnings
|
||||
from django.utils.deprecation import RemovedInDjango40Warning
|
||||
|
||||
|
||||
class TestAuthenticationMiddleware(TestCase):
|
||||
|
@ -29,6 +31,12 @@ class TestAuthenticationMiddleware(TestCase):
|
|||
self.assertIsNotNone(self.request.user)
|
||||
self.assertFalse(self.request.user.is_anonymous)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_session_default_hashing_algorithm(self):
|
||||
hash_session = self.client.session[HASH_SESSION_KEY]
|
||||
with override_settings(DEFAULT_HASHING_ALGORITHM='sha1'):
|
||||
self.assertNotEqual(hash_session, self.user.get_session_auth_hash())
|
||||
|
||||
def test_changed_password_invalidates_session(self):
|
||||
# After password change, user should be anonymous
|
||||
self.user.set_password('new_password')
|
||||
|
|
|
@ -23,6 +23,7 @@ class DeprecationTests(TestCase):
|
|||
class Mocked(PasswordResetTokenGenerator):
|
||||
def __init__(self, now):
|
||||
self._now_val = now
|
||||
super().__init__()
|
||||
|
||||
def _now(self):
|
||||
return self._now_val
|
||||
|
|
|
@ -4,11 +4,14 @@ from django.conf import settings
|
|||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||
from django.test import TestCase
|
||||
from django.test.utils import ignore_warnings
|
||||
from django.utils.deprecation import RemovedInDjango40Warning
|
||||
|
||||
|
||||
class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator):
|
||||
def __init__(self, now):
|
||||
self._now_val = now
|
||||
super().__init__()
|
||||
|
||||
def _now(self):
|
||||
return self._now_val
|
||||
|
@ -88,6 +91,15 @@ class TokenGeneratorTest(TestCase):
|
|||
self.assertIs(p0.check_token(user, tk1), False)
|
||||
self.assertIs(p1.check_token(user, tk0), False)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_token_default_hashing_algorithm(self):
|
||||
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
|
||||
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
|
||||
generator = PasswordResetTokenGenerator()
|
||||
self.assertEqual(generator.algorithm, 'sha1')
|
||||
token = generator.make_token(user)
|
||||
self.assertIs(generator.check_token(user, token), True)
|
||||
|
||||
def test_legacy_token_validation(self):
|
||||
# RemovedInDjango40Warning: pre-Django 3.1 tokens will be invalid.
|
||||
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
from django.conf import (
|
||||
DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG, Settings, settings,
|
||||
)
|
||||
from django.core.checks.security import base as security_base
|
||||
from django.test import TestCase, ignore_warnings
|
||||
from django.utils.deprecation import RemovedInDjango40Warning
|
||||
|
||||
|
||||
class DefaultHashingAlgorithmDeprecationTests(TestCase):
|
||||
msg = DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG
|
||||
|
||||
def test_override_settings_warning(self):
|
||||
with self.assertRaisesMessage(RemovedInDjango40Warning, self.msg):
|
||||
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
|
||||
pass
|
||||
|
||||
def test_settings_init_warning(self):
|
||||
settings_module = ModuleType('fake_settings_module')
|
||||
settings_module.SECRET_KEY = 'foo'
|
||||
settings_module.DEFAULT_HASHING_ALGORITHM = 'sha1'
|
||||
sys.modules['fake_settings_module'] = settings_module
|
||||
try:
|
||||
with self.assertRaisesMessage(RemovedInDjango40Warning, self.msg):
|
||||
Settings('fake_settings_module')
|
||||
finally:
|
||||
del sys.modules['fake_settings_module']
|
||||
|
||||
def test_access(self):
|
||||
# Warning is not raised on access.
|
||||
self.assertEqual(settings.DEFAULT_HASHING_ALGORITHM, 'sha256')
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_system_check_invalid_value(self):
|
||||
tests = [
|
||||
None,
|
||||
256,
|
||||
'invalid',
|
||||
'md5',
|
||||
'sha512',
|
||||
]
|
||||
for value in tests:
|
||||
with self.subTest(value=value), self.settings(DEFAULT_HASHING_ALGORITHM=value):
|
||||
self.assertEqual(
|
||||
security_base.check_default_hashing_algorithm(None),
|
||||
[security_base.E100],
|
||||
)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_system_check_valid_value(self):
|
||||
for value in ['sha1', 'sha256']:
|
||||
with self.subTest(value=value), self.settings(DEFAULT_HASHING_ALGORITHM=value):
|
||||
self.assertEqual(security_base.check_default_hashing_algorithm(None), [])
|
|
@ -7,6 +7,8 @@ from django.contrib.messages.storage.cookie import (
|
|||
CookieStorage, MessageDecoder, MessageEncoder,
|
||||
)
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
from django.test.utils import ignore_warnings
|
||||
from django.utils.deprecation import RemovedInDjango40Warning
|
||||
from django.utils.safestring import SafeData, mark_safe
|
||||
|
||||
from .base import BaseTests
|
||||
|
@ -169,3 +171,14 @@ class CookieTests(BaseTests, SimpleTestCase):
|
|||
encoded_messages = '%s$%s' % (storage._legacy_hash(value), value)
|
||||
decoded_messages = storage._decode(encoded_messages)
|
||||
self.assertEqual(messages, decoded_messages)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_default_hashing_algorithm(self):
|
||||
messages = Message(constants.DEBUG, ['this', 'that'])
|
||||
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
|
||||
storage = self.get_storage()
|
||||
encoded = storage._encode(messages)
|
||||
decoded = storage._decode(encoded)
|
||||
self.assertEqual(decoded, messages)
|
||||
storage_default = self.get_storage()
|
||||
self.assertNotEqual(encoded, storage_default._encode(messages))
|
||||
|
|
|
@ -2,8 +2,9 @@ import datetime
|
|||
|
||||
from django.core import signing
|
||||
from django.test import SimpleTestCase
|
||||
from django.test.utils import freeze_time
|
||||
from django.test.utils import freeze_time, ignore_warnings
|
||||
from django.utils.crypto import InvalidAlgorithm
|
||||
from django.utils.deprecation import RemovedInDjango40Warning
|
||||
|
||||
|
||||
class TestSigner(SimpleTestCase):
|
||||
|
@ -52,6 +53,14 @@ class TestSigner(SimpleTestCase):
|
|||
'VzO9_jVu7R-VkqknHYNvw',
|
||||
)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_default_hashing_algorithm(self):
|
||||
signer = signing.Signer('predictable-secret', algorithm='sha1')
|
||||
signature_sha1 = signer.signature('hello')
|
||||
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
|
||||
signer = signing.Signer('predictable-secret')
|
||||
self.assertEqual(signer.signature('hello'), signature_sha1)
|
||||
|
||||
def test_invalid_algorithm(self):
|
||||
signer = signing.Signer('predictable-secret', algorithm='whatever')
|
||||
msg = "'whatever' is not an algorithm accepted by the hashlib module."
|
||||
|
@ -134,6 +143,13 @@ class TestSigner(SimpleTestCase):
|
|||
signed = 'ImEgc3RyaW5nIFx1MjAyMCI:1k1beT:ZfNhN1kdws7KosUleOvuYroPHEc'
|
||||
self.assertEqual(signing.loads(signed), value)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango40Warning)
|
||||
def test_dumps_loads_default_hashing_algorithm_sha1(self):
|
||||
value = 'a string \u2020'
|
||||
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
|
||||
signed = signing.dumps(value)
|
||||
self.assertEqual(signing.loads(signed), value)
|
||||
|
||||
def test_decode_detects_tampering(self):
|
||||
"loads should raise exception for tampered objects"
|
||||
transforms = (
|
||||
|
|
Loading…
Reference in New Issue