How support hashed fields in Django Admin?

Hello everyone,
in my current django project (django rest framework) I want to hash a field on the user model. So I edited the default User Model and added the field I want to hash.

class MyUserModel(AbstractBaseUser, PermissionsMixin):
    pin = models.CharField(max_length=512)

for the hashing I wanted to use the make_password method inside the save method of the model.
…but isnt that hashing the field on every save - so its also hashing the hash if just something else is getting edited? Or is that the right intention to hash the field in the save method?
Like here: https://stackoverflow.com/questions/51682594/python-django-how-to-create-hash-fields-inside-django-models-py

My final question about that topic - how can I make this editable in django admin, just like the password property? I want to be able to set a new value for that field that is getting hashed afterwards, but I cant just show and edit the hashed field, else the hash was getting hashed when I change something else and hit save. The password field got an own page for setting a new password, is that somehow possible for me and that hashed field too?

I hope you get what I want, if not please just ask. Thanks for any help!
Have a good one :slight_smile:

I wouldn’t do the hashing within the model, I would do it in the views that accept the data being submitted.

You potentially have two different cases

  • Supplying the original data
  • Supplying the hashed value

In the first case, you want to accept the original data, hash it, and save the hashed value in the model.

In the second case, the assumption is that you’re receiving the hashed value, which means you would be saving it directly in the model field.

In either case, once you have the hashed value in the field, you don’t want to hash it again, and so this isn’t something to be done within the model.

Thank you for your fast reply!

Those are good ideas, sadly not relevant for my project because It looks like I forgot providing some informations…

This pin property on my User Model is used for authentication, for that little project you shouldnt remember a username and password, just a pin and authenticate from the frontend with it. For django admin you use username and password to authenticate.
This Pin field should only be changed and set in Django Admin, there is no view for that. Only views for accepting the pin and checking if its valid.
So in comparison Im only interested in setting and changing this pin field in django admin. Its also all working without hashing, but its for authentication purposes, so I thought it would be nice to hash it.

Was it understandable? :slight_smile:

Thanks for your help!

Actually they are, because:

is not an accurate statement. The admin is just another Django app. Structurally, it’s the same as any other Django app. That means that there are views for everything within the admin, and they’re highly customizable.

See The Django admin site | Django documentation | Django for any of the variety of ways that you could plug this type of functionality into your ModelAdmin class or even the ModelForm used by that class.

You might even want to take a close look at the User creation and edit views within the admin. That’s an example of a situation where an unhashed field (password) is entered into the form, but hashed and stored in the database upon submission.

All of that information helps me a lot, thank you!

I can’t find that one. Do you have a link for that? Because that sounds very helpful, it looks pretty complicated to me to build that on my own, or at least its taking a lot of time reading the docs and implementing what I need.

It’s in django.contrib.auth.admin.UserAdmin and django.contrib.auth.forms.UserCreationForm. They’re not going to be something that you can directly implement and use - I’m pointing them out as an example of changing data submitted in a form to a different value to be stored in the model.

Ah thank you very much.
The django.contrib.auth.forms.SetPasswordForm looks pretty good to me, because I just want the functionality to set it without remembering the old PIN. I could build a similar form myself.
Now I would need a new Site in Django Admin with that Form displayed, so I can use it. I can try a lot of ways how to do it - but could you explain it real short so I dont need to try everything? I would’ve started with adding a new AdminSite class and somehow get a url for that and register the form… Or is that the wrong way?

Why a new “Site”? Is there a specific reason why you think that would be necessary? You can just create a new ModelAdmin class for your model. See the full example for some ideas.

I thought about the same way the ChangePasswordForm works… On the edit page of the user, there is this hash field for the password. In the tooltip you got the link to another page where you can change the password. And on that other page you have only “Password”, “Password Repeat” and submit to change the password. I wanted to have something like that - so I thought I somehow need to add that “SetPin”-Page that I can link to…
Was that understandable?

Ok, that’s all fine - but there’s no reason to create a new site for that. All you’re trying to do is create a new model admin page for your model.

Looks like I do not get it…
Ive got a MyUserModel inheriting from AbstractBaseUser, to customize it with my PIN and some other stuff.
My admin.py

from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.contrib.auth.forms import UsernameField

from .models import MyUserModel


class UserCreationForm(forms.ModelForm):
    """A form for creating new users. Includes all the required
    fields, plus a repeated password."""
    password1 = forms.CharField(label='Password', widget=forms.PasswordInput)
    password2 = forms.CharField(
        label='Password confirmation', widget=forms.PasswordInput)

    class Meta:
        model = MyUserModel
        fields = ()

    def clean_password2(self):
        # Check that the two password entries match
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")
        if password1 and password2 and password1 != password2:
            raise forms.ValidationError("Passwords don't match")
        return password2

    def save(self, commit=True):
        # Save the provided password in hashed format
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user


class UserChangeForm(BaseUserChangeForm):
    """A form for updating users. Includes all the fields on
    the user, but replaces the password field with admin's
    disabled password hash display field.
    """
    username = UsernameField()


class UserAdmin(BaseUserAdmin):
    # The forms to add and change user instances
    form = UserChangeForm
    add_form = UserCreationForm

    readonly_fields = ('id',)
    list_display = ('username', 'full_name',
                    'is_active', 'is_staff', 'is_superuser')
    list_filter = ('is_active', 'is_staff', 'is_superuser',)
    fieldsets = (
        (None, {'fields': ('id',)}),
        ('Personal info', {
         'fields': ('first_name', 'last_name',)}),
        ('Auth', {'fields': ('username', 'password', 'is_active',)}),
        ('PIN', {
            'classes': ('collapse',),
            'fields': ('pin',)
        }),
        ('Permissions', {'classes': ('collapse',), 'fields': (
            'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
    )

    add_fieldsets = (
        ('User Authentication', {
            'fields': ('username', 'pin', 'password1', 'password2'),
        }),
        ('Personal Information', {
            'fields': ('first_name', 'last_name'),
        }),
        ('Permissions', {
            'classes': ('collapse',),
            'fields': ('groups', 'user_permissions'),
        }),
    )
    search_fields = ('username', 'first_name', 'last_name',)
    ordering = ('username',)
    filter_horizontal = ('groups', 'user_permissions',)


admin.site.register(MyUserModel, UserAdmin)

So there I create a model admin page for my Model, to create user and to edit user.
But now I want this Pin field hashed in this edit page and have a link redirecting to a page, where I can set the pin. But I cant register a second admin page for that same model.
Or what am I getting wrong? Im sorry for that…

Read the code in django.contrib.auth.UserAdmin. See how get_urls is used to add a new url, and how django.contrib.auth.UserChangeForm generates that url to the special password change page from the User edit page in the admin.

Thank you!
That is looking kinda complex for me, I’ll have a look into it and ask my questions later on :slight_smile:
Ill try to work my way through, starting from the get_urls() method, looking promising to me. Thanks for the advice!

You can always forget about trying to integrate this into the admin for now, create regular views as necessary and then enhance it later on.

I thank you so much for your help!
A lot of copy and paste with slight adjustments, but working for my case…
A little documentation for someone in the future how I did it:

In my admin.py I extended my model:

from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .admin_forms import AdminPinChangeForm, UserChangeForm, UserCreationForm

class UserAdmin(BaseUserAdmin):
    ...

    form = UserChangeForm
    add_form = UserCreationForm
    change_pin_form = AdminPinChangeForm

    def get_urls(self):
        return [
            path(
                "<id>/pin/",
                self.admin_site.admin_view(self.user_change_pin),
                name="auth_user_pin_change",
            ),
        ] + super().get_urls()

    @sensitive_post_parameters_m
    def user_change_pin(self, request, id, form_url=""):
        user = self.get_object(request, unquote(id))
        if not self.has_change_permission(request, user):
            raise PermissionDenied
        if user is None:
            raise Http404(
                _("%(name)s object with primary key %(key)r does not exist.")
                % {
                    "name": self.model._meta.verbose_name,
                    "key": escape(id),
                }
            )
        if request.method == "POST":
            form = self.change_pin_form(user, request.POST)
            if form.is_valid():
                form.save()
                change_message = self.construct_change_message(
                    request, form, None)
                self.log_change(request, user, change_message)
                msg = gettext("Pin changed successfully.")
                messages.success(request, msg)
                update_session_auth_hash(request, form.user)
                return HttpResponseRedirect(
                    reverse(
                        "%s:%s_%s_change"
                        % (
                            self.admin_site.name,
                            user._meta.app_label,
                            user._meta.model_name,
                        ),
                        args=(user.pk,),
                    )
                )
        else:
            form = self.change_pin_form(user)

        fieldsets = [(None, {"fields": list(form.base_fields)})]
        adminForm = admin.helpers.AdminForm(form, fieldsets, {})

        context = {
            "title": _("Change pin: %s") % escape(user.get_username()),
            "adminForm": adminForm,
            "form_url": form_url,
            "form": form,
            "is_popup": (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET),
            "is_popup_var": IS_POPUP_VAR,
            "add": True,
            "change": False,
            "has_delete_permission": False,
            "has_change_permission": True,
            "has_absolute_url": False,
            "opts": self.model._meta,
            "original": user,
            "save_as": False,
            "show_save": True,
            **self.admin_site.each_context(request),
        }

        request.current_app = self.admin_site.name

        return TemplateResponse(
            request,
            get_template('change_pin.html'),
            context,
        )

Its basically all the password parts from the django.contrib.auth.admin.UserAdmin copied and edited for pin.

Then I needed a custom user create and user change form in the admin.py (I created an admin_forms.py and imported it from there):

from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.contrib.auth.forms import UserCreationForm as BaseUserCreateForm

class UserCreationForm(BaseUserCreateForm):
    pin = forms.CharField(
        label=_("Pin"),
        widget=forms.NumberInput(
            attrs={"autofocus": True, "min": 0}
        ),
        strip=False,
        validators=[MinLengthValidator(4)]
    )

    def clean_pin(self):
        pin = self.cleaned_data.get("pin")
        if not pin:
            raise ValidationError(
                self.error_messages["pin_not_set"],
                code="pin_not_set",
            )
        if not pin.isnumeric():
            raise ValidationError(
                self.error_messages["pin_not_numeric"],
                code="pin_not_numeric"
            )
        return pin

    def save(self, commit=True):
        # Save the provided password in hashed format
        user = super().save(commit=False)
        user.set_pin(self.cleaned_data["pin"])
        if commit:
            user.save()
        return user


class UserChangeForm(BaseUserChangeForm):
    """A form for updating users. Includes all the fields on
    the user, but replaces the password field with admin's
    disabled password hash display field.
    """
    pin = ReadOnlyPasswordHashField(
        label=_("Pin"),
        help_text=_(
            "Raw pins are not stored, so there is no way to see this "
            "user’s pin, but you can change the pin using "
            '<a href="{}">this form</a>.'
        ),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        pin = self.fields.get("pin")
        if pin:
            pin.help_text = pin.help_text.format("../pin/")
        user_permissions = self.fields.get("user_permissions")
        if user_permissions:
            user_permissions.queryset = user_permissions.queryset.select_related(
                "content_type"
            )

In the ModelAdmin I created that new Link, now I need to pass a Form there, for me it is the following (also created in admin_forms.py):

class AdminPinChangeForm(forms.Form):
    """
    A form used to change the pin of a user in the admin interface.
    """

    error_messages = {
        "pin_not_set": _("The Pin field must be set."),
        "pin_not_numeric": _("The Pin field must be numeric."),
    }
    required_css_class = "required"
    pin = forms.CharField(
        label=_("Pin"),
        widget=forms.NumberInput(
            attrs={"autofocus": True, "min": 0}
        ),
        strip=False,
        validators=[MinLengthValidator(4)]
    )

    def __init__(self, user, *args, **kwargs):
        self.user = user
        super().__init__(*args, **kwargs)

    def clean_pin(self):
        pin = self.cleaned_data.get("pin")
        if not pin:
            raise ValidationError(
                self.error_messages["pin_not_set"],
                code="pin_not_set",
            )
        if not pin.isnumeric():
            raise ValidationError(
                self.error_messages["pin_not_numeric"],
                code="pin_not_numeric"
            )
        return pin

    def save(self, commit=True):
        """Save the new pin."""
        pin = self.cleaned_data["pin"]
        self.user.set_pin(pin)
        if commit:
            self.user.save()
        return self.user

    @property
    def changed_data(self):
        data = super().changed_data
        for name in self.fields:
            if name not in data:
                return []
        return ["pin"]

Now I needed a custom template, so I copied the change_password.html to my change_pin.html inside my created templates folder.
change_pin.html:

{% extends "admin/base_site.html" %}
{% load i18n static %}
{% load admin_urls %}

{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}">{% endblock %}
{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</a>
&rsaquo; {% translate 'Change Pin' %}
</div>
{% endblock %}
{% endif %}
{% block content %}<div id="content-main">
<form{% if form_url %} action="{{ form_url }}"{% endif %} method="post" id="{{ opts.model_name }}_form">{% csrf_token %}{% block form_top %}{% endblock %}
<input type="text" name="username" value="{{ original.get_username }}" class="hidden">
<div>
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1">{% endif %}
{% if form.errors %}
    <p class="errornote">
    {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}
    </p>
{% endif %}

<p>{% blocktranslate with username=original %}Enter a new pin for the user <strong>{{ username }}</strong>.{% endblocktranslate %}</p>

<fieldset class="module aligned">
<div class="form-row">
  {{ form.pin.errors }}
  {{ form.pin.label_tag }} {{ form.pin }}
  {% if form.pin.help_text %}
  <div class="help">{{ form.pin.help_text|safe }}</div>
  {% endif %}
</div>
</fieldset>

<div class="submit-row">
<input type="submit" value="{% translate 'Set pin' %}" class="default">
</div>

</div>
</form></div>
{% endblock %}

What I forgot to mention are the basics - I added a function set_pin() to my Model MyUserModel:

class MyUserModel(AbstractBaseUser, PermissionsMixin):
    ...
    objects = MyUserManager()
    ...
    def set_pin(self, raw_pin):
        self.pin = make_password(raw_pin)

and I also want to hash the field when adding that user from the terminal, so I edit my ModelManager MyUserManager:

class MyUserManager(BaseUserManager):
    use_in_migrations: bool = True

    def _create_user(self, username, password, pin, first_name, is_active, is_staff, is_superuser, **extra_fields):
        user = self.model(
            username=username,
            first_name=first_name,
            is_staff=is_staff,
            is_active=is_active,
            is_superuser=is_superuser,
        )
        user.set_password(password)
        user.set_pin(pin)
        user.save(using=self._db)
        return user

    def create_superuser(self, username, password, pin, first_name):
        return self._create_user(username, password, pin, first_name, True, True, True)

    def create_user(self, username, password, pin, first_name):
        return self._create_user(username, password, pin, first_name, True, False, False)

and thats basically it.
Thank you very much for your help @KenWhitesell ! Im so happy to have it now working the way I wanted it!