浏览代码

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.
Mariusz Felisiak 4 年之前
父节点
当前提交
d907371ef9

+ 11 - 0
django/conf/__init__.py

@@ -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):
     """
@@ -195,6 +201,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.
@@ -241,6 +250,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):

+ 6 - 0
django/conf/global_settings.py

@@ -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 #
 ##############

+ 9 - 1
django/contrib/auth/base_user.py

@@ -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):

+ 4 - 1
django/contrib/auth/tokens.py

@@ -11,11 +11,14 @@ class PasswordResetTokenGenerator:
     reset mechanism.
     """
     key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
-    algorithm = 'sha256'
+    algorithm = None
     secret = None
 
     def __init__(self):
         self.secret = self.secret or settings.SECRET_KEY
+        # 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):
         """

+ 13 - 0
django/core/checks/security/base.py

@@ -116,6 +116,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
@@ -228,3 +233,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 []

+ 4 - 2
django/core/signing.py

@@ -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)

+ 2 - 0
docs/internals/deprecation.txt

@@ -118,6 +118,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

+ 6 - 0
docs/ref/checks.txt

@@ -484,6 +484,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
 -------
 

+ 21 - 0
docs/ref/settings.txt

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

+ 23 - 0
docs/releases/3.1.txt

@@ -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

+ 2 - 2
docs/topics/signing.txt

@@ -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
 

+ 9 - 1
tests/auth_tests/test_middleware.py

@@ -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):
@@ -32,6 +34,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')

+ 1 - 0
tests/auth_tests/test_password_reset_timeout_days.py

@@ -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

+ 12 - 0
tests/auth_tests/test_tokens.py

@@ -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')

+ 55 - 0
tests/deprecation/test_default_hashing_algorithm.py

@@ -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), [])

+ 13 - 0
tests/messages_tests/test_cookie.py

@@ -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))

+ 17 - 1
tests/signing/tests.py

@@ -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 = (