Escribiendo un custom hasher para llevar contraseñas de un sistema hecho en Java/Grails a Django
Nahuel Segovia
Posted on November 15, 2022
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
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')
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.
"""
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)
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)
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',
]
Resultado:
Documentación oficial de Django acerca de PASSWORD_HASHERS:
https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-PASSWORD_HASHERS
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
November 15, 2022