Artur Siepietowski
Artur Siepietowski

Reputation: 1092

Django password hasher using php format of function password_hash()

I have to add a backward-compatible Django application that supports legacy passwords persisted in a database created with the use of PHP function password_hash() which output is like

$2y$10$puZfZbp0UGMYeUiyZjdfB.4RN9frEMy8ENpih9.jOEngy1FJWUAHy

(salted blowfish crypt algorithm with 10 hashing rounds)

Django supports formats with the prefixed name of the algorithm so if I use BCryptPasswordHasher as the main hasher output will be like:

bcrypt$$2y$10$puZfZbp0UGMYeUiyZjdfB.4RN9frEMy8ENpih9.jOEngy1FJWUAHy

I have created custom BCryptPasswordHasher like:

class BCryptPasswordHasher(BasePasswordHasher):
    algorithm = "bcrypt_php"
    library = ("bcrypt", "bcrypt")
    rounds = 10

    def salt(self):
        bcrypt = self._load_library()
        return bcrypt.gensalt(self.rounds)

    def encode(self, password, salt):
        bcrypt = self._load_library()
        password = password.encode()
        data = bcrypt.hashpw(password, salt)
        return f"{data.decode('ascii')}"

    def verify(self, incoming_password, encoded_db_password):
        algorithm, data = encoded_db_password.split('$', 1)
        assert algorithm == self.algorithm

        db_password_salt = data.encode('ascii')
        encoded_incoming_password = self.encode(incoming_password, db_password_salt)
        # Compare of `data` should only be done because in database we don't persist alg prefix like `bcrypt$`
        return constant_time_compare(data, encoded_incoming_password)

    def safe_summary(self, encoded):
        empty, algostr, work_factor, data = encoded.split('$', 3)
        salt, checksum = data[:22], data[22:]
        return OrderedDict([
            ('algorithm', self.algorithm),
            ('work factor', work_factor),
            ('salt', mask_hash(salt)),
            ('checksum', mask_hash(checksum)),
        ])

    def must_update(self, encoded):
        return False

    def harden_runtime(self, password, encoded):
        data = encoded.split('$')
        salt = data[:29]  # Length of the salt in bcrypt.
        rounds = data.split('$')[2]
        # work factor is logarithmic, adding one doubles the load.
        diff = 2 ** (self.rounds - int(rounds)) - 1
        while diff > 0:
            self.encode(password, salt.encode('ascii'))
            diff -= 1

And AUTH_USER_MODEL like:

from django.contrib.auth.hashers import check_password
from django.db import models


class User(models.Model):
    id = models.BigAutoField(primary_key=True)
    email = models.EmailField(unique=True)
    password = models.CharField(max_length=120, blank=True, null=True)
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []
    EMAIL_FIELD = 'email'

    def check_password(self, raw_password):
        def setter():
            pass

        alg_prefix = "bcrypt_php$"
        password_with_alg_prefix = alg_prefix + self.password
        return check_password(raw_password, password_with_alg_prefix, setter)

Settings base.py:

...
AUTH_USER_MODEL = 'custom.User'

PASSWORD_HASHERS = [
    'custom.auth.hashers.BCryptPasswordHasher',
]
...

In that case, before the validation of password, I add bcrypt$ prefix and then do validation but in the database, the password is kept without bcrypt$.

It works but I'm wondering if there is some other easier way to do this, or maybe someone meets the same problem?

I want to add that both PHP application and new Django should support both formats and I cannot do changes on the legacy PHP. Changes only could be done on new Django server.

Upvotes: 4

Views: 1001

Answers (2)

gwanchi
gwanchi

Reputation: 39

You can use your Custom authentication backend. First create a file in your auth app (say you name it auth), call the file backends.py

Contents of backends.py

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend

UserModel = get_user_model()


class ModelBackend(BaseBackend):
    """
    Authenticate against the settings ADMIN_LOGIN and ADMIN_PASSWORD.

    Use the login name and a hash of the password. For example:

    ADMIN_LOGIN = 'admin'
    ADMIN_PASSWORD = 'pbkdf2_sha256$30000$Vo0VlMnkR4Bk$qEvtdyZRWTcOsCnI/oQ7fVOu1XAURIZYoOZ3iq8Dr4M='
    """

    def authenticate(self, request, username=None, password=None, **kwargs):
        if username is None:
            username = kwargs.get(UserModel.USERNAME_FIELD)
        if username is None or password is None:
            return
        try:
            user = UserModel._default_manager.get_by_natural_key(username)
        except UserModel.DoesNotExist:
            # Run the default password hasher once to reduce the timing
            # difference between an existing and a nonexistent user (#20760).
            UserModel().set_password(password)
        else:
            if user.check_password(password):
                # user exists
                return user

Then in your settings.py include your backends.py in AUTHENTICATION_BACKENDS variables

It should look something like this

AUTHENTICATION_BACKENDS = [
    "djangoprojectname.auth.backends.ModelBackend",
    "django.contrib.auth.backends.ModelBackend",
]

Last point is to implement the check_password method in your auth model, based in our example here, we open file models.py in auth app, within the class User we add the method to implement check password based on bcrypt algorithm.

import bcrypt

class User(AbstractUser):
    first_name = models.CharField(max_length=255, null=True)
    email = models.CharField(unique=True, max_length=255, blank=True, null=True)

    def check_password(self, raw_password):
        def setter():
            pass

        check = bcrypt.checkpw(bytes(raw_password, 'utf-8'), bytes(self.password, 'utf-8'))
        return check

    def set_password(self, raw_password):
        hashed = bcrypt.hashpw(bytes(raw_password, 'utf-8'), bcrypt.gensalt(rounds=10))
        encrypted = str(hashed, 'UTF-8')
        self.password = encrypted
        self._password = encrypted 

Then, in your login view probably you will have to implement something like this, in your views.py in auth app

username = data.get("username")
password = data.get("password")

if username is None or password is None:
    pass # implement for requesting username and password

user = authenticate(username=username, password=password)
if user is None:
    pass # implement for invalid credentials

# check user confirmation
confirmed = getattr(user, 'confirmed', None)
if confirmed is False or None:
    pass # implement for user not confirmed

# check if user is active
isactive = getattr(user, 'isactive', None)
if isactive is False or None:
    pass # implement for user account disabled

login(request, user)

# return view or json response or whatever for login success

Upvotes: 2

Denis Untevskiy
Denis Untevskiy

Reputation: 123

To solve the inversed task of password checking for hashes generated with PHP's password_hash(password, PASSWORD_DEFAULT)

from django.contrib.auth.hashers import check_password
check_password(decoded_pass, 'bcrypt${0}'.format(php_hash), preferred='bcrypt')

worked for me.

Tested with Django 2.2. The bcrypt$ prefix hack is based on another SO answer.

Upvotes: 1

Related Questions