From 0218f1bada79b96767eebb4e917ec9439560daef Mon Sep 17 00:00:00 2001
From: Valentin Deniaud <vdeniaud@entrouvert.com>
Date: Fri, 5 Jul 2019 17:15:08 +0200
Subject: [PATCH] add multifactor module auth_webauthn

This new authentication factor allows to use hardware tokens, via the
WebAuthn W3C specification.
---
 MANIFEST.in                                   |   2 +
 setup.py                                      |   2 +
 .../auth_webauthn/__init__.py                 |  15 +
 .../auth_webauthn/app_settings.py             |  24 ++
 .../auth2_multifactor/auth_webauthn/apps.py   |   8 +
 .../auth_webauthn/authenticators.py           |  26 ++
 .../auth_webauthn/decorators.py               |  13 +
 .../auth2_multifactor/auth_webauthn/forms.py  |   9 +
 .../auth_webauthn/migrations/0001_initial.py  |  35 ++
 .../auth_webauthn/migrations/__init__.py      |   0
 .../auth2_multifactor/auth_webauthn/models.py |  32 ++
 .../static/auth_webauthn/js/base64js.min.js   |   1 +
 .../static/auth_webauthn/js/webauthn.js       | 305 ++++++++++++++++++
 .../templates/auth_webauthn/login.html        |  12 +
 .../templates/auth_webauthn/profile.html      |  31 ++
 .../templates/auth_webauthn/scripts.html      |   7 +
 .../webauthncredential_confirm_delete.html    |  14 +
 .../auth2_multifactor/auth_webauthn/urls.py   |  37 +++
 .../auth2_multifactor/auth_webauthn/utils.py  |  37 +++
 .../auth2_multifactor/auth_webauthn/views.py  | 187 +++++++++++
 .../auth2_multifactor/decorators.py           |  13 +-
 21 files changed, 809 insertions(+), 1 deletion(-)
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/__init__.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/app_settings.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/apps.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/authenticators.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/decorators.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/forms.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/migrations/0001_initial.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/migrations/__init__.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/models.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/base64js.min.js
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/webauthn.js
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/login.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/profile.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/scripts.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/webauthncredential_confirm_delete.html
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/urls.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/utils.py
 create mode 100644 src/authentic2/auth2_multifactor/auth_webauthn/views.py

diff --git a/MANIFEST.in b/MANIFEST.in
index c0fa1026..d57d9234 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -10,6 +10,7 @@ include tox.ini .coveragerc
 recursive-include src/authentic2/static *.css *.js *.ico *.gif *.png *.jpg
 recursive-include src/authentic2/manager/static *.css *.js *.png
 recursive-include src/authentic2_auth_fc/static/authentic2_auth_fc *.css *.js *.png *.svg
+recursive-include src/authentic2/auth2_multifactor/static/auth_webauthn/ *.js
 
 # templates
 recursive-include src/authentic2/templates *.html *.txt *.xml
@@ -26,6 +27,7 @@ recursive-include src/authentic2_auth_saml/templates/authentic2_auth_saml *.html
 recursive-include src/authentic2_auth_oidc/templates/authentic2_auth_oidc *.html
 recursive-include src/authentic2_idp_oidc/templates/authentic2_idp_oidc *.html
 recursive-include src/authentic2/auth2_multifactor/auth_oath/templates *.html
+recursive-include src/authentic2/auth2_multifactor/auth_webauthn/templates *.html
 
 recursive-include src/authentic2/saml/fixtures *.json
 recursive-include src/authentic2/locale *.po *.mo
diff --git a/setup.py b/setup.py
index e4c3b365..ab1455a7 100755
--- a/setup.py
+++ b/setup.py
@@ -142,6 +142,7 @@ setup(name="authentic2",
           'tablib',
           'qrcode',
           'oath',
+          'webauthn',
       ],
       zip_safe=False,
       classifiers=[
@@ -177,5 +178,6 @@ setup(name="authentic2",
               'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin',
               'authentic2-auth-fc = authentic2_auth_fc:Plugin',
               'authentic2-auth-oath = authentic2.auth2_multifactor.auth_oath:Plugin',
+              'authentic2-auth-webauthn = authentic2.auth2_multifactor.auth_webauthn:Plugin',
           ],
       })
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/__init__.py b/src/authentic2/auth2_multifactor/auth_webauthn/__init__.py
new file mode 100644
index 00000000..7c389328
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/__init__.py
@@ -0,0 +1,15 @@
+class Plugin(object):
+    def get_before_urls(self):
+        from . import app_settings
+        from django.conf.urls import include, url
+        from authentic2.decorators import setting_enabled, required
+
+        return required(
+            setting_enabled('ENABLE', settings=app_settings),
+            [url(r'^accounts/authenticators/webauthn/', include(__name__ + '.urls'))])
+
+    def get_apps(self):
+        return [__name__]
+
+    def get_authenticators(self):
+        return ['authentic2.auth2_multifactor.auth_webauthn.authenticators.WebAuthnAuthenticator']
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/app_settings.py b/src/authentic2/auth2_multifactor/auth_webauthn/app_settings.py
new file mode 100644
index 00000000..b86307be
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/app_settings.py
@@ -0,0 +1,24 @@
+class AppSettings(object):
+    __DEFAULTS = {
+        'ENABLE': True,
+        'LEVEL': 3,
+        'RP_NAME': 'Publik',
+    }
+
+    def __init__(self, prefix):
+        self.prefix = prefix
+
+    def _setting(self, name, dflt):
+        from django.conf import settings
+        return getattr(settings, self.prefix + name, dflt)
+
+    def __getattr__(self, name):
+        if name not in self.__DEFAULTS:
+            raise AttributeError(name)
+        return self._setting(name, self.__DEFAULTS[name])
+
+
+import sys
+app_settings = AppSettings('A2_AUTH_WEBAUTHN_')
+app_settings.__name__ = __name__
+sys.modules[__name__] = app_settings
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/apps.py b/src/authentic2/auth2_multifactor/auth_webauthn/apps.py
new file mode 100644
index 00000000..ff3f152b
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/apps.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+
+
+class AuthWebauthnConfig(AppConfig):
+    name = 'auth_webauthn'
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/authenticators.py b/src/authentic2/auth2_multifactor/auth_webauthn/authenticators.py
new file mode 100644
index 00000000..33bb7b08
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/authenticators.py
@@ -0,0 +1,26 @@
+from django.utils.translation import ugettext_lazy
+
+from . import app_settings
+
+
+class WebAuthnAuthenticator(object):
+    submit_name = 'webauthn-submit'
+    auth_level = app_settings.LEVEL
+    _id = 'multifactor-webauthn'
+
+    def enabled(self):
+        return app_settings.ENABLE
+
+    def name(self):
+        return ugettext_lazy('Hardware token')
+
+    def id(self):
+        return self._id
+
+    def login(self, request, *args, **kwargs):
+        from .views import webauthn_login
+        return webauthn_login(request, *args, **kwargs)
+
+    def profile(self, request, *args, **kwargs):
+        from .views import webauthn_profile
+        return webauthn_profile(request, *args, **kwargs)
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/decorators.py b/src/authentic2/auth2_multifactor/auth_webauthn/decorators.py
new file mode 100644
index 00000000..da9841e2
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/decorators.py
@@ -0,0 +1,13 @@
+from functools import wraps
+
+from django.http import HttpResponseForbidden
+
+
+def secure_required(func):
+    @wraps(func)
+    def inner(request, *args, **kwargs):
+        if not request.is_secure():
+            return HttpResponseForbidden('WebAuthn requires https',
+                                         content_type='text/plain')
+        return func(request, *args, **kwargs)
+    return inner
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/forms.py b/src/authentic2/auth2_multifactor/auth_webauthn/forms.py
new file mode 100644
index 00000000..c5659ea2
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/forms.py
@@ -0,0 +1,9 @@
+from django import forms
+
+from .models import WebAuthnCredential
+
+
+class CredentialForm(forms.ModelForm):
+    class Meta:
+        model = WebAuthnCredential
+        fields = ['name']
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/migrations/0001_initial.py b/src/authentic2/auth2_multifactor/auth_webauthn/migrations/0001_initial.py
new file mode 100644
index 00000000..137c5b55
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/migrations/0001_initial.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-07-05 15:12
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='WebAuthnCredential',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=30, verbose_name='Authenticator name')),
+                ('date', models.DateField(auto_now_add=True)),
+                ('credential_id', models.CharField(max_length=256)),
+                ('public_key', models.CharField(max_length=1000)),
+                ('signature_count', models.PositiveIntegerField()),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webauthn_credential', to=settings.AUTH_USER_MODEL, verbose_name='utilisateur')),
+            ],
+        ),
+        migrations.AlterUniqueTogether(
+            name='webauthncredential',
+            unique_together=set([('user', 'name'), ('user', 'credential_id')]),
+        ),
+    ]
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/migrations/__init__.py b/src/authentic2/auth2_multifactor/auth_webauthn/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/models.py b/src/authentic2/auth2_multifactor/auth_webauthn/models.py
new file mode 100644
index 00000000..1ba249f8
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/models.py
@@ -0,0 +1,32 @@
+from django.conf import settings
+from django.db import models
+from django.utils.translation import ugettext as _
+
+
+class WebAuthnCredential(models.Model):
+
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='webauthn_credential',
+                             verbose_name=_('user'))
+    name = models.CharField(max_length=30, verbose_name=_('Authenticator name'))
+    date = models.DateField(auto_now_add=True)
+    credential_id = models.CharField(max_length=256)
+    public_key = models.CharField(max_length=1000)
+    signature_count = models.PositiveIntegerField()
+
+    def validate_unique(self, exclude=None):
+        # Allow validating (name, user) uniqueness at form submission
+        if getattr(self, 'user', None) and 'user' in exclude:
+            exclude.remove('user')
+        super(WebAuthnCredential, self).validate_unique(exclude=exclude)
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        unique_together = [
+            ['user', 'name'],
+            # spec says credential ids should be unique accross all users,
+            # but this seems only relevant if webauthn is used as a primary
+            # authentication factor, something we don't allow.
+            ['user', 'credential_id']
+        ]
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/base64js.min.js b/src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/base64js.min.js
new file mode 100644
index 00000000..0c621d35
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/base64js.min.js
@@ -0,0 +1 @@
+(function(r){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=r()}else if(typeof define==="function"&&define.amd){define([],r)}else{var e;if(typeof window!=="undefined"){e=window}else if(typeof global!=="undefined"){e=global}else if(typeof self!=="undefined"){e=self}else{e=this}e.base64js=r()}})(function(){var r,e,n;return function(){function r(e,n,t){function o(f,i){if(!n[f]){if(!e[f]){var u="function"==typeof require&&require;if(!i&&u)return u(f,!0);if(a)return a(f,!0);var v=new Error("Cannot find module '"+f+"'");throw v.code="MODULE_NOT_FOUND",v}var d=n[f]={exports:{}};e[f][0].call(d.exports,function(r){var n=e[f][1][r];return o(n||r)},d,d.exports,r,e,n,t)}return n[f].exports}for(var a="function"==typeof require&&require,f=0;f<t.length;f++)o(t[f]);return o}return r}()({"/":[function(r,e,n){"use strict";n.byteLength=d;n.toByteArray=h;n.fromByteArray=p;var t=[];var o=[];var a=typeof Uint8Array!=="undefined"?Uint8Array:Array;var f="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(var i=0,u=f.length;i<u;++i){t[i]=f[i];o[f.charCodeAt(i)]=i}o["-".charCodeAt(0)]=62;o["_".charCodeAt(0)]=63;function v(r){var e=r.length;if(e%4>0){throw new Error("Invalid string. Length must be a multiple of 4")}var n=r.indexOf("=");if(n===-1)n=e;var t=n===e?0:4-n%4;return[n,t]}function d(r){var e=v(r);var n=e[0];var t=e[1];return(n+t)*3/4-t}function c(r,e,n){return(e+n)*3/4-n}function h(r){var e;var n=v(r);var t=n[0];var f=n[1];var i=new a(c(r,t,f));var u=0;var d=f>0?t-4:t;for(var h=0;h<d;h+=4){e=o[r.charCodeAt(h)]<<18|o[r.charCodeAt(h+1)]<<12|o[r.charCodeAt(h+2)]<<6|o[r.charCodeAt(h+3)];i[u++]=e>>16&255;i[u++]=e>>8&255;i[u++]=e&255}if(f===2){e=o[r.charCodeAt(h)]<<2|o[r.charCodeAt(h+1)]>>4;i[u++]=e&255}if(f===1){e=o[r.charCodeAt(h)]<<10|o[r.charCodeAt(h+1)]<<4|o[r.charCodeAt(h+2)]>>2;i[u++]=e>>8&255;i[u++]=e&255}return i}function s(r){return t[r>>18&63]+t[r>>12&63]+t[r>>6&63]+t[r&63]}function l(r,e,n){var t;var o=[];for(var a=e;a<n;a+=3){t=(r[a]<<16&16711680)+(r[a+1]<<8&65280)+(r[a+2]&255);o.push(s(t))}return o.join("")}function p(r){var e;var n=r.length;var o=n%3;var a=[];var f=16383;for(var i=0,u=n-o;i<u;i+=f){a.push(l(r,i,i+f>u?u:i+f))}if(o===1){e=r[n-1];a.push(t[e>>2]+t[e<<4&63]+"==")}else if(o===2){e=(r[n-2]<<8)+r[n-1];a.push(t[e>>10]+t[e>>4&63]+t[e<<2&63]+"=")}return a.join("")}},{}]},{},[])("/")});
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/webauthn.js b/src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/webauthn.js
new file mode 100644
index 00000000..c9fd6899
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/webauthn.js
@@ -0,0 +1,305 @@
+// Adapted from https://github.com/duo-labs/py_webauthn/blob/master/flask_demo/static/js/webauthn.js
+// as we already depend on py_webauthn
+
+function b64enc(buf) {
+    return base64js.fromByteArray(buf)
+                   .replace(/\+/g, "-")
+                   .replace(/\//g, "_")
+                   .replace(/=/g, "");
+}
+
+function b64RawEnc(buf) {
+    return base64js.fromByteArray(buf)
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_");
+}
+
+function hexEncode(buf) {
+    return Array.from(buf)
+                .map(function(x) {
+                    return ("0" + x.toString(16)).substr(-2);
+				})
+                .join("");
+}
+
+function getFormData(form) {
+  if(form != null)
+    form_data = new FormData(form);
+  else
+    form_data = new FormData();
+  form_data.append('csrfmiddlewaretoken', window.csrf_token);
+  return form_data;
+}
+
+async function fetch_json(url, options) {
+    const response = await fetch(url, options);
+    const body = await response.json();
+    if (body.fail)
+        throw body.fail;
+    return body;
+}
+
+/**
+ * REGISTRATION FUNCTIONS
+ */
+
+/**
+ * Callback after the registration form is submitted.
+ * @param {Event} e
+ */
+const didClickRegister = async (e) => {
+    e.preventDefault();
+
+    // gather the data in the form
+    const form = document.querySelector('#register-form');
+
+    if(!form.reportValidity())
+      return false;
+
+    // post the data to the server to generate the PublicKeyCredentialCreateOptions
+    let credentialCreateOptionsFromServer;
+    try {
+        credentialCreateOptionsFromServer = await getCredentialOptionsFromServer(form);
+    } catch (err) {
+        return console.error("Failed to generate credential request options:", credentialCreateOptionsFromServer)
+    }
+
+    // convert certain members of the PublicKeyCredentialCreateOptions into
+    // byte arrays as expected by the spec.
+    const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(credentialCreateOptionsFromServer);
+
+    // request the authenticator(s) to create a new credential keypair.
+    let credential;
+    try {
+        credential = await navigator.credentials.create({
+            publicKey: publicKeyCredentialCreateOptions
+        });
+    } catch (err) {
+        return console.error("Error creating credential:", err);
+    }
+
+    // we now have a new credential! We now need to encode the byte arrays
+    // in the credential into strings, for posting to our server.
+    const newAssertionForServer = transformNewAssertionForServer(credential);
+
+    // post the transformed credential data to the server for validation
+    // and storing the public key
+    let assertionValidationResponse;
+    try {
+        assertionValidationResponse = await postNewAssertionToServer(newAssertionForServer);
+    } catch (err) {
+        return console.error("Server validation of credential failed:", err);
+    }
+
+    // reload the page after a successful result
+    let current_url = new URL(window.location);
+    let next = current_url.searchParams.get('next');
+    if(next)
+      window.location.replace(next);
+    else
+      window.location.reload();
+}
+
+
+const transformCredentialRequestOptions = (credentialRequestOptionsFromServer) => {
+    let {challenge, allowCredentials} = credentialRequestOptionsFromServer;
+
+    challenge = Uint8Array.from(
+        atob(challenge), c => c.charCodeAt(0));
+
+    allowCredentials = allowCredentials.map(credentialDescriptor => {
+        let {id} = credentialDescriptor;
+        id = id.replace(/\_/g, "/").replace(/\-/g, "+");
+        id = Uint8Array.from(atob(id), c => c.charCodeAt(0));
+        return Object.assign({}, credentialDescriptor, {id});
+    });
+
+    const transformedCredentialRequestOptions = Object.assign(
+        {},
+        credentialRequestOptionsFromServer,
+        {challenge, allowCredentials});
+
+    return transformedCredentialRequestOptions;
+};
+
+
+/**
+ * Get PublicKeyCredentialRequestOptions for this user from the server
+ * formData of the registration form
+ * @param {form} form
+ */
+const getCredentialOptionsFromServer = async (form) => {
+    return await fetch_json(
+        form.getAttribute('action'),
+        {
+            method: "POST",
+            body: getFormData(form)
+        }
+    );
+}
+
+/**
+ * Transforms items in the credentialCreateOptions generated on the server
+ * into byte arrays expected by the navigator.credentials.create() call
+ * @param {Object} credentialCreateOptionsFromServer
+ */
+const transformCredentialCreateOptions = (credentialCreateOptionsFromServer) => {
+    let {challenge, user} = credentialCreateOptionsFromServer;
+    user.id = Uint8Array.from(
+        atob(credentialCreateOptionsFromServer.user.id), c => c.charCodeAt(0));
+
+    challenge = Uint8Array.from(
+        atob(credentialCreateOptionsFromServer.challenge), c => c.charCodeAt(0));
+
+    const transformedCredentialCreateOptions = Object.assign(
+            {}, credentialCreateOptionsFromServer,
+            {challenge, user});
+
+    return transformedCredentialCreateOptions;
+}
+
+
+
+/**
+ * AUTHENTICATION FUNCTIONS
+ */
+
+
+/**
+ * Callback executed after submitting login form
+ * @param {Event} e
+ */
+const didClickLogin = async (e) => {
+    e.preventDefault();
+    // gather the data in the form
+    const form = document.querySelector('#login-form');
+
+    // post the login data to the server to retrieve the PublicKeyCredentialRequestOptions
+    let credentialCreateOptionsFromServer;
+    try {
+        credentialRequestOptionsFromServer = await getCredentialOptionsFromServer(form);
+    } catch (err) {
+        return console.error("Error when getting request options from server:", err);
+    }
+
+    // convert certain members of the PublicKeyCredentialRequestOptions into
+    // byte arrays as expected by the spec.
+    const transformedCredentialRequestOptions = transformCredentialRequestOptions(
+        credentialRequestOptionsFromServer);
+
+    // request the authenticator to create an assertion signature using the
+    // credential private key
+    let assertion;
+    try {
+        assertion = await navigator.credentials.get({
+            publicKey: transformedCredentialRequestOptions,
+        });
+    } catch (err) {
+        return console.error("Error when creating credential:", err);
+    }
+
+    // we now have an authentication assertion! encode the byte arrays contained
+    // in the assertion data as strings for posting to the server
+    const transformedAssertionForServer = transformAssertionForServer(assertion);
+
+    // post the assertion to the server for verification.
+    let response;
+    try {
+        response = await postAssertionToServer(transformedAssertionForServer);
+    } catch (err) {
+        return console.error("Error when validating assertion on server:", err);
+    }
+
+    let current_url = new URL(window.location);
+    window.location.replace(current_url.searchParams.get('next'));
+};
+
+/**
+ * Transforms the binary data in the credential into base64 strings
+ * for posting to the server.
+ * @param {PublicKeyCredential} newAssertion
+ */
+const transformNewAssertionForServer = (newAssertion) => {
+    const attObj = new Uint8Array(
+        newAssertion.response.attestationObject);
+    const clientDataJSON = new Uint8Array(
+        newAssertion.response.clientDataJSON);
+    const rawId = new Uint8Array(
+        newAssertion.rawId);
+
+    const registrationClientExtensions = newAssertion.getClientExtensionResults();
+
+    return {
+        id: newAssertion.id,
+        rawId: b64enc(rawId),
+        type: newAssertion.type,
+        attObj: b64enc(attObj),
+        clientData: b64enc(clientDataJSON),
+        registrationClientExtensions: JSON.stringify(registrationClientExtensions)
+    };
+}
+
+/**
+ * Posts the new credential data to the server for validation and storage.
+ * @param {Object} credentialDataForServer
+ */
+const postNewAssertionToServer = async (credentialDataForServer) => {
+    const formData = getFormData(null);
+    Object.entries(credentialDataForServer).forEach(([key, value]) => {
+        formData.set(key, value);
+    });
+
+    return await fetch_json(
+        document.getElementById('register-proceed-url').href, {
+        method: "POST",
+        body: formData
+    });
+}
+
+/**
+ * Encodes the binary data in the assertion into strings for posting to the server.
+ * @param {PublicKeyCredential} newAssertion
+ */
+const transformAssertionForServer = (newAssertion) => {
+    const authData = new Uint8Array(newAssertion.response.authenticatorData);
+    const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
+    const rawId = new Uint8Array(newAssertion.rawId);
+    const sig = new Uint8Array(newAssertion.response.signature);
+    const assertionClientExtensions = newAssertion.getClientExtensionResults();
+
+    return {
+        id: newAssertion.id,
+        rawId: b64enc(rawId),
+        type: newAssertion.type,
+        authData: b64RawEnc(authData),
+        clientData: b64RawEnc(clientDataJSON),
+        signature: hexEncode(sig),
+        assertionClientExtensions: JSON.stringify(assertionClientExtensions)
+    };
+};
+
+/**
+ * Post the assertion to the server for validation and logging the user in.
+ * @param {Object} assertionDataForServer
+ */
+const postAssertionToServer = async (assertionDataForServer) => {
+    const formData = getFormData(null);
+    Object.entries(assertionDataForServer).forEach(([key, value]) => {
+        formData.set(key, value);
+    });
+
+    return await fetch_json(
+        document.getElementById('auth-proceed-url').href, {
+        method: "POST",
+        body: formData
+    });
+}
+
+
+document.addEventListener("DOMContentLoaded", e => {
+    let qs;
+    if(qs = document.querySelector('#register'))
+        qs.addEventListener('click', didClickRegister);
+    if(qs = document.querySelector('#login'))
+        qs.addEventListener('click', didClickLogin);
+});
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/login.html b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/login.html
new file mode 100644
index 00000000..6f147cc3
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/login.html
@@ -0,0 +1,12 @@
+{% load i18n %}
+{% include "auth_webauthn/scripts.html" %}
+
+<a hidden id="auth-proceed-url" href="{% url 'authenticate-proceed' %}"></a>
+
+<p>
+{% trans "In order to continue, please use one of the hardware devices you configured, by pressing the button below." %}
+</p>
+
+<form id="login-form" name="login" action="{% url 'authenticate-begin' %}">
+  <button id="login" type="submit">Continue with WebAuthn</button>
+</form>
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/profile.html b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/profile.html
new file mode 100644
index 00000000..aa49d1ed
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/profile.html
@@ -0,0 +1,31 @@
+{% load i18n static %}
+{% include "auth_webauthn/scripts.html" %}
+
+{% if insufficient_level_url %}
+  <a href="{{ insufficient_level_url }}">
+    {% trans "Insufficient authentication level to view, click here to increase." %}
+  </a>
+{% else %}
+  <p>
+    {% trans "Add a hardware device in order to allow for stronger authentication." %}
+  </p>
+
+  <a hidden id="register-proceed-url" href="{% url 'register-proceed' %}"></a>
+  <form id="register-form" name="register" action="{% url 'register-begin' %}">
+    {{ form.as_p }}
+    <button id="register" type="submit">{% trans "Register new WebAuthn device" %}</button>
+  </form>
+
+  <h3>{% trans "Already registered credentials" %} :</h3>
+  <ul>
+    {% for credential in object_list %}
+      <li>
+        {{ credential.name }} -
+        {% trans "Added on" %} {{ credential.date }} -
+        <a href="{% url 'webauthn-delete' credential.pk %}">{% trans "Delete" %}</a>
+      </li>
+    {% empty %}
+      <li>{% trans "No credentials have been registered yet." %}</li>
+    {% endfor %}
+  </ul>
+{% endif %}
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/scripts.html b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/scripts.html
new file mode 100644
index 00000000..3aa6e7f3
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/scripts.html
@@ -0,0 +1,7 @@
+{% load static %}
+
+<script type="text/javascript" src="{% static "auth_webauthn/js/webauthn.js" %}"></script>
+<script type="text/javascript" src="{% static "auth_webauthn/js/base64js.min.js" %}"></script>
+<script>
+  window.csrf_token = '{{ csrf_token }}';
+</script>
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/webauthncredential_confirm_delete.html b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/webauthncredential_confirm_delete.html
new file mode 100644
index 00000000..f838bb49
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/webauthncredential_confirm_delete.html
@@ -0,0 +1,14 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+<form method="post">
+  {% csrf_token %}
+  <p>
+  {% blocktrans %}
+    Do you really want to remove credential &quot;{{ object }}&quot;&nbsp;?
+  {% endblocktrans %}
+  </p>
+  <input type="submit" value="Confirm">
+</form>
+{% endblock %}
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/urls.py b/src/authentic2/auth2_multifactor/auth_webauthn/urls.py
new file mode 100644
index 00000000..2943afe5
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/urls.py
@@ -0,0 +1,37 @@
+from django.conf.urls import url
+from django.contrib.auth.decorators import login_required
+from django.views.decorators.csrf import requires_csrf_token
+from django.views.decorators.http import require_POST
+
+from authentic2.auth2_multifactor.decorators import auth_level_required
+from authentic2.decorators import required
+
+from . import views
+from .authenticators import WebAuthnAuthenticator
+from .decorators import secure_required
+
+
+common_decorators = [login_required]
+webauth_api_decorators = [secure_required, require_POST, requires_csrf_token]
+restrict_auth_level = [auth_level_required(WebAuthnAuthenticator.auth_level,
+                                           only_if_enabled=WebAuthnAuthenticator._id)]
+
+urlpatterns = required(
+    common_decorators + webauth_api_decorators, [
+        url(r'^authenticate/begin/$', views.authenticate_begin, name='authenticate-begin'),
+        url(r'^authenticate/proceed/$', views.authenticate_proceed, name='authenticate-proceed'),
+    ]
+)
+
+urlpatterns += required(
+    common_decorators + webauth_api_decorators + restrict_auth_level, [
+        url(r'^register/begin/$', views.register_begin, name='register-begin'),
+        url(r'^register/proceed/$', views.register_proceed, name='register-proceed'),
+    ]
+)
+
+urlpatterns += required(
+    common_decorators + restrict_auth_level, [
+        url(r'^credential/delete/(?P<pk>\d+)/$', views.delete, name='webauthn-delete'),
+    ]
+)
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/utils.py b/src/authentic2/auth2_multifactor/auth_webauthn/utils.py
new file mode 100644
index 00000000..cd5eb4cf
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/utils.py
@@ -0,0 +1,37 @@
+from random import SystemRandom
+
+from webauthn import WebAuthnUser
+
+
+def generate_challenge():
+    return format(SystemRandom().getrandbits(256), '064x')
+
+
+def get_hostname(request):
+    host = request.get_host()
+    if ':' in host:
+        host = host.split(':')[0]
+    return host
+
+
+def get_origin(request):
+    return 'https://' + request.get_host()
+
+
+def get_webauthn_user_credentials(request):
+    """The webauthn lib is made in such a way that in order to allow multiple
+    credentials per user account, we have to create a list of WebAuthnUser, each
+    in regards to the same user but with different credentials.
+    """
+    return [get_webauthn_user(request, cred) for cred in request.user.webauthn_credential.all()]
+
+
+def get_webauthn_user(request, credential):
+    return WebAuthnUser(user_id=request.user.uuid,
+                        username=request.user.get_username(),
+                        display_name=request.user.get_full_name(),
+                        icon_url=None,
+                        credential_id=credential.credential_id,
+                        public_key=credential.public_key,
+                        sign_count=credential.signature_count,
+                        rp_id=get_hostname(request))
diff --git a/src/authentic2/auth2_multifactor/auth_webauthn/views.py b/src/authentic2/auth2_multifactor/auth_webauthn/views.py
new file mode 100644
index 00000000..27995d39
--- /dev/null
+++ b/src/authentic2/auth2_multifactor/auth_webauthn/views.py
@@ -0,0 +1,187 @@
+import logging
+
+from webauthn import (WebAuthnMakeCredentialOptions, WebAuthnRegistrationResponse,
+                      WebAuthnAssertionOptions, WebAuthnAssertionResponse)
+
+from django.db import IntegrityError
+from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotFound
+from django.urls import reverse
+from django.views.generic.base import TemplateView
+from django.views.generic.edit import FormMixin, FormView, DeleteView
+from django.views.generic.list import ListView
+
+from authentic2.utils import make_url
+
+from . import app_settings
+from .authenticators import WebAuthnAuthenticator
+from .forms import CredentialForm
+from .models import WebAuthnCredential
+from .utils import (generate_challenge, get_hostname, get_origin,
+                    get_webauthn_user_credentials, get_webauthn_user)
+
+logger = logging.getLogger(__name__)
+
+SESSION_DATA = 'webauthn_cache'
+REG_CHALLENGE = 'register_challenge'
+AUTH_CHALLENGE = 'authenticate_challenge'
+CRED_NAME = 'credential_name'
+
+
+class RegisterBegin(FormView):
+    form_class = CredentialForm
+
+    def get_form_kwargs(self):
+        kwargs = super(RegisterBegin, self).get_form_kwargs()
+        kwargs['instance'] = WebAuthnCredential(user=self.request.user)
+        return kwargs
+
+    def form_valid(self, form):
+        name = form.cleaned_data['name']
+        challenge = generate_challenge()
+        self.request.session.setdefault(SESSION_DATA, {}).update({
+            REG_CHALLENGE: challenge,
+            CRED_NAME: name
+        })
+        options = WebAuthnMakeCredentialOptions(challenge=challenge,
+                                                rp_name=app_settings.RP_NAME,
+                                                rp_id=get_hostname(self.request),
+                                                user_id=self.request.user.uuid,
+                                                username=self.request.user.get_username(),
+                                                display_name=self.request.user.get_full_name(),
+                                                icon_url=None)
+        return JsonResponse(options.registration_dict)
+
+    def form_invalid(self, form):
+        return JsonResponse({'error : form is not valid': form.errors}, status=400)
+
+
+register_begin = RegisterBegin.as_view()
+
+
+def register_proceed(request):
+    try:
+        session_data = request.session[SESSION_DATA]
+    except KeyError:
+        logger.warning(u'WebAuthn registration failure: /register/proceed '
+                       'was called before /register/begin')
+        return HttpResponseBadRequest()
+
+    registration = WebAuthnRegistrationResponse(rp_id=get_hostname(request),
+                                                origin=get_origin(request),
+                                                registration_response=request.POST,
+                                                challenge=session_data.pop(REG_CHALLENGE))
+    try:
+        credential = registration.verify()
+    except Exception as e:
+        logger.warning(u'WebAuthn registration failure: %s', e)
+        return JsonResponse({'error': 'registration failure'}, status=400)
+
+    try:
+        request.user.webauthn_credential.create(name=session_data.pop(CRED_NAME),
+                                                credential_id=credential.credential_id,
+                                                public_key=credential.public_key,
+                                                signature_count=credential.sign_count)
+    except IntegrityError as e:
+        logger.warning(u'WebAuthn error when registering new credential: %s', e)
+        return HttpResponseBadRequest()
+
+    request.user.enabled_auth_factors.get_or_create(
+        authenticator_id=WebAuthnAuthenticator._id)
+    request.session['auth_level'] = WebAuthnAuthenticator.auth_level
+
+    return JsonResponse({'success': 'User successfully registered.'})
+
+
+def authenticate_begin(request):
+    challenge = generate_challenge()
+    request.session.setdefault(SESSION_DATA, {})[AUTH_CHALLENGE] = challenge
+    webauthn_user_credentials = get_webauthn_user_credentials(request)
+    if not webauthn_user_credentials:
+        return JsonResponse({'error': 'no registered credentials'}, status=400)
+    assertion = WebAuthnAssertionOptions(webauthn_user_credentials, challenge)
+    return JsonResponse(assertion.assertion_dict)
+
+
+def authenticate_proceed(request):
+    try:
+        session_data = request.session[SESSION_DATA]
+    except KeyError:
+        logger.warning(u'WebAuthn authentication failure: /authenticate/proceed '
+                       'was called before /authenticate/begin')
+        return HttpResponseBadRequest()
+
+    try:
+        credential = request.user.webauthn_credential.get(credential_id=request.POST['id'])
+    except request.user.webauthn_credential.model.DoesNotExist:
+        return JsonResponse({'error': 'unknown credential'}, status=400)
+
+    webauthn_user = get_webauthn_user(request, credential)
+    assertion_response = WebAuthnAssertionResponse(webauthn_user=webauthn_user,
+                                                   assertion_response=request.POST,
+                                                   challenge=session_data.pop(AUTH_CHALLENGE),
+                                                   origin=get_origin(request))
+    try:
+        sign_count = assertion_response.verify()
+    except Exception as e:
+        logger.warning(u'WebAuthn authentication failure: %s', e)
+        return JsonResponse({'error': 'authentication failure'}, status=400)
+
+    credential.signature_count = sign_count
+    credential.save()
+    request.session['auth_level'] = WebAuthnAuthenticator.auth_level
+    return JsonResponse({'success': 1})
+
+
+class Profile(FormMixin, ListView):
+    template_name = 'auth_webauthn/profile.html'
+    form_class = CredentialForm
+
+    def get_queryset(self):
+        return self.request.user.webauthn_credential.all()
+
+    def get_context_data(self, **kwargs):
+        try:
+            self.request.user.enabled_auth_factors.get(
+                authenticator_id=WebAuthnAuthenticator._id)
+        except self.request.user.enabled_auth_factors.model.DoesNotExist:
+            pass
+        else:
+            if self.request.session.get('auth_level', 1) < WebAuthnAuthenticator.auth_level:
+                params = {
+                    'next': self.request.get_full_path(),
+                    'auth_level': WebAuthnAuthenticator.auth_level,
+                }
+                kwargs['insufficient_level_url'] = make_url('auth_login', params=params)
+        return super(Profile, self).get_context_data(**kwargs)
+
+
+webauthn_profile = Profile.as_view()
+
+
+class Login(TemplateView):
+    template_name = 'auth_webauthn/login.html'
+
+
+webauthn_login = Login.as_view()
+
+
+class Delete(DeleteView):
+    model = WebAuthnCredential
+    success_url = reverse('authenticators_profile')
+
+    def dispatch(self, request, *args, **kwargs):
+        cred = self.get_object()
+        if cred.user != self.request.user:
+            return HttpResponseNotFound()
+        return super(Delete, self).dispatch(request, *args, **kwargs)
+
+    def delete(self, request, *args, **kwargs):
+        response = super(Delete, self).delete(request, *args, **kwargs)
+        if not request.user.webauthn_credential.exists():
+            factor = request.user.enabled_auth_factors.get(
+                authenticator_id=WebAuthnAuthenticator._id)
+            factor.delete()
+        return response
+
+
+delete = Delete.as_view()
diff --git a/src/authentic2/auth2_multifactor/decorators.py b/src/authentic2/auth2_multifactor/decorators.py
index 3e068c8c..f267c315 100644
--- a/src/authentic2/auth2_multifactor/decorators.py
+++ b/src/authentic2/auth2_multifactor/decorators.py
@@ -1,10 +1,21 @@
 from django.core.exceptions import PermissionDenied
 
 
-def auth_level_required(auth_level):
+def auth_level_required(auth_level, only_if_enabled=None):
+    """Deny access to view if current authentication level is less than auth_level.
+
+    only_if_enabled is optional and should be the ID of an authentication
+    factor. When specified, access will be denied only if the factor is
+    enabled.
+    """
     def actual_decorator(func):
         actual_auth_level = auth_level() if callable(auth_level) else auth_level
         def wrapped(request, *args, **kwargs):
+            if only_if_enabled:
+                try:
+                    request.user.enabled_auth_factors.get(authenticator_id=only_if_enabled)
+                except request.user.enabled_auth_factors.model.DoesNotExist:
+                    return func(request, *args, **kwargs)
             if request.session.get('auth_level', 1) < actual_auth_level:
                 raise PermissionDenied
             return func(request, *args, **kwargs)
-- 
2.20.1

