All Post's

Using py_webauthn with Django

py_webauthn is the Python3 implementation of the WebAuthn API used for all FIDO2-compliant authenticators

Install py_webauthn

Installing py_webauthn is straight forward pip install webauthn but using it with Django requires some understanding and knowledge. So lets jump right into the fun!

Register a new key

Create some Models to handle the data


from django.contrib.auth import get_user_model
from django.db import models



class WebauthnRegistration(models.Model):
    user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE)
    challenge = models.CharField(max_length=9000, blank=True, null=True)


class WebauthnAuthentication(models.Model):
    user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE)
    challenge = models.CharField(max_length=9000, blank=True, null=True)


class WebauthnCredentials(models.Model):
    user = models.ForeignKey(
        get_user_model(), on_delete=models.CASCADE, related_name="webauthn"
    )
    name = models.CharField(
        max_length=100,
        verbose_name="Name",
        blank=True,
        null=True,
    )
    credential_public_key = models.CharField(max_length=9000, blank=True, null=True)
    credential_id = models.CharField(max_length=9000, blank=True, null=True)
    current_sign_count = models.IntegerField(default=0)

    def __str__(self):
        return self.name

The django view that handles the registration

To register a new key you generate the registration options using generate_registration_options from py_webauthn

from webauthn import (
    generate_registration_options,
    options_to_json,
    verify_registration_response,
    base64url_to_bytes,

)
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from webauthn.helpers.structs import (
    RegistrationCredential,
)

def webauthn_register(request):
    if request.method == "GET":
        public_credential_creation_options = generate_registration_options(
            rp_id=request.get_host(), # On dev make sure that you have the ports here
            rp_name="Your Awesome name",
            user_id=str(request.user.pk),  # Must be a string
            user_name=request.user.username,
        )

        webauthn_registration, _ = WebauthnRegistration.objects.get_or_create(
            user=request.user
        )

        #The options are encoded in bytes - to save them correctly
        # I use the json strings
        options_dict = json.loads(options_to_json(public_credential_creation_options))
        webauthn_registration.challenge = options_dict["challenge"]
        webauthn_registration.save()

        return render(
            request,
            "mfa/register_token.html",
            context={
                "public_credential_creation_options": options_to_json(
                    public_credential_creation_options
                )
            },
        )
    if request.method == "POST":
        webauthn_registration = WebauthnRegistration.objects.get(user=request.user)

        registration_credentials = RegistrationCredential.parse_raw(request.body)

        try:
            authentication_verification = verify_registration_response(
                credential=registration_credentials,
                expected_challenge=base64url_to_bytes(webauthn_registration.challenge),
                expected_origin=f"https://{request.get_host()}", # dont forget the ports
                expected_rp_id=request.get_host(),
            )

            auth_json = json.loads(authentication_verification.json())
            WebauthnCredentials.objects.create(
                user=request.user,
                credential_public_key=auth_json.get("credential_public_key"),
                credential_id=auth_json.get("credential_id"),
            )

            return HttpResponse(status=201)
        except InvalidRegistrationResponse as error:
            messages.error(
                request,
                f"Something went wrong: {error}",
            )
            return redirect("mfa:register")

The template that handles the registration

Normally you would have to deal with ByteArrays and Json and the conversion of it, but Matthew Miller created https://simplewebauthn.dev/. I wished that I had known this earlier - it deals with all the headache and reduces it to a few lines of JS:


{% extends 'base.html' %}
{% load static %}
{% block title %}Webauthn register{% endblock %}
{% block page-title %}
 Register
{% endblock %}
{% block content %}
    <div class="card">
        <div class="card-body">

                <p>register:</p>
            <div class="row">
                <div class="col">
                <button class="offset-4 center btn btn-success" id="btnBegin">register</button>
                </div>
            </div>
        <div id="success"></div>

            <div class="mt-3 text-danger" id="error"></div>
        </div>
    </div>

{% endblock %}

{% block js %}
    {% autoescape off %}
    <script src="{% static 'js/simplewebauthn_browser.js' %}"></script>
    <script>
  const { startRegistration } = SimpleWebAuthnBrowser;

  // <button>
  const elemBegin = document.getElementById('btnBegin');
  // <span>/<p>/etc...
  const elemSuccess = document.getElementById('success');
  // <span>/<p>/etc...
  const elemError = document.getElementById('error');

  // Start registration when the user clicks a button
  elemBegin.addEventListener('click', async () => {
    // Reset success/error messages


    // GET registration options from the endpoint that calls
    // @simplewebauthn/server -> generateRegistrationOptions()
    const resp = {{public_credential_creation_options|safe }};

    let attResp;
    try {
      // Pass the options to the authenticator and wait for a response
      attResp = await startRegistration(resp);
    } catch (error) {
      // Some basic error handling
      if (error.name === 'InvalidStateError') {
        elemError.innerText = 'Error';
      } else {
        elemError.innerText ='Error: '+ error;
      }

      throw error;
    }

    // POST the response to the endpoint that calls
    // @simplewebauthn/server -> verifyRegistrationResponse()


    const verificationResp = await fetch('{{ request.path }}', {
      method: 'POST',
      headers: {
          'X-CSRFToken': '{{ csrf_token }}',
        'Content-Type': 'application/json',
      },
      body:  JSON.stringify(attResp),
    });

    // Wait for the results of verification
    const verificationJSON = await verificationResp;

    // Show UI appropriate for the `verified` status
    if (verificationJSON.status === 201) {
      window.location.replace("/auth/list")
    } else {
      location.reload()
    }
  });
</script>
    {% endautoescape %}
{% endblock %}

