Reputation: 1092
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
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
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