Search code examples
djangodjango-authentication

Django unexpectedly stores only password hash, not algorithm parameters


I have a Django app which works as expected locally. It creates a user in a migration:

superuser = User.objects.create_superuser(
  username=username, email=email, password=password
)
superuser.save()

Locally it creates a password structure exactly as I'd expect:

MySQL [XXXX]> select * from auth_user;
+----+---------------------------------------------------------------------------+----------------------------+--------------+----------+------------+-----------+-------------------+----------+-----------+----------------------------+
| id | password                                                                  | last_login                 | is_superuser | username | first_name | last_name | email             | is_staff | is_active | date_joined                |
+----+---------------------------------------------------------------------------+----------------------------+--------------+----------+------------+-----------+-------------------+----------+-----------+----------------------------+
|  5 | argon2$argon2i$v=19$m=512,t=2,p=2$SXXXXXXXXXX2eVFl$KZdVItv/XXXXXXXXXXXuRg | 2020-05-15 16:26:01.713174 |            1 | internal |            |           | XXX@XXX.org |        1 |         1 | 2020-05-15 16:25:12.438746 |
+----+---------------------------------------------------------------------------+----------------------------+--------------+----------+------------+-----------+-------------------+----------+-----------+----------------------------+

In production it did something very odd, storing the hash but not any algorithm data:

MySQL [XXXX]> select * from auth_user;
+----+-------------------------------------------+------------+--------------+----------+------------+-----------+-------------------+----------+-----------+----------------------------+
| id | password                                  | last_login | is_superuser | username | first_name | last_name | email             | is_staff | is_active | date_joined|
+----+-------------------------------------------+------------+--------------+----------+------------+-----------+-------------------+----------+-----------+----------------------------+
|  1 | !rbx7XXXXXXXXXXXXXXXXu7o84FNI3tZcQc5Lgkqt | NULL       |            1 | internal |            |           | XXX@XXX.org |        1 |         1 | 2020-05-15 09:43:49.955879|
+----+-------------------------------------------+------------+--------------+----------+------------+-----------+-------------------+----------+-----------+----------------------------+

I have verified that the same docker image checksum is used for local testing and remotely. My requirements file is:

#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile requirements.in
#
argon2-cffi==19.2.0       # via django
boto==2.49.0              # via django-ses
brotli==1.0.7             # via whitenoise
certifi==2020.4.5.1       # via requests, sentry-sdk
cffi==1.14.0              # via argon2-cffi
chardet==3.0.4            # via requests
django-environ==0.4.5     # via -r requirements.in
django-ipware==2.1.0      # via django-structlog
django-prometheus==1.1.0  # via -r requirements.in
django-ses==0.8.14        # via -r requirements.in
django-structlog==1.5.2   # via -r requirements.in
django-zxcvbn-password==2.1.0  # via -r requirements.in
django[argon2]==2.2.3     # via -r requirements.in, django-structlog, djangorestframework
djangorestframework==3.11.0  # via -r requirements.in
future==0.18.2            # via django-ses
gunicorn==20.0.4          # via -r requirements.in
idna==2.9                 # via requests
incuna-mail==4.0.0        # via -r requirements.in
mysqlclient==1.4.6        # via -r requirements.in
prometheus-client==0.7.1  # via django-prometheus
pycparser==2.20           # via cffi
pytz==2019.3              # via django, django-ses
requests==2.23.0          # via -r requirements.in
sentry-sdk==0.14.3        # via -r requirements.in
six==1.14.0               # via -r requirements.in, argon2-cffi, structlog
sqlparse==0.3.1           # via django
structlog==20.1.0         # via django-structlog
urllib3==1.25.9           # via requests, sentry-sdk
whitenoise[brotli]==5.0.1  # via -r requirements.in
zxcvbn==4.4.28            # via django-zxcvbn-password

# The following packages are considered to be unsafe in a requirements file:
# setuptools

What could cause this?


Solution

  • This was caused by a blank password supplied in config resulting in an un-usable password being set. User.objects.create_superuser sets an unusable password when an empty string is supplied. The docs say:

    If no password is provided, set_unusable_password() will be called.

    https://docs.djangoproject.com/en/3.0/ref/contrib/auth/#django.contrib.auth.models.UserManager.create_user

    However an empty string seems to be treated as "no password" (which was unexpected, although not so surprising given Python's falsy treatment of empty strings). There was a bug in the configuration of the service which caused an empty password to be passed to it.

    The reason for the unexpected different format is that an un-usable password appears not to use the same hash function structure.