This is mostly taken from the example here: https://simplewebauthn.dev/docs/packages/browser

Editing your login function

Now that you have the key registered your login view has to be updated to know about the new method:

def login_view(request):
    if request.method == "GET":
        return render(request, "registration/login.html")
    if request.method == "POST":
        username = request.POST["username"]
        password = request.POST["password"]
        user = authenticate(username=username, password=password)
        if user is not None:
            # Check if the user uses webauthn
            if user.webauthn.all().count() > 0:
                request.session["id"] = user.id
                return redirect("webauthn", next=request.GET.get("next"))
            else:
                login(request, user)
                return redirect(request.GET.get("next"))
        else:
            messages.error(request, message="Login failed")
            return render(request, "registration/login.html")

If the user uses a FIDO key they are redirected to:

def webauthn_login(request, next=None):
    id = request.session.get("id")
    user = User.objects.get(pk=id)
    authenticators = user.webauthn.all()
    if request.method == "GET":
        webauthn_authentication, _ = WebauthnAuthentication.objects.get_or_create(
            user_id=id
        )

        allowed_credentials = [
            PublicKeyCredentialDescriptor(
                id=base64url_to_bytes(credentials.credential_id)
            )
            for credentials in authenticators
        ]
        authentication_options = generate_authentication_options(
            rp_id=request.get_host(),
            allow_credentials=allowed_credentials,
        )
        json_option = json.loads(options_to_json(authentication_options))
        webauthn_authentication.challenge = json_option.get("challenge")
        webauthn_authentication.save()

        return render(
            request,
            "registration/webauthn_login.html",
            context={
                "authentication_options": options_to_json(authentication_options),
                "next": next,
            },
        )
    if request.method == "POST":
        webauthn_authentication = WebauthnAuthentication.objects.get(user=user)
        authentication_credential = AuthenticationCredential.parse_raw(request.body)
        webauthn_credentials = WebauthnCredentials.objects.get(
            credential_id=authentication_credential.id
        )
        if webauthn_credentials.user != user:
            del request.session['id']
            return HttpResponse(status=403)

        authentication_verification = verify_authentication_response(
            credential=authentication_credential,
            expected_challenge=base64url_to_bytes(webauthn_authentication.challenge),
            expected_rp_id=request.get_host(),
            expected_origin=f"https://{request.get_host()}",
            credential_public_key=base64url_to_bytes(
                webauthn_credentials.credential_public_key
            ),
            credential_current_sign_count=webauthn_credentials.current_sign_count,
        )
        webauthn_credentials.current_sign_count = (
            authentication_verification.new_sign_count
        )
        del request.session['id']
        login(request, user)
        return HttpResponse(status=200)

The template looks like this, again it is mostly the example used here: https://simplewebauthn.dev/docs/packages/browser

{% load static %}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title> Log in</title>
  <link rel="shortcut icon" type="image/png" href="{% static 'img/logo/favicon.png' %}"/>
  <!-- Tell the browser to be responsive to screen width -->
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- Font Awesome Icons -->
  <link rel="stylesheet" href="{% static 'plugins/fontawesome-free/css/all.min.css' %}">
  <!-- Theme style -->
  <link rel="stylesheet" href="{% static 'css/adminlte.min.css' %}">
</head>
<body class="hold-transition login-page">
<div class="login-box">
  <div class="login-logo">
    <img src="{% static '/img/logo/logo.png' %}">
  </div>

  <!-- /.login-logo -->
  <div class="card">
    <div class="card-body login-card-body">
      <p class="login-box-msg">Please use you authentication token to log in</p>
        {% include 'messages.html' %}
        <div class="row">
            <div class="offset-2 col">
                <button class="btn-primary btn" id="btnBegin">Start authentication</button>
            </div>
        </div>

      </form>
    </div>
    <!-- /.login-card-body -->
  </div>
</div>
<!-- /.login-box -->

<!-- jQuery -->
<script src="{% static 'plugins/jquery/jquery.min.js'%}"></script>
<!-- Bootstrap 4 -->
<script src="{% static 'plugins/bootstrap/js/bootstrap.bundle.min.js'%}"></script>
<!-- AdminLTE App -->
<script src="{% static 'js/adminlte.min.js' %}"></script>


    <script src="{% static 'js/simplewebauthn_browser.js' %}"></script>
    <script>
  const { startAuthentication } = SimpleWebAuthnBrowser;

  // <button>
  const elemBegin = document.getElementById('btnBegin');


  // Start authentication when the user clicks a button
  elemBegin.addEventListener('click', async () => {
    // Reset success/error messages


    // GET authentication options from the endpoint that calls
    // @simplewebauthn/server -> generateAuthenticationOptions()
    const resp = {{ authentication_options|safe }};

    let asseResp;
    try {
      // Pass the options to the authenticator and wait for a response
      asseResp = await startAuthentication(resp);
    } catch (error) {
      // Some basic error handling
      throw error;
    }
    console.log(asseResp)
    // POST the response to the endpoint that calls
    // @simplewebauthn/server -> verifyAuthenticationResponse()
    const verificationResp = await fetch('{{ request.path }}', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': '{{ csrf_token }}',
      },
      body: JSON.stringify(asseResp),
    });
    if (verificationResp.status === 200) {
        window.location.replace("{{ next }}")
    }
  });
</script>


</body>
</html>

Conclusion

And HOTDOG! you are logged in! This sure was a lot of work to figure out. I guess there is a lot of space for improvement, but wow this was exhausting!

If you have suggestions or spottet an error send me an email via bithive@bithive.space`