Code Review: Token authentication by querying Django users by password

I have a Django project that I want to extend with an API which meets the following requirements:

  • The API requires authentication via Bearer token.
  • The superuser should be able to create tokens.
  • Token should be created by the click of a button.
  • They must be system-generated.
  • And they should be displayed to the superuser only once.

I want to ask for feedback about my implementation, because I’m doing some shenanigans with authentication and password storing. Specifically adding the password as a one-time attribute to the user object, and hashing the password with a static (non-random) salt. I believe this approach is good-enough for its use-case (without changing the use-case), but I’d like to be sure I’m not making it more complex than it needs to be, or missing an important bit.

The way I implemented this is a follows.

I created a new model Account that has a 1:1 relation to a User.

# api/models.py
from django.contrib.auth.models import User

class Account(models.Model):
    user = OneToOneField(User, on_delete=models.CASCADE)
    ...

If the superuser creates a new token via the click of a button, behind the scenes it actually creates a new Account through a form.

# api/forms.py
from api.models import Account

class AccountForm(ModelForm):
    ...

    class Meta:
        model = Account

To display the token to the user, I’m actually doing some shenanigs on the model, that I did not show you earlier. During the Account(...).save() phase I am checking the internal state if the new Account objects is being added and that it does not exist in the database (if self._state.adding and not self._state.db). If this is the case, I am generating a random password get_random_string(32) and assigning it to a new property self.secret. This property only exists temporarily. Then I am creating a new User object and set its password to self.secret. Furthermore, I am salting the password with a static string that must stay the same for every User object that is created through an Accountobject. I’m explaining this a little later. For now, this is the extended models.py:

# api/models.py
from django.contrib.auth.models import User

class Account(models.Model):
    user = OneToOneField(User, on_delete=models.CASCADE)
    
    def save(self, *args, **kwargs):
        # TODO prevent updating secret_partial
        if self._state.adding and not self._state.db:
            self.secret = get_random_string(32)
            user = User(username=uuid.uuid4())
            user.password = make_password(self.secret, salt="<REDACTED>")
            user.save()
            self.secret_partial = self.secret[-3:]
            self.user = user
        super().save(*args, **kwargs)

And below is the view which puts it all together. After checking that the form was submitted and valid, I am saving the form, which returns the Account object with the temporary create property (account = form.save()). I then render the response including the randomly created secret. In no other cases the secret exposed to the Accountobject. The user now must copy and store the secret before he leaves the current page.

# api/views.py
from api.forms import AccountForm
from api.models import Account

def account_create(request):
    form = AccountForm(request.POST or None)

    if request.method == "POST" and form.is_valid():       
        account = form.save()        
        return render(
            request,
            "api/partials/account_create_response.html",
            { "secret": account.secret },
        )

    ...

Back to the salt. Why did I chose a static salt, instead of a randomly created one? The reason is the Bearer token authentication. Bearer authentication does not require nor support user ids. Therefore, if a client authenticates with the token only, then I need to be able to find a user with that password, or fail the authentication. And I believe that in order to do that, not only the password, but also the salt must be known so I can generate the exact same hash to run the query.

# api/api.py
from ninja.security import HttpBearer
from django.contrib.auth.hashers import make_password
from .models import Account

class GlobalAuth(HttpBearer):
    def authenticate(self, request, token):
        secret = base64.b64decode(token).decode('utf-8')
        hashed_password = make_password(secret, salt="<REDACTED>")
        try:
            account = Account.objects.get(user__password=hashed_password)
            return account.pk
        except Account.DoesNotExist:
            return

api = NinjaAPI(auth=GlobalAuth())

Thank you!