Escribiendo un custom hasher para llevar contraseñas de un sistema hecho en Java/Grails a Django

nahuelsegovia

Nahuel Segovia

Posted on November 15, 2022

Escribiendo un custom hasher para llevar contraseñas de un sistema hecho en Java/Grails a Django

Contexto: Cliente con un sistema hecho en Java/Grails pide que se re-escriba el proyecto en Django, ya que se les dificultaba encontrar desarrolladores para poder mantenerlo.

Problema: Muchos, pero uno de ellos era que la forma en que se hasheaban las contraseñas de los usuarios en la plataforma legacy no coinciden con las que trae Django por defecto, por lo que no encontrábamos la forma de llevar esas contraseñas al proyecto nuevo.

Cosas que se intentaron:

  • A través de settings.py, especifcarle a django en el array de configuración PASSWORD_HASHERS que utilice sha256 como algoritmo de cifrado para las contraseñas

Problema: escribía las contraseñas pero además les agregaba un salt y otros caracteres que no nos permitía hacer coincidir con los hashes que teníamos en producción

  • Crear una clase nueva que extienda de AbstractUser y pisarle los métodos en donde escribía la contraseña de los usuarios, para que cuando vayamos a crear los usuarios esta los haga como requeríamos(muy mala idea, pero hay que pensar fuera de la caja cuando sea posible)

Problema: por supuesto que se rompía por todos lados y lo descarté rápidamente

Momento de reflexión: Decidí que debería entender un poco más de como funcionaba así que primero fui a buscar código en donde estábamos creando usuarios de forma automática en el momento en que el proyecto se levanta en los contenedores. Descubrí que estábamos usando el método instanciaUsuario.set_password para crearle la contraseña a un usuario con permisos de administración.

Fui al código y llegué hasta el archivo base_user.py que contiene ese método, y me encontré con esto:


def set_password(self, raw_password):
        self.password = make_password(raw_password)
        self._password = raw_password
Enter fullscreen mode Exit fullscreen mode

El método make_password me llevó hasta el archivo hashers.py, en donde se encuentran diferentes clases que contienen la configuración para diferentes tipos de algoritmos más alguna que otra cosita que le agregaba Django al algoritmo, como salt y caracteres, etc.

Todos extendían de la clase BasePasswordHasher, y ya que estamos pego el código:


class BasePasswordHasher:
    """
    Abstract base class for password hashers

    When creating your own hasher, you need to override algorithm,
    verify(), encode() and safe_summary().

    PasswordHasher objects are immutable.
    """
    algorithm = None
    library = None
    salt_entropy = 128

    def _load_library(self):
        if self.library is not None:
            if isinstance(self.library, (tuple, list)):
                name, mod_path = self.library
            else:
                mod_path = self.library
            try:
                module = importlib.import_module(mod_path)
            except ImportError as e:
                raise ValueError("Couldn't load %r algorithm library: %s" %
                                 (self.__class__.__name__, e))
            return module
        raise ValueError("Hasher %r doesn't specify a library attribute" %
                         self.__class__.__name__)

    def salt(self):
        """
        Generate a cryptographically secure nonce salt in ASCII with an entropy
        of at least `salt_entropy` bits.
        """
        # Each character in the salt provides
        # log_2(len(alphabet)) bits of entropy.
        char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
        return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)

    def verify(self, password, encoded):
        """Check if the given password is correct."""
        raise NotImplementedError('subclasses of BasePasswordHasher must provide a verify() method')

    def _check_encode_args(self, password, salt):
        if password is None:
            raise TypeError('password must be provided.')
        if not salt or '$' in salt:
            raise ValueError('salt must be provided and cannot contain $.')

    def encode(self, password, salt):
        """
        Create an encoded database value.

        The result is normally formatted as "algorithm$salt$hash" and
        must be fewer than 128 characters.
        """
        raise NotImplementedError('subclasses of BasePasswordHasher must provide an encode() method')

    def decode(self, encoded):
        """
        Return a decoded database value.

        The result is a dictionary and should contain `algorithm`, `hash`, and
        `salt`. Extra keys can be algorithm specific like `iterations` or
        `work_factor`.
        """
        raise NotImplementedError(
            'subclasses of BasePasswordHasher must provide a decode() method.'
        )

    def safe_summary(self, encoded):
        """
        Return a summary of safe values.

        The result is a dictionary and will be used where the password field
        must be displayed to construct a safe representation of the password.
        """
        raise NotImplementedError('subclasses of BasePasswordHasher must provide a safe_summary() method')

    def must_update(self, encoded):
        return False

    def harden_runtime(self, password, encoded):
        """
        Bridge the runtime gap between the work factor supplied in `encoded`
        and the work factor suggested by this hasher.

        Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
        `self.iterations` is 30000, this method should run password through
        another 10000 iterations of PBKDF2. Similar approaches should exist
        for any hasher that has a work factor. If not, this method should be
        defined as a no-op to silence the warning.
        """
        warnings.warn('subclasses of BasePasswordHasher should provide a harden_runtime() method')
