How to migrate unsalted SHA1 password hashes to PBKDF2 in django

2023-03-21

Backstory

Recently, I needed to migrate a very old PHP site with about 25 thousand users to django. One of the tasks was user migration, which is the topic of this post.

The legacy PHP application consisted of many PHP files, but did not use a framework. The passwords were hashed with SHA1 and stored without salt in the database. To give you a glimpse of how the code looked like:

src/register.php
$q="INSERT INTO users (user_id, username, pass, email, registration_date, ip) VALUES (NULL, '$un', SHA1('$p'), '$email', NOW(), '$ip')";

src/adm/edit.php
$q="UPDATE users SET pass=SHA1('$pass'), email='$email' WHERE user_id=$uid";

I’m a big fan of first get it to work, then polish / iterate on it. So first I made it work by importing the PHP users to django.

First, I enabled support for unsalted passwords in settings.py:

PASSWORD_HASHERS = [
    (...)
    "django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher",
]

Then I imported each row with something like this:

for row in rows:
    User.objects.get_or_create(
        email=row["email"],
        defaults={
            "username": row["username"],
            "password": "sha1$$" + row["pass"],  # note the added "sha1$$" and no salt between "$"
            "date_joined": row["registration_date"],
        },
    )

And it worked! On first user login after the migration, django automatically upgrades the password hash to PBKDF2. But since it can take a while until all users have logged in (and some never will) it is best to upgrade all passwords using a script and be done with it.

The actual migration

This how-to assumes that you have in your User.password column passwords which start with sha1$$.

We’ll follow a slightly modified version of the Password upgrading without requiring a login method from the django docs.

  1. Create a “wrapped” password hasher
from django.contrib.auth.hashers import PBKDF2PasswordHasher, UnsaltedSHA1PasswordHasher


class PBKDF2WrappedUnsaltedSHA1PasswordHasher(PBKDF2PasswordHasher):
    algorithm = "pbkdf2_wrapped_unsalted_sha1"

    def encode_sha1_hash(self, sha1_hash, salt, iterations=None):
        return super().encode(sha1_hash, salt, iterations)

    def encode(self, password, salt, iterations=None):
        # empty salt for SHA1, normal salt for PBKDF2
        _, _, sha1_hash = (
            UnsaltedSHA1PasswordHasher().encode(password, "").split("$", 2)
        )
        return self.encode_sha1_hash(sha1_hash, salt, iterations)
  1. Add it to settings.py
PASSWORD_HASHERS = [
    (...)
    "your.app.hashers.PBKDF2WrappedUnsaltedSHA1PasswordHasher",
]
  1. Migrate the password hashes

The django example uses a migration, but I don’t like long-running migrations, so I did it in a management command and not all 25k at once.

from your.app.hashers import PBKDF2WrappedUnsaltedSHA1PasswordHasher

hasher = PBKDF2WrappedUnsaltedSHA1PasswordHasher()

users = User.objects.filter(password__startswith='sha1$$')
for user in users:
    algorithm, empty_salt, sha1_hash = user.password.split('$', 2)
    # since the salt is empty we generate a new one
    user.password = hasher.encode_sha1_hash(sha1_hash, hasher.salt())
    user.save(update_fields=['password'])
  1. Afterwards, if you used the temporarily needed UnsaltedSHA1PasswordHasher you need to remove it now, if not you are done :-)
Articlesdjangolegacyphp

Random 404 client errors while using the Microsoft Graph API for fetching emails