Thanks Gordon Wrigley for the report and implementation idea. Regression in 226ebb17290b604ef29e82fb5c1fbac3594ac163. Backport of 34180922380cf41cd684f846ecf00f92eb289bcf from master
115 lines
4.3 KiB
Python
115 lines
4.3 KiB
Python
from datetime import datetime, time
|
|
|
|
from django.conf import settings
|
|
from django.utils.crypto import constant_time_compare, salted_hmac
|
|
from django.utils.http import base36_to_int, int_to_base36
|
|
|
|
|
|
class PasswordResetTokenGenerator:
|
|
"""
|
|
Strategy object used to generate and check tokens for the password
|
|
reset mechanism.
|
|
"""
|
|
key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
|
|
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
|
|
for the given user.
|
|
"""
|
|
return self._make_token_with_timestamp(user, self._num_seconds(self._now()))
|
|
|
|
def check_token(self, user, token):
|
|
"""
|
|
Check that a password reset token is correct for a given user.
|
|
"""
|
|
if not (user and token):
|
|
return False
|
|
# Parse the token
|
|
try:
|
|
ts_b36, _ = token.split("-")
|
|
# RemovedInDjango40Warning.
|
|
legacy_token = len(ts_b36) < 4
|
|
except ValueError:
|
|
return False
|
|
|
|
try:
|
|
ts = base36_to_int(ts_b36)
|
|
except ValueError:
|
|
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):
|
|
# RemovedInDjango40Warning: when the deprecation ends, replace
|
|
# with:
|
|
# return False
|
|
if not constant_time_compare(
|
|
self._make_token_with_timestamp(user, ts, legacy=True),
|
|
token,
|
|
):
|
|
return False
|
|
|
|
# RemovedInDjango40Warning: convert days to seconds and round to
|
|
# midnight (server time) for pre-Django 3.1 tokens.
|
|
now = self._now()
|
|
if legacy_token:
|
|
ts *= 24 * 60 * 60
|
|
ts += int((now - datetime.combine(now.date(), time.min)).total_seconds())
|
|
# Check the timestamp is within limit.
|
|
if (self._num_seconds(now) - ts) > settings.PASSWORD_RESET_TIMEOUT:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _make_token_with_timestamp(self, user, timestamp, legacy=False):
|
|
# 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,
|
|
# RemovedInDjango40Warning: when the deprecation ends, remove the
|
|
# legacy argument and replace with:
|
|
# algorithm=self.algorithm,
|
|
algorithm='sha1' if legacy else self.algorithm,
|
|
).hexdigest()[::2] # Limit to 20 characters to shorten the URL.
|
|
return "%s-%s" % (ts_b36, hash_string)
|
|
|
|
def _make_hash_value(self, user, timestamp):
|
|
"""
|
|
Hash the user's primary key and some user state that's sure to change
|
|
after a password reset to produce a token that invalidated when it's
|
|
used:
|
|
1. The password field will change upon a password reset (even if the
|
|
same password is chosen, due to password salting).
|
|
2. The last_login field will usually be updated very shortly after
|
|
a password reset.
|
|
Failing those things, settings.PASSWORD_RESET_TIMEOUT eventually
|
|
invalidates the token.
|
|
|
|
Running this data through salted_hmac() prevents password cracking
|
|
attempts using the reset token, provided the secret isn't compromised.
|
|
"""
|
|
# Truncate microseconds so that tokens are consistent even if the
|
|
# database doesn't support microseconds.
|
|
login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None)
|
|
return str(user.pk) + user.password + str(login_timestamp) + str(timestamp)
|
|
|
|
def _num_seconds(self, dt):
|
|
return int((dt - datetime(2001, 1, 1)).total_seconds())
|
|
|
|
def _now(self):
|
|
# Used for mocking in tests
|
|
return datetime.now()
|
|
|
|
|
|
default_token_generator = PasswordResetTokenGenerator()
|