Fixed #30360 -- Added support for secret key rotation.
Thanks Florian Apolloner for the implementation idea. Co-authored-by: Andreas Pelme <andreas@pelme.se> Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es> Co-authored-by: Vuyisile Ndlovu <terrameijar@gmail.com>
This commit is contained in:
parent
ba4a6880d1
commit
0dcd549bbe
|
@ -188,6 +188,7 @@ class Settings:
|
|||
"INSTALLED_APPS",
|
||||
"TEMPLATE_DIRS",
|
||||
"LOCALE_PATHS",
|
||||
"SECRET_KEY_FALLBACKS",
|
||||
)
|
||||
self._explicit_settings = set()
|
||||
for setting in dir(mod):
|
||||
|
|
|
@ -272,6 +272,10 @@ IGNORABLE_404_URLS = []
|
|||
# loudly.
|
||||
SECRET_KEY = ''
|
||||
|
||||
# List of secret keys used to verify the validity of signatures. This allows
|
||||
# secret key rotation.
|
||||
SECRET_KEY_FALLBACKS = []
|
||||
|
||||
# Default file storage mechanism that holds media.
|
||||
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ class PasswordResetTokenGenerator:
|
|||
key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
|
||||
algorithm = None
|
||||
_secret = None
|
||||
_secret_fallbacks = None
|
||||
|
||||
def __init__(self):
|
||||
self.algorithm = self.algorithm or 'sha256'
|
||||
|
@ -25,12 +26,26 @@ class PasswordResetTokenGenerator:
|
|||
|
||||
secret = property(_get_secret, _set_secret)
|
||||
|
||||
def _get_fallbacks(self):
|
||||
if self._secret_fallbacks is None:
|
||||
return settings.SECRET_KEY_FALLBACKS
|
||||
return self._secret_fallbacks
|
||||
|
||||
def _set_fallbacks(self, fallbacks):
|
||||
self._secret_fallbacks = fallbacks
|
||||
|
||||
secret_fallbacks = property(_get_fallbacks, _set_fallbacks)
|
||||
|
||||
def make_token(self, user):
|
||||
"""
|
||||
Return a token that can be used once to do a password reset
|
||||
for the given user.
|
||||
"""
|
||||
return self._make_token_with_timestamp(user, self._num_seconds(self._now()))
|
||||
return self._make_token_with_timestamp(
|
||||
user,
|
||||
self._num_seconds(self._now()),
|
||||
self.secret,
|
||||
)
|
||||
|
||||
def check_token(self, user, token):
|
||||
"""
|
||||
|
@ -50,7 +65,13 @@ class PasswordResetTokenGenerator:
|
|||
return False
|
||||
|
||||
# Check that the timestamp/uid has not been tampered with
|
||||
if not constant_time_compare(self._make_token_with_timestamp(user, ts), token):
|
||||
for secret in [self.secret, *self.secret_fallbacks]:
|
||||
if constant_time_compare(
|
||||
self._make_token_with_timestamp(user, ts, secret),
|
||||
token,
|
||||
):
|
||||
break
|
||||
else:
|
||||
return False
|
||||
|
||||
# Check the timestamp is within limit.
|
||||
|
@ -59,14 +80,14 @@ class PasswordResetTokenGenerator:
|
|||
|
||||
return True
|
||||
|
||||
def _make_token_with_timestamp(self, user, timestamp):
|
||||
def _make_token_with_timestamp(self, user, timestamp, secret):
|
||||
# timestamp is number of seconds since 2001-1-1. Converted to base 36,
|
||||
# this gives us a 6 digit string until about 2069.
|
||||
ts_b36 = int_to_base36(timestamp)
|
||||
hash_string = salted_hmac(
|
||||
self.key_salt,
|
||||
self._make_hash_value(user, timestamp),
|
||||
secret=self.secret,
|
||||
secret=secret,
|
||||
algorithm=self.algorithm,
|
||||
).hexdigest()[::2] # Limit to shorten the URL.
|
||||
return "%s-%s" % (ts_b36, hash_string)
|
||||
|
|
|
@ -16,6 +16,15 @@ SECRET_KEY_INSECURE_PREFIX = 'django-insecure-'
|
|||
SECRET_KEY_MIN_LENGTH = 50
|
||||
SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
|
||||
|
||||
SECRET_KEY_WARNING_MSG = (
|
||||
f"Your %s has less than {SECRET_KEY_MIN_LENGTH} characters, less than "
|
||||
f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS} unique characters, or it's prefixed "
|
||||
f"with '{SECRET_KEY_INSECURE_PREFIX}' indicating that it was generated "
|
||||
f"automatically by Django. Please generate a long and random value, "
|
||||
f"otherwise many of Django's security-critical features will be "
|
||||
f"vulnerable to attack."
|
||||
)
|
||||
|
||||
W001 = Warning(
|
||||
"You do not have 'django.middleware.security.SecurityMiddleware' "
|
||||
"in your MIDDLEWARE so the SECURE_HSTS_SECONDS, "
|
||||
|
@ -72,15 +81,7 @@ W008 = Warning(
|
|||
)
|
||||
|
||||
W009 = Warning(
|
||||
"Your SECRET_KEY has less than %(min_length)s characters, less than "
|
||||
"%(min_unique_chars)s unique characters, or it's prefixed with "
|
||||
"'%(insecure_prefix)s' indicating that it was generated automatically by "
|
||||
"Django. Please generate a long and random SECRET_KEY, otherwise many of "
|
||||
"Django's security-critical features will be vulnerable to attack." % {
|
||||
'min_length': SECRET_KEY_MIN_LENGTH,
|
||||
'min_unique_chars': SECRET_KEY_MIN_UNIQUE_CHARACTERS,
|
||||
'insecure_prefix': SECRET_KEY_INSECURE_PREFIX,
|
||||
},
|
||||
SECRET_KEY_WARNING_MSG % 'SECRET_KEY',
|
||||
id='security.W009',
|
||||
)
|
||||
|
||||
|
@ -131,6 +132,8 @@ E024 = Error(
|
|||
id='security.E024',
|
||||
)
|
||||
|
||||
W025 = Warning(SECRET_KEY_WARNING_MSG, id='security.W025')
|
||||
|
||||
|
||||
def _security_middleware():
|
||||
return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE
|
||||
|
@ -196,6 +199,14 @@ def check_ssl_redirect(app_configs, **kwargs):
|
|||
return [] if passed_check else [W008]
|
||||
|
||||
|
||||
def _check_secret_key(secret_key):
|
||||
return (
|
||||
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and
|
||||
len(secret_key) >= SECRET_KEY_MIN_LENGTH and
|
||||
not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
@register(Tags.security, deploy=True)
|
||||
def check_secret_key(app_configs, **kwargs):
|
||||
try:
|
||||
|
@ -203,14 +214,28 @@ def check_secret_key(app_configs, **kwargs):
|
|||
except (ImproperlyConfigured, AttributeError):
|
||||
passed_check = False
|
||||
else:
|
||||
passed_check = (
|
||||
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and
|
||||
len(secret_key) >= SECRET_KEY_MIN_LENGTH and
|
||||
not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
|
||||
)
|
||||
passed_check = _check_secret_key(secret_key)
|
||||
return [] if passed_check else [W009]
|
||||
|
||||
|
||||
@register(Tags.security, deploy=True)
|
||||
def check_secret_key_fallbacks(app_configs, **kwargs):
|
||||
warnings = []
|
||||
try:
|
||||
fallbacks = settings.SECRET_KEY_FALLBACKS
|
||||
except (ImproperlyConfigured, AttributeError):
|
||||
warnings.append(
|
||||
Warning(W025.msg % 'SECRET_KEY_FALLBACKS', id=W025.id)
|
||||
)
|
||||
else:
|
||||
for index, key in enumerate(fallbacks):
|
||||
if not _check_secret_key(key):
|
||||
warnings.append(
|
||||
Warning(W025.msg % f'SECRET_KEY_FALLBACKS[{index}]', id=W025.id)
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
@register(Tags.security, deploy=True)
|
||||
def check_debug(app_configs, **kwargs):
|
||||
passed_check = not settings.DEBUG
|
||||
|
|
|
@ -97,10 +97,18 @@ def base64_hmac(salt, value, key, algorithm='sha1'):
|
|||
return b64_encode(salted_hmac(salt, value, key, algorithm=algorithm).digest()).decode()
|
||||
|
||||
|
||||
def _cookie_signer_key(key):
|
||||
# SECRET_KEYS items may be str or bytes.
|
||||
return b'django.http.cookies' + force_bytes(key)
|
||||
|
||||
|
||||
def get_cookie_signer(salt='django.core.signing.get_cookie_signer'):
|
||||
Signer = import_string(settings.SIGNING_BACKEND)
|
||||
key = force_bytes(settings.SECRET_KEY) # SECRET_KEY may be str or bytes.
|
||||
return Signer(b'django.http.cookies' + key, salt=salt)
|
||||
return Signer(
|
||||
key=_cookie_signer_key(settings.SECRET_KEY),
|
||||
fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
|
||||
salt=salt,
|
||||
)
|
||||
|
||||
|
||||
class JSONSerializer:
|
||||
|
@ -135,18 +143,41 @@ def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer,
|
|||
return TimestampSigner(key, salt=salt).sign_object(obj, serializer=serializer, compress=compress)
|
||||
|
||||
|
||||
def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None):
|
||||
def loads(
|
||||
s,
|
||||
key=None,
|
||||
salt='django.core.signing',
|
||||
serializer=JSONSerializer,
|
||||
max_age=None,
|
||||
fallback_keys=None,
|
||||
):
|
||||
"""
|
||||
Reverse of dumps(), raise BadSignature if signature fails.
|
||||
|
||||
The serializer is expected to accept a bytestring.
|
||||
"""
|
||||
return TimestampSigner(key, salt=salt).unsign_object(s, serializer=serializer, max_age=max_age)
|
||||
return TimestampSigner(key, salt=salt, fallback_keys=fallback_keys).unsign_object(
|
||||
s,
|
||||
serializer=serializer,
|
||||
max_age=max_age,
|
||||
)
|
||||
|
||||
|
||||
class Signer:
|
||||
def __init__(self, key=None, sep=':', salt=None, algorithm=None):
|
||||
def __init__(
|
||||
self,
|
||||
key=None,
|
||||
sep=':',
|
||||
salt=None,
|
||||
algorithm=None,
|
||||
fallback_keys=None,
|
||||
):
|
||||
self.key = key or settings.SECRET_KEY
|
||||
self.fallback_keys = (
|
||||
fallback_keys
|
||||
if fallback_keys is not None
|
||||
else settings.SECRET_KEY_FALLBACKS
|
||||
)
|
||||
self.sep = sep
|
||||
if _SEP_UNSAFE.match(self.sep):
|
||||
raise ValueError(
|
||||
|
@ -156,8 +187,9 @@ class Signer:
|
|||
self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
|
||||
self.algorithm = algorithm or 'sha256'
|
||||
|
||||
def signature(self, value):
|
||||
return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm)
|
||||
def signature(self, value, key=None):
|
||||
key = key or self.key
|
||||
return base64_hmac(self.salt + 'signer', value, key, algorithm=self.algorithm)
|
||||
|
||||
def sign(self, value):
|
||||
return '%s%s%s' % (value, self.sep, self.signature(value))
|
||||
|
@ -166,7 +198,8 @@ class Signer:
|
|||
if self.sep not in signed_value:
|
||||
raise BadSignature('No "%s" found in value' % self.sep)
|
||||
value, sig = signed_value.rsplit(self.sep, 1)
|
||||
if constant_time_compare(sig, self.signature(value)):
|
||||
for key in [self.key, *self.fallback_keys]:
|
||||
if constant_time_compare(sig, self.signature(value, key)):
|
||||
return value
|
||||
raise BadSignature('Signature "%s" does not match' % sig)
|
||||
|
||||
|
|
|
@ -59,6 +59,22 @@ or from a file::
|
|||
with open('/etc/secret_key.txt') as f:
|
||||
SECRET_KEY = f.read().strip()
|
||||
|
||||
If rotating secret keys, you may use :setting:`SECRET_KEY_FALLBACKS`::
|
||||
|
||||
import os
|
||||
SECRET_KEY = os.environ['CURRENT_SECRET_KEY']
|
||||
SECRET_KEY_FALLBACKS = [
|
||||
os.environ['OLD_SECRET_KEY'],
|
||||
]
|
||||
|
||||
Ensure that old secret keys are removed from ``SECRET_KEY_FALLBACKS`` in a
|
||||
timely manner.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``SECRET_KEY_FALLBACKS`` setting was added to support rotating secret
|
||||
keys.
|
||||
|
||||
:setting:`DEBUG`
|
||||
----------------
|
||||
|
||||
|
|
|
@ -457,8 +457,8 @@ The following checks are run if you use the :option:`check --deploy` option:
|
|||
* **security.W009**: Your :setting:`SECRET_KEY` has less than 50 characters,
|
||||
less than 5 unique characters, or it's prefixed with ``'django-insecure-'``
|
||||
indicating that it was generated automatically by Django. Please generate a
|
||||
long and random ``SECRET_KEY``, otherwise many of Django's security-critical
|
||||
features will be vulnerable to attack.
|
||||
long and random value, otherwise many of Django's security-critical features
|
||||
will be vulnerable to attack.
|
||||
* **security.W010**: You have :mod:`django.contrib.sessions` in your
|
||||
:setting:`INSTALLED_APPS` but you have not set
|
||||
:setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session
|
||||
|
@ -511,6 +511,12 @@ The following checks are run if you use the :option:`check --deploy` option:
|
|||
to an invalid value.
|
||||
* **security.E024**: You have set the
|
||||
:setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to an invalid value.
|
||||
* **security.W025**: Your
|
||||
:setting:`SECRET_KEY_FALLBACKS[n] <SECRET_KEY_FALLBACKS>` has less than 50
|
||||
characters, less than 5 unique characters, or it's prefixed with
|
||||
``'django-insecure-'`` indicating that it was generated automatically by
|
||||
Django. Please generate a long and random value, otherwise many of Django's
|
||||
security-critical features will be vulnerable to attack.
|
||||
|
||||
The following checks verify that your security-related settings are correctly
|
||||
configured:
|
||||
|
|
|
@ -2291,9 +2291,11 @@ The secret key is used for:
|
|||
* Any usage of :doc:`cryptographic signing </topics/signing>`, unless a
|
||||
different key is provided.
|
||||
|
||||
If you rotate your secret key, all of the above will be invalidated.
|
||||
Secret keys are not used for passwords of users and key rotation will not
|
||||
affect them.
|
||||
When a secret key is no longer set as :setting:`SECRET_KEY` or contained within
|
||||
:setting:`SECRET_KEY_FALLBACKS` all of the above will be invalidated. When
|
||||
rotating your secret key, you should move the old key to
|
||||
:setting:`SECRET_KEY_FALLBACKS` temporarily. Secret keys are not used for
|
||||
passwords of users and key rotation will not affect them.
|
||||
|
||||
.. note::
|
||||
|
||||
|
@ -2301,6 +2303,36 @@ affect them.
|
|||
startproject <startproject>` creates a unique ``SECRET_KEY`` for
|
||||
convenience.
|
||||
|
||||
.. setting:: SECRET_KEY_FALLBACKS
|
||||
|
||||
``SECRET_KEY_FALLBACKS``
|
||||
------------------------
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
Default: ``[]``
|
||||
|
||||
A list of fallback secret keys for a particular Django installation. These are
|
||||
used to allow rotation of the ``SECRET_KEY``.
|
||||
|
||||
In order to rotate your secret keys, set a new ``SECRET_KEY`` and move the
|
||||
previous value to the beginning of ``SECRET_KEY_FALLBACKS``. Then remove the
|
||||
old values from the end of the ``SECRET_KEY_FALLBACKS`` when you are ready to
|
||||
expire the sessions, password reset tokens, and so on, that make use of them.
|
||||
|
||||
.. note::
|
||||
|
||||
Signing operations are computationally expensive. Having multiple old key
|
||||
values in ``SECRET_KEY_FALLBACKS`` adds additional overhead to all checks
|
||||
that don't match an earlier key.
|
||||
|
||||
As such, fallback values should be removed after an appropriate period,
|
||||
allowing for key rotation.
|
||||
|
||||
Uses of the secret key values shouldn't assume that they are text or bytes.
|
||||
Every use should go through :func:`~django.utils.encoding.force_str` or
|
||||
:func:`~django.utils.encoding.force_bytes` to convert it to the desired type.
|
||||
|
||||
.. setting:: SECURE_CONTENT_TYPE_NOSNIFF
|
||||
|
||||
``SECURE_CONTENT_TYPE_NOSNIFF``
|
||||
|
@ -3725,6 +3757,7 @@ Security
|
|||
* :setting:`CSRF_USE_SESSIONS`
|
||||
|
||||
* :setting:`SECRET_KEY`
|
||||
* :setting:`SECRET_KEY_FALLBACKS`
|
||||
* :setting:`X_FRAME_OPTIONS`
|
||||
|
||||
Serialization
|
||||
|
|
|
@ -254,7 +254,8 @@ Requests and Responses
|
|||
Security
|
||||
~~~~~~~~
|
||||
|
||||
* ...
|
||||
* The new :setting:`SECRET_KEY_FALLBACKS` setting allows providing a list of
|
||||
values for secret key rotation.
|
||||
|
||||
Serialization
|
||||
~~~~~~~~~~~~~
|
||||
|
|
|
@ -912,8 +912,9 @@ function.
|
|||
|
||||
Since
|
||||
:meth:`~django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash()`
|
||||
is based on :setting:`SECRET_KEY`, updating your site to use a new secret
|
||||
will invalidate all existing sessions.
|
||||
is based on :setting:`SECRET_KEY`, secret key values must be
|
||||
rotated to avoid invalidating existing sessions when updating your site to
|
||||
use a new secret. See :setting:`SECRET_KEY_FALLBACKS` for details.
|
||||
|
||||
.. _built-in-auth-views:
|
||||
|
||||
|
|
|
@ -123,13 +123,15 @@ and the :setting:`SECRET_KEY` setting.
|
|||
|
||||
.. warning::
|
||||
|
||||
**If the SECRET_KEY is not kept secret and you are using the**
|
||||
``django.contrib.sessions.serializers.PickleSerializer``, **this can
|
||||
lead to arbitrary remote code execution.**
|
||||
**If the ``SECRET_KEY`` or ``SECRET_KEY_FALLBACKS`` are not kept secret and
|
||||
you are using the**
|
||||
``django.contrib.sessions.serializers.PickleSerializer``, **this can lead
|
||||
to arbitrary remote code execution.**
|
||||
|
||||
An attacker in possession of the :setting:`SECRET_KEY` can not only
|
||||
generate falsified session data, which your site will trust, but also
|
||||
remotely execute arbitrary code, as the data is serialized using pickle.
|
||||
An attacker in possession of the :setting:`SECRET_KEY` or
|
||||
:setting:`SECRET_KEY_FALLBACKS` can not only generate falsified session
|
||||
data, which your site will trust, but also remotely execute arbitrary code,
|
||||
as the data is serialized using pickle.
|
||||
|
||||
If you use cookie-based sessions, pay extra care that your secret key is
|
||||
always kept completely secret, for any system which might be remotely
|
||||
|
@ -323,11 +325,12 @@ cookie backend*.
|
|||
|
||||
For example, here's an attack scenario if you use :mod:`pickle` to serialize
|
||||
session data. If you're using the :ref:`signed cookie session backend
|
||||
<cookie-session-backend>` and :setting:`SECRET_KEY` is known by an attacker
|
||||
(there isn't an inherent vulnerability in Django that would cause it to leak),
|
||||
the attacker could insert a string into their session which, when unpickled,
|
||||
executes arbitrary code on the server. The technique for doing so is simple and
|
||||
easily available on the internet. Although the cookie session storage signs the
|
||||
<cookie-session-backend>` and :setting:`SECRET_KEY` (or any key of
|
||||
:setting:`SECRET_KEY_FALLBACKS`) is known by an attacker (there isn't an
|
||||
inherent vulnerability in Django that would cause it to leak), the attacker
|
||||
could insert a string into their session which, when unpickled, executes
|
||||
arbitrary code on the server. The technique for doing so is simple and easily
|
||||
available on the internet. Although the cookie session storage signs the
|
||||
cookie-stored data to prevent tampering, a :setting:`SECRET_KEY` leak
|
||||
immediately escalates to a remote code execution vulnerability.
|
||||
|
||||
|
@ -359,8 +362,8 @@ Bundled serializers
|
|||
.. class:: serializers.PickleSerializer
|
||||
|
||||
Supports arbitrary Python objects, but, as described above, can lead to a
|
||||
remote code execution vulnerability if :setting:`SECRET_KEY` becomes known
|
||||
by an attacker.
|
||||
remote code execution vulnerability if :setting:`SECRET_KEY` or any key of
|
||||
:setting:`SECRET_KEY_FALLBACKS` becomes known by an attacker.
|
||||
|
||||
.. deprecated:: 4.1
|
||||
|
||||
|
|
|
@ -296,7 +296,8 @@ security protection of the web server, operating system and other components.
|
|||
* Django does not throttle requests to authenticate users. To protect against
|
||||
brute-force attacks against the authentication system, you may consider
|
||||
deploying a Django plugin or web server module to throttle these requests.
|
||||
* Keep your :setting:`SECRET_KEY` a secret.
|
||||
* Keep your :setting:`SECRET_KEY`, and :setting:`SECRET_KEY_FALLBACKS` if in
|
||||
use, secret.
|
||||
* It is a good idea to limit the accessibility of your caching system and
|
||||
database using a firewall.
|
||||
* Take a look at the Open Web Application Security Project (OWASP) `Top 10
|
||||
|
|
|
@ -25,8 +25,8 @@ You may also find signing useful for the following:
|
|||
protected resource, for example a downloadable file that a user has
|
||||
paid for.
|
||||
|
||||
Protecting the ``SECRET_KEY``
|
||||
=============================
|
||||
Protecting ``SECRET_KEY`` and ``SECRET_KEY_FALLBACKS``
|
||||
======================================================
|
||||
|
||||
When you create a new Django project using :djadmin:`startproject`, the
|
||||
``settings.py`` file is generated automatically and gets a random
|
||||
|
@ -34,6 +34,14 @@ When you create a new Django project using :djadmin:`startproject`, the
|
|||
data -- it is vital you keep this secure, or attackers could use it to
|
||||
generate their own signed values.
|
||||
|
||||
:setting:`SECRET_KEY_FALLBACKS` can be used to rotate secret keys. The
|
||||
values will not be used to sign data, but if specified, they will be used to
|
||||
validate signed data and must be kept secure.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``SECRET_KEY_FALLBACKS`` setting was added.
|
||||
|
||||
Using the low-level API
|
||||
=======================
|
||||
|
||||
|
@ -93,13 +101,19 @@ 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=None)
|
||||
.. class:: Signer(key=None, sep=':', salt=None, algorithm=None, fallback_keys=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`, it defaults to ``'sha256'``.
|
||||
:py:mod:`hashlib`, it defaults to ``'sha256'``. ``fallback_keys`` is a list
|
||||
of additional values used to validate signed data, defaults to
|
||||
:setting:`SECRET_KEY_FALLBACKS`.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``fallback_keys`` argument was added.
|
||||
|
||||
Using the ``salt`` argument
|
||||
---------------------------
|
||||
|
@ -221,7 +235,11 @@ and tuples) if you pass in a tuple, you will get a list from
|
|||
Returns URL-safe, signed base64 compressed JSON string. Serialized object
|
||||
is signed using :class:`~TimestampSigner`.
|
||||
|
||||
.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None)
|
||||
.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None, fallback_keys=None)
|
||||
|
||||
Reverse of ``dumps()``, raises ``BadSignature`` if signature fails.
|
||||
Checks ``max_age`` (in seconds) if given.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``fallback_keys`` argument was added.
|
||||
|
|
|
@ -140,3 +140,39 @@ class TokenGeneratorTest(TestCase):
|
|||
msg = 'The SECRET_KEY setting must not be empty.'
|
||||
with self.assertRaisesMessage(ImproperlyConfigured, msg):
|
||||
default_token_generator.secret
|
||||
|
||||
def test_check_token_secret_fallbacks(self):
|
||||
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
|
||||
p1 = PasswordResetTokenGenerator()
|
||||
p1.secret = 'secret'
|
||||
tk = p1.make_token(user)
|
||||
p2 = PasswordResetTokenGenerator()
|
||||
p2.secret = 'newsecret'
|
||||
p2.secret_fallbacks = ['secret']
|
||||
self.assertIs(p1.check_token(user, tk), True)
|
||||
self.assertIs(p2.check_token(user, tk), True)
|
||||
|
||||
@override_settings(
|
||||
SECRET_KEY='secret',
|
||||
SECRET_KEY_FALLBACKS=['oldsecret'],
|
||||
)
|
||||
def test_check_token_secret_key_fallbacks(self):
|
||||
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
|
||||
p1 = PasswordResetTokenGenerator()
|
||||
p1.secret = 'oldsecret'
|
||||
tk = p1.make_token(user)
|
||||
p2 = PasswordResetTokenGenerator()
|
||||
self.assertIs(p2.check_token(user, tk), True)
|
||||
|
||||
@override_settings(
|
||||
SECRET_KEY='secret',
|
||||
SECRET_KEY_FALLBACKS=['oldsecret'],
|
||||
)
|
||||
def test_check_token_secret_key_fallbacks_override(self):
|
||||
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
|
||||
p1 = PasswordResetTokenGenerator()
|
||||
p1.secret = 'oldsecret'
|
||||
tk = p1.make_token(user)
|
||||
p2 = PasswordResetTokenGenerator()
|
||||
p2.secret_fallbacks = []
|
||||
self.assertIs(p2.check_token(user, tk), False)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from django.conf import settings
|
||||
from django.core.checks.messages import Error
|
||||
from django.core.checks.messages import Error, Warning
|
||||
from django.core.checks.security import base, csrf, sessions
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
from django.test import SimpleTestCase
|
||||
|
@ -414,6 +414,79 @@ class CheckSecretKeyTest(SimpleTestCase):
|
|||
self.assertEqual(base.check_secret_key(None), [base.W009])
|
||||
|
||||
|
||||
class CheckSecretKeyFallbacksTest(SimpleTestCase):
|
||||
@override_settings(SECRET_KEY_FALLBACKS=[('abcdefghijklmnopqrstuvwx' * 2) + 'ab'])
|
||||
def test_okay_secret_key_fallbacks(self):
|
||||
self.assertEqual(
|
||||
len(settings.SECRET_KEY_FALLBACKS[0]),
|
||||
base.SECRET_KEY_MIN_LENGTH,
|
||||
)
|
||||
self.assertGreater(
|
||||
len(set(settings.SECRET_KEY_FALLBACKS[0])),
|
||||
base.SECRET_KEY_MIN_UNIQUE_CHARACTERS,
|
||||
)
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [])
|
||||
|
||||
def test_no_secret_key_fallbacks(self):
|
||||
with self.settings(SECRET_KEY_FALLBACKS=None):
|
||||
del settings.SECRET_KEY_FALLBACKS
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS', id=base.W025.id),
|
||||
])
|
||||
|
||||
@override_settings(SECRET_KEY_FALLBACKS=[
|
||||
base.SECRET_KEY_INSECURE_PREFIX + get_random_secret_key()
|
||||
])
|
||||
def test_insecure_secret_key_fallbacks(self):
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
|
||||
])
|
||||
|
||||
@override_settings(SECRET_KEY_FALLBACKS=[('abcdefghijklmnopqrstuvwx' * 2) + 'a'])
|
||||
def test_low_length_secret_key_fallbacks(self):
|
||||
self.assertEqual(
|
||||
len(settings.SECRET_KEY_FALLBACKS[0]),
|
||||
base.SECRET_KEY_MIN_LENGTH - 1,
|
||||
)
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
|
||||
])
|
||||
|
||||
@override_settings(SECRET_KEY_FALLBACKS=['abcd' * 20])
|
||||
def test_low_entropy_secret_key_fallbacks(self):
|
||||
self.assertGreater(
|
||||
len(settings.SECRET_KEY_FALLBACKS[0]),
|
||||
base.SECRET_KEY_MIN_LENGTH,
|
||||
)
|
||||
self.assertLess(
|
||||
len(set(settings.SECRET_KEY_FALLBACKS[0])),
|
||||
base.SECRET_KEY_MIN_UNIQUE_CHARACTERS,
|
||||
)
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
|
||||
])
|
||||
|
||||
@override_settings(SECRET_KEY_FALLBACKS=[
|
||||
('abcdefghijklmnopqrstuvwx' * 2) + 'ab',
|
||||
'badkey',
|
||||
])
|
||||
def test_multiple_keys(self):
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[1]', id=base.W025.id),
|
||||
])
|
||||
|
||||
@override_settings(SECRET_KEY_FALLBACKS=[
|
||||
('abcdefghijklmnopqrstuvwx' * 2) + 'ab',
|
||||
'badkey1',
|
||||
'badkey2',
|
||||
])
|
||||
def test_multiple_bad_keys(self):
|
||||
self.assertEqual(base.check_secret_key_fallbacks(None), [
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[1]', id=base.W025.id),
|
||||
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[2]', id=base.W025.id),
|
||||
])
|
||||
|
||||
|
||||
class CheckDebugTest(SimpleTestCase):
|
||||
@override_settings(DEBUG=True)
|
||||
def test_debug_true(self):
|
||||
|
|
|
@ -483,6 +483,7 @@ class TestListSettings(SimpleTestCase):
|
|||
"INSTALLED_APPS",
|
||||
"TEMPLATE_DIRS",
|
||||
"LOCALE_PATHS",
|
||||
"SECRET_KEY_FALLBACKS",
|
||||
)
|
||||
|
||||
def test_tuple_settings(self):
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import datetime
|
||||
|
||||
from django.core import signing
|
||||
from django.test import SimpleTestCase
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
from django.test.utils import freeze_time
|
||||
from django.utils.crypto import InvalidAlgorithm
|
||||
|
||||
|
@ -178,6 +178,39 @@ class TestSigner(SimpleTestCase):
|
|||
with self.assertRaisesMessage(ValueError, msg % sep):
|
||||
signing.Signer(sep=sep)
|
||||
|
||||
def test_verify_with_non_default_key(self):
|
||||
old_signer = signing.Signer('secret')
|
||||
new_signer = signing.Signer('newsecret', fallback_keys=['othersecret', 'secret'])
|
||||
signed = old_signer.sign('abc')
|
||||
self.assertEqual(new_signer.unsign(signed), 'abc')
|
||||
|
||||
def test_sign_unsign_multiple_keys(self):
|
||||
"""The default key is a valid verification key."""
|
||||
signer = signing.Signer('secret', fallback_keys=['oldsecret'])
|
||||
signed = signer.sign('abc')
|
||||
self.assertEqual(signer.unsign(signed), 'abc')
|
||||
|
||||
@override_settings(
|
||||
SECRET_KEY='secret',
|
||||
SECRET_KEY_FALLBACKS=['oldsecret'],
|
||||
)
|
||||
def test_sign_unsign_ignore_secret_key_fallbacks(self):
|
||||
old_signer = signing.Signer('oldsecret')
|
||||
signed = old_signer.sign('abc')
|
||||
signer = signing.Signer(fallback_keys=[])
|
||||
with self.assertRaises(signing.BadSignature):
|
||||
signer.unsign(signed)
|
||||
|
||||
@override_settings(
|
||||
SECRET_KEY='secret',
|
||||
SECRET_KEY_FALLBACKS=['oldsecret'],
|
||||
)
|
||||
def test_default_keys_verification(self):
|
||||
old_signer = signing.Signer('oldsecret')
|
||||
signed = old_signer.sign('abc')
|
||||
signer = signing.Signer()
|
||||
self.assertEqual(signer.unsign(signed), 'abc')
|
||||
|
||||
|
||||
class TestTimestampSigner(SimpleTestCase):
|
||||
|
||||
|
|
|
@ -1459,6 +1459,7 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
|
|||
"""
|
||||
sensitive_settings = [
|
||||
'SECRET_KEY',
|
||||
'SECRET_KEY_FALLBACKS',
|
||||
'PASSWORD',
|
||||
'API_KEY',
|
||||
'AUTH_TOKEN',
|
||||
|
@ -1475,6 +1476,7 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
|
|||
"""
|
||||
sensitive_settings = [
|
||||
'SECRET_KEY',
|
||||
'SECRET_KEY_FALLBACKS',
|
||||
'PASSWORD',
|
||||
'API_KEY',
|
||||
'AUTH_TOKEN',
|
||||
|
|
Loading…
Reference in New Issue