Enter fullscreen mode Exit fullscreen mode

Podemos ver después de esta clase, que tenemos PBKDF2PasswordHasher, que ya en los comentarios nos dice esto:


"""
    Secure password hashing using the PBKDF2 algorithm (recommended)

    Configured to use PBKDF2 + HMAC + SHA256.
    The result is a 64 byte binary string.  Iterations may be changed
    safely but you must rename the algorithm if you change SHA256.
    """
Enter fullscreen mode Exit fullscreen mode

La primera parte del código nos muestra como crean el hash para guardarlo en la base de datos y de que manera lo hacen


algorithm = "pbkdf2_sha256"
    iterations = 320000
    digest = hashlib.sha256

    def encode(self, password, salt, iterations=None):
        self._check_encode_args(password, salt)
        iterations = iterations or self.iterations
        hash = pbkdf2(password, salt, iterations, digest=self.digest)
        hash = base64.b64encode(hash).decode('ascii').strip()
        return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash)

    def decode(self, encoded):
        algorithm, iterations, salt, hash = encoded.split('$', 3)
        assert algorithm == self.algorithm
        return {
            'algorithm': algorithm,
            'hash': hash,
            'iterations': int(iterations),
            'salt': salt,
        }

    def verify(self, password, encoded):
        decoded = self.decode(encoded)
        encoded_2 = self.encode(password, decoded['salt'], decoded['iterations'])
        return constant_time_compare(encoded, encoded_2)
Enter fullscreen mode Exit fullscreen mode

El método encode es quien hashea nuestras contraseñas, y verify, parecía ser el encargado de comprobar el hash guardado con el que se le pasa en el login.

Solución: Crear nuestra propia clase para cifrar nuestras contraseñas con sha256:

from django.contrib.auth.hashers import BasePasswordHasher
from django.utils.crypto import constant_time_compare


class CustomizedPasswordHasher(BasePasswordHasher):
    algorithm = 'sha256' #NOTA: SI O SI HAY QUE PASARLE EL ALGORITMO
    def encode(self, password, salt):
        assert password is not None
        hash = hashlib.sha256(password.encode()).hexdigest()
        return hash

    def verify(self, password, encoded):
        encoded_2 = self.encode(password, '')
        return constant_time_compare(encoded, encoded_2)
Enter fullscreen mode Exit fullscreen mode

De esta forma nos aseguramos de guardar las contraseñas solo en sha256, sin salt, sin caracteres especiales, tal y cual como estaban en producción. Solo nos queda agregar nuestro hasher a la configuración del proyecto en settings.py:

PASSWORD_HASHERS = [
    'backend.utils.CustomizedPasswordHasher',
]
Enter fullscreen mode Exit fullscreen mode

Resultado:

Image description

Documentación oficial de Django acerca de PASSWORD_HASHERS:

https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-PASSWORD_HASHERS

💖 💪 🙅 🚩
nahuelsegovia
Nahuel Segovia

Posted on November 15, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related