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`