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 Account
object. 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 Account
object. 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!