Plain is headed towards 1.0! Subscribe for development updates →

plain.password

Password authentication for Plain.

  1from datetime import datetime
  2
  3from plain.runtime import settings
  4from plain.utils.crypto import constant_time_compare, salted_hmac
  5from plain.utils.http import base36_to_int, int_to_base36
  6
  7
  8class PasswordResetTokenGenerator:
  9    """
 10    Strategy object used to generate and check tokens for the password
 11    reset mechanism.
 12    """
 13
 14    key_salt = "plain.auth.tokens.PasswordResetTokenGenerator"
 15    algorithm = None
 16    _secret = None
 17    _secret_fallbacks = None
 18
 19    def __init__(self):
 20        self.algorithm = self.algorithm or "sha256"
 21
 22    def _get_secret(self):
 23        return self._secret or settings.SECRET_KEY
 24
 25    def _set_secret(self, secret):
 26        self._secret = secret
 27
 28    secret = property(_get_secret, _set_secret)
 29
 30    def _get_fallbacks(self):
 31        if self._secret_fallbacks is None:
 32            return settings.SECRET_KEY_FALLBACKS
 33        return self._secret_fallbacks
 34
 35    def _set_fallbacks(self, fallbacks):
 36        self._secret_fallbacks = fallbacks
 37
 38    secret_fallbacks = property(_get_fallbacks, _set_fallbacks)
 39
 40    def make_token(self, user):
 41        """
 42        Return a token that can be used once to do a password reset
 43        for the given user.
 44        """
 45        return self._make_token_with_timestamp(
 46            user,
 47            self._num_seconds(self._now()),
 48            self.secret,
 49        )
 50
 51    def check_token(self, user, token):
 52        """
 53        Check that a password reset token is correct for a given user.
 54        """
 55        if not (user and token):
 56            return False
 57        # Parse the token
 58        try:
 59            ts_b36, _ = token.split("-")
 60        except ValueError:
 61            return False
 62
 63        try:
 64            ts = base36_to_int(ts_b36)
 65        except ValueError:
 66            return False
 67
 68        # Check that the timestamp/uid has not been tampered with
 69        for secret in [self.secret, *self.secret_fallbacks]:
 70            if constant_time_compare(
 71                self._make_token_with_timestamp(user, ts, secret),
 72                token,
 73            ):
 74                break
 75        else:
 76            return False
 77
 78        # Check the timestamp is within limit.
 79        if (self._num_seconds(self._now()) - ts) > settings.PASSWORD_RESET_TIMEOUT:
 80            return False
 81
 82        return True
 83
 84    def _make_token_with_timestamp(self, user, timestamp, secret):
 85        # timestamp is number of seconds since 2001-1-1. Converted to base 36,
 86        # this gives us a 6 digit string until about 2069.
 87        ts_b36 = int_to_base36(timestamp)
 88        hash_string = salted_hmac(
 89            self.key_salt,
 90            self._make_hash_value(user, timestamp),
 91            secret=secret,
 92            algorithm=self.algorithm,
 93        ).hexdigest()[::2]  # Limit to shorten the URL.
 94        return f"{ts_b36}-{hash_string}"
 95
 96    def _make_hash_value(self, user, timestamp):
 97        """
 98        Hash the user's primary key, email (if available), and some user state
 99        that's sure to change after a password reset to produce a token that is
100        invalidated when it's used:
101        1. The password field will change upon a password reset (even if the
102           same password is chosen, due to password salting).
103        Failing those things, settings.PASSWORD_RESET_TIMEOUT eventually
104        invalidates the token.
105
106        Running this data through salted_hmac() prevents password cracking
107        attempts using the reset token, provided the secret isn't compromised.
108        """
109        return f"{user.pk}{user.password}{timestamp}{user.email}"
110
111    def _num_seconds(self, dt):
112        return int((dt - datetime(2001, 1, 1)).total_seconds())
113
114    def _now(self):
115        # Used for mocking in tests
116        return datetime.now()
117
118
119default_token_generator = PasswordResetTokenGenerator()