Projet

Général

Profil

0001-add-multifactor-module-auth_webauthn.patch

Valentin Deniaud, 10 juillet 2019 12:08

Télécharger (41 ko)

Voir les différences:

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
MANIFEST.in
10 10
recursive-include src/authentic2/static *.css *.js *.ico *.gif *.png *.jpg
11 11
recursive-include src/authentic2/manager/static *.css *.js *.png
12 12
recursive-include src/authentic2_auth_fc/static/authentic2_auth_fc *.css *.js *.png *.svg
13
recursive-include src/authentic2/auth2_multifactor/static/auth_webauthn/ *.js
13 14

  
14 15
# templates
15 16
recursive-include src/authentic2/templates *.html *.txt *.xml
......
26 27
recursive-include src/authentic2_auth_oidc/templates/authentic2_auth_oidc *.html
27 28
recursive-include src/authentic2_idp_oidc/templates/authentic2_idp_oidc *.html
28 29
recursive-include src/authentic2/auth2_multifactor/auth_oath/templates *.html
30
recursive-include src/authentic2/auth2_multifactor/auth_webauthn/templates *.html
29 31

  
30 32
recursive-include src/authentic2/saml/fixtures *.json
31 33
recursive-include src/authentic2/locale *.po *.mo
setup.py
142 142
          'tablib',
143 143
          'qrcode',
144 144
          'oath',
145
          'webauthn',
145 146
      ],
146 147
      zip_safe=False,
147 148
      classifiers=[
......
177 178
              'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin',
178 179
              'authentic2-auth-fc = authentic2_auth_fc:Plugin',
179 180
              'authentic2-auth-oath = authentic2.auth2_multifactor.auth_oath:Plugin',
181
              'authentic2-auth-webauthn = authentic2.auth2_multifactor.auth_webauthn:Plugin',
180 182
          ],
181 183
      })
src/authentic2/auth2_multifactor/auth_webauthn/__init__.py
1
class Plugin(object):
2
    def get_before_urls(self):
3
        from . import app_settings
4
        from django.conf.urls import include, url
5
        from authentic2.decorators import setting_enabled, required
6

  
7
        return required(
8
            setting_enabled('ENABLE', settings=app_settings),
9
            [url(r'^accounts/authenticators/webauthn/', include(__name__ + '.urls'))])
10

  
11
    def get_apps(self):
12
        return [__name__]
13

  
14
    def get_authenticators(self):
15
        return ['authentic2.auth2_multifactor.auth_webauthn.authenticators.WebAuthnAuthenticator']
src/authentic2/auth2_multifactor/auth_webauthn/app_settings.py
1
class AppSettings(object):
2
    __DEFAULTS = {
3
        'ENABLE': True,
4
        'LEVEL': 3,
5
        'RP_NAME': 'Publik',
6
    }
7

  
8
    def __init__(self, prefix):
9
        self.prefix = prefix
10

  
11
    def _setting(self, name, dflt):
12
        from django.conf import settings
13
        return getattr(settings, self.prefix + name, dflt)
14

  
15
    def __getattr__(self, name):
16
        if name not in self.__DEFAULTS:
17
            raise AttributeError(name)
18
        return self._setting(name, self.__DEFAULTS[name])
19

  
20

  
21
import sys
22
app_settings = AppSettings('A2_AUTH_WEBAUTHN_')
23
app_settings.__name__ = __name__
24
sys.modules[__name__] = app_settings
src/authentic2/auth2_multifactor/auth_webauthn/apps.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

  
4
from django.apps import AppConfig
5

  
6

  
7
class AuthWebauthnConfig(AppConfig):
8
    name = 'auth_webauthn'
src/authentic2/auth2_multifactor/auth_webauthn/authenticators.py
1
from django.utils.translation import ugettext_lazy
2

  
3
from . import app_settings
4

  
5

  
6
class WebAuthnAuthenticator(object):
7
    submit_name = 'webauthn-submit'
8
    auth_level = app_settings.LEVEL
9
    _id = 'multifactor-webauthn'
10

  
11
    def enabled(self):
12
        return app_settings.ENABLE
13

  
14
    def name(self):
15
        return ugettext_lazy('Hardware token')
16

  
17
    def id(self):
18
        return self._id
19

  
20
    def login(self, request, *args, **kwargs):
21
        from .views import webauthn_login
22
        return webauthn_login(request, *args, **kwargs)
23

  
24
    def profile(self, request, *args, **kwargs):
25
        from .views import webauthn_profile
26
        return webauthn_profile(request, *args, **kwargs)
src/authentic2/auth2_multifactor/auth_webauthn/decorators.py
1
from functools import wraps
2

  
3
from django.http import HttpResponseForbidden
4

  
5

  
6
def secure_required(func):
7
    @wraps(func)
8
    def inner(request, *args, **kwargs):
9
        if not request.is_secure():
10
            return HttpResponseForbidden('WebAuthn requires https',
11
                                         content_type='text/plain')
12
        return func(request, *args, **kwargs)
13
    return inner
src/authentic2/auth2_multifactor/auth_webauthn/forms.py
1
from django import forms
2

  
3
from .models import WebAuthnCredential
4

  
5

  
6
class CredentialForm(forms.ModelForm):
7
    class Meta:
8
        model = WebAuthnCredential
9
        fields = ['name']
src/authentic2/auth2_multifactor/auth_webauthn/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2019-07-05 15:12
3
from __future__ import unicode_literals
4

  
5
from django.conf import settings
6
from django.db import migrations, models
7
import django.db.models.deletion
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    initial = True
13

  
14
    dependencies = [
15
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16
    ]
17

  
18
    operations = [
19
        migrations.CreateModel(
20
            name='WebAuthnCredential',
21
            fields=[
22
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
                ('name', models.CharField(max_length=30, verbose_name='Authenticator name')),
24
                ('date', models.DateField(auto_now_add=True)),
25
                ('credential_id', models.CharField(max_length=256)),
26
                ('public_key', models.CharField(max_length=1000)),
27
                ('signature_count', models.PositiveIntegerField()),
28
                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webauthn_credential', to=settings.AUTH_USER_MODEL, verbose_name='utilisateur')),
29
            ],
30
        ),
31
        migrations.AlterUniqueTogether(
32
            name='webauthncredential',
33
            unique_together=set([('user', 'name'), ('user', 'credential_id')]),
34
        ),
35
    ]
src/authentic2/auth2_multifactor/auth_webauthn/models.py
1
from django.conf import settings
2
from django.db import models
3
from django.utils.translation import ugettext as _
4

  
5

  
6
class WebAuthnCredential(models.Model):
7

  
8
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='webauthn_credential',
9
                             verbose_name=_('user'))
10
    name = models.CharField(max_length=30, verbose_name=_('Authenticator name'))
11
    date = models.DateField(auto_now_add=True)
12
    credential_id = models.CharField(max_length=256)
13
    public_key = models.CharField(max_length=1000)
14
    signature_count = models.PositiveIntegerField()
15

  
16
    def validate_unique(self, exclude=None):
17
        # Allow validating (name, user) uniqueness at form submission
18
        if getattr(self, 'user', None) and 'user' in exclude:
19
            exclude.remove('user')
20
        super(WebAuthnCredential, self).validate_unique(exclude=exclude)
21

  
22
    def __str__(self):
23
        return self.name
24

  
25
    class Meta:
26
        unique_together = [
27
            ['user', 'name'],
28
            # spec says credential ids should be unique accross all users,
29
            # but this seems only relevant if webauthn is used as a primary
30
            # authentication factor, something we don't allow.
31
            ['user', 'credential_id']
32
        ]
src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/base64js.min.js
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("")}},{}]},{},[])("/")});
src/authentic2/auth2_multifactor/auth_webauthn/static/auth_webauthn/js/webauthn.js
1
// Adapted from https://github.com/duo-labs/py_webauthn/blob/master/flask_demo/static/js/webauthn.js
2
// as we already depend on py_webauthn
3

  
4
function b64enc(buf) {
5
    return base64js.fromByteArray(buf)
6
                   .replace(/\+/g, "-")
7
                   .replace(/\//g, "_")
8
                   .replace(/=/g, "");
9
}
10

  
11
function b64RawEnc(buf) {
12
    return base64js.fromByteArray(buf)
13
    .replace(/\+/g, "-")
14
    .replace(/\//g, "_");
15
}
16

  
17
function hexEncode(buf) {
18
    return Array.from(buf)
19
                .map(function(x) {
20
                    return ("0" + x.toString(16)).substr(-2);
21
				})
22
                .join("");
23
}
24

  
25
function getFormData(form) {
26
  if(form != null)
27
    form_data = new FormData(form);
28
  else
29
    form_data = new FormData();
30
  form_data.append('csrfmiddlewaretoken', window.csrf_token);
31
  return form_data;
32
}
33

  
34
async function fetch_json(url, options) {
35
    const response = await fetch(url, options);
36
    const body = await response.json();
37
    if (body.fail)
38
        throw body.fail;
39
    return body;
40
}
41

  
42
/**
43
 * REGISTRATION FUNCTIONS
44
 */
45

  
46
/**
47
 * Callback after the registration form is submitted.
48
 * @param {Event} e
49
 */
50
const didClickRegister = async (e) => {
51
    e.preventDefault();
52

  
53
    // gather the data in the form
54
    const form = document.querySelector('#register-form');
55

  
56
    if(!form.reportValidity())
57
      return false;
58

  
59
    // post the data to the server to generate the PublicKeyCredentialCreateOptions
60
    let credentialCreateOptionsFromServer;
61
    try {
62
        credentialCreateOptionsFromServer = await getCredentialOptionsFromServer(form);
63
    } catch (err) {
64
        return console.error("Failed to generate credential request options:", credentialCreateOptionsFromServer)
65
    }
66

  
67
    // convert certain members of the PublicKeyCredentialCreateOptions into
68
    // byte arrays as expected by the spec.
69
    const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(credentialCreateOptionsFromServer);
70

  
71
    // request the authenticator(s) to create a new credential keypair.
72
    let credential;
73
    try {
74
        credential = await navigator.credentials.create({
75
            publicKey: publicKeyCredentialCreateOptions
76
        });
77
    } catch (err) {
78
        return console.error("Error creating credential:", err);
79
    }
80

  
81
    // we now have a new credential! We now need to encode the byte arrays
82
    // in the credential into strings, for posting to our server.
83
    const newAssertionForServer = transformNewAssertionForServer(credential);
84

  
85
    // post the transformed credential data to the server for validation
86
    // and storing the public key
87
    let assertionValidationResponse;
88
    try {
89
        assertionValidationResponse = await postNewAssertionToServer(newAssertionForServer);
90
    } catch (err) {
91
        return console.error("Server validation of credential failed:", err);
92
    }
93

  
94
    // reload the page after a successful result
95
    let current_url = new URL(window.location);
96
    let next = current_url.searchParams.get('next');
97
    if(next)
98
      window.location.replace(next);
99
    else
100
      window.location.reload();
101
}
102

  
103

  
104
const transformCredentialRequestOptions = (credentialRequestOptionsFromServer) => {
105
    let {challenge, allowCredentials} = credentialRequestOptionsFromServer;
106

  
107
    challenge = Uint8Array.from(
108
        atob(challenge), c => c.charCodeAt(0));
109

  
110
    allowCredentials = allowCredentials.map(credentialDescriptor => {
111
        let {id} = credentialDescriptor;
112
        id = id.replace(/\_/g, "/").replace(/\-/g, "+");
113
        id = Uint8Array.from(atob(id), c => c.charCodeAt(0));
114
        return Object.assign({}, credentialDescriptor, {id});
115
    });
116

  
117
    const transformedCredentialRequestOptions = Object.assign(
118
        {},
119
        credentialRequestOptionsFromServer,
120
        {challenge, allowCredentials});
121

  
122
    return transformedCredentialRequestOptions;
123
};
124

  
125

  
126
/**
127
 * Get PublicKeyCredentialRequestOptions for this user from the server
128
 * formData of the registration form
129
 * @param {form} form
130
 */
131
const getCredentialOptionsFromServer = async (form) => {
132
    return await fetch_json(
133
        form.getAttribute('action'),
134
        {
135
            method: "POST",
136
            body: getFormData(form)
137
        }
138
    );
139
}
140

  
141
/**
142
 * Transforms items in the credentialCreateOptions generated on the server
143
 * into byte arrays expected by the navigator.credentials.create() call
144
 * @param {Object} credentialCreateOptionsFromServer
145
 */
146
const transformCredentialCreateOptions = (credentialCreateOptionsFromServer) => {
147
    let {challenge, user} = credentialCreateOptionsFromServer;
148
    user.id = Uint8Array.from(
149
        atob(credentialCreateOptionsFromServer.user.id), c => c.charCodeAt(0));
150

  
151
    challenge = Uint8Array.from(
152
        atob(credentialCreateOptionsFromServer.challenge), c => c.charCodeAt(0));
153

  
154
    const transformedCredentialCreateOptions = Object.assign(
155
            {}, credentialCreateOptionsFromServer,
156
            {challenge, user});
157

  
158
    return transformedCredentialCreateOptions;
159
}
160

  
161

  
162

  
163
/**
164
 * AUTHENTICATION FUNCTIONS
165
 */
166

  
167

  
168
/**
169
 * Callback executed after submitting login form
170
 * @param {Event} e
171
 */
172
const didClickLogin = async (e) => {
173
    e.preventDefault();
174
    // gather the data in the form
175
    const form = document.querySelector('#login-form');
176

  
177
    // post the login data to the server to retrieve the PublicKeyCredentialRequestOptions
178
    let credentialCreateOptionsFromServer;
179
    try {
180
        credentialRequestOptionsFromServer = await getCredentialOptionsFromServer(form);
181
    } catch (err) {
182
        return console.error("Error when getting request options from server:", err);
183
    }
184

  
185
    // convert certain members of the PublicKeyCredentialRequestOptions into
186
    // byte arrays as expected by the spec.
187
    const transformedCredentialRequestOptions = transformCredentialRequestOptions(
188
        credentialRequestOptionsFromServer);
189

  
190
    // request the authenticator to create an assertion signature using the
191
    // credential private key
192
    let assertion;
193
    try {
194
        assertion = await navigator.credentials.get({
195
            publicKey: transformedCredentialRequestOptions,
196
        });
197
    } catch (err) {
198
        return console.error("Error when creating credential:", err);
199
    }
200

  
201
    // we now have an authentication assertion! encode the byte arrays contained
202
    // in the assertion data as strings for posting to the server
203
    const transformedAssertionForServer = transformAssertionForServer(assertion);
204

  
205
    // post the assertion to the server for verification.
206
    let response;
207
    try {
208
        response = await postAssertionToServer(transformedAssertionForServer);
209
    } catch (err) {
210
        return console.error("Error when validating assertion on server:", err);
211
    }
212

  
213
    let current_url = new URL(window.location);
214
    window.location.replace(current_url.searchParams.get('next'));
215
};
216

  
217
/**
218
 * Transforms the binary data in the credential into base64 strings
219
 * for posting to the server.
220
 * @param {PublicKeyCredential} newAssertion
221
 */
222
const transformNewAssertionForServer = (newAssertion) => {
223
    const attObj = new Uint8Array(
224
        newAssertion.response.attestationObject);
225
    const clientDataJSON = new Uint8Array(
226
        newAssertion.response.clientDataJSON);
227
    const rawId = new Uint8Array(
228
        newAssertion.rawId);
229

  
230
    const registrationClientExtensions = newAssertion.getClientExtensionResults();
231

  
232
    return {
233
        id: newAssertion.id,
234
        rawId: b64enc(rawId),
235
        type: newAssertion.type,
236
        attObj: b64enc(attObj),
237
        clientData: b64enc(clientDataJSON),
238
        registrationClientExtensions: JSON.stringify(registrationClientExtensions)
239
    };
240
}
241

  
242
/**
243
 * Posts the new credential data to the server for validation and storage.
244
 * @param {Object} credentialDataForServer
245
 */
246
const postNewAssertionToServer = async (credentialDataForServer) => {
247
    const formData = getFormData(null);
248
    Object.entries(credentialDataForServer).forEach(([key, value]) => {
249
        formData.set(key, value);
250
    });
251

  
252
    return await fetch_json(
253
        document.getElementById('register-proceed-url').href, {
254
        method: "POST",
255
        body: formData
256
    });
257
}
258

  
259
/**
260
 * Encodes the binary data in the assertion into strings for posting to the server.
261
 * @param {PublicKeyCredential} newAssertion
262
 */
263
const transformAssertionForServer = (newAssertion) => {
264
    const authData = new Uint8Array(newAssertion.response.authenticatorData);
265
    const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
266
    const rawId = new Uint8Array(newAssertion.rawId);
267
    const sig = new Uint8Array(newAssertion.response.signature);
268
    const assertionClientExtensions = newAssertion.getClientExtensionResults();
269

  
270
    return {
271
        id: newAssertion.id,
272
        rawId: b64enc(rawId),
273
        type: newAssertion.type,
274
        authData: b64RawEnc(authData),
275
        clientData: b64RawEnc(clientDataJSON),
276
        signature: hexEncode(sig),
277
        assertionClientExtensions: JSON.stringify(assertionClientExtensions)
278
    };
279
};
280

  
281
/**
282
 * Post the assertion to the server for validation and logging the user in.
283
 * @param {Object} assertionDataForServer
284
 */
285
const postAssertionToServer = async (assertionDataForServer) => {
286
    const formData = getFormData(null);
287
    Object.entries(assertionDataForServer).forEach(([key, value]) => {
288
        formData.set(key, value);
289
    });
290

  
291
    return await fetch_json(
292
        document.getElementById('auth-proceed-url').href, {
293
        method: "POST",
294
        body: formData
295
    });
296
}
297

  
298

  
299
document.addEventListener("DOMContentLoaded", e => {
300
    let qs;
301
    if(qs = document.querySelector('#register'))
302
        qs.addEventListener('click', didClickRegister);
303
    if(qs = document.querySelector('#login'))
304
        qs.addEventListener('click', didClickLogin);
305
});
src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/login.html
1
{% load i18n %}
2
{% include "auth_webauthn/scripts.html" %}
3

  
4
<a hidden id="auth-proceed-url" href="{% url 'authenticate-proceed' %}"></a>
5

  
6
<p>
7
{% trans "In order to continue, please use one of the hardware devices you configured, by pressing the button below." %}
8
</p>
9

  
10
<form id="login-form" name="login" action="{% url 'authenticate-begin' %}">
11
  <button id="login" type="submit">Continue with WebAuthn</button>
12
</form>
src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/profile.html
1
{% load i18n static %}
2
{% include "auth_webauthn/scripts.html" %}
3

  
4
{% if insufficient_level_url %}
5
  <a href="{{ insufficient_level_url }}">
6
    {% trans "Insufficient authentication level to view, click here to increase." %}
7
  </a>
8
{% else %}
9
  <p>
10
    {% trans "Add a hardware device in order to allow for stronger authentication." %}
11
  </p>
12

  
13
  <a hidden id="register-proceed-url" href="{% url 'register-proceed' %}"></a>
14
  <form id="register-form" name="register" action="{% url 'register-begin' %}">
15
    {{ form.as_p }}
16
    <button id="register" type="submit">{% trans "Register new WebAuthn device" %}</button>
17
  </form>
18

  
19
  <h3>{% trans "Already registered credentials" %} :</h3>
20
  <ul>
21
    {% for credential in object_list %}
22
      <li>
23
        {{ credential.name }} -
24
        {% trans "Added on" %} {{ credential.date }} -
25
        <a href="{% url 'webauthn-delete' credential.pk %}">{% trans "Delete" %}</a>
26
      </li>
27
    {% empty %}
28
      <li>{% trans "No credentials have been registered yet." %}</li>
29
    {% endfor %}
30
  </ul>
31
{% endif %}
src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/scripts.html
1
{% load static %}
2

  
3
<script type="text/javascript" src="{% static "auth_webauthn/js/webauthn.js" %}"></script>
4
<script type="text/javascript" src="{% static "auth_webauthn/js/base64js.min.js" %}"></script>
5
<script>
6
  window.csrf_token = '{{ csrf_token }}';
7
</script>
src/authentic2/auth2_multifactor/auth_webauthn/templates/auth_webauthn/webauthncredential_confirm_delete.html
1
{% extends "authentic2/base-page.html" %}
2
{% load i18n %}
3

  
4
{% block content %}
5
<form method="post">
6
  {% csrf_token %}
7
  <p>
8
  {% blocktrans %}
9
    Do you really want to remove credential &quot;{{ object }}&quot;&nbsp;?
10
  {% endblocktrans %}
11
  </p>
12
  <input type="submit" value="Confirm">
13
</form>
14
{% endblock %}
src/authentic2/auth2_multifactor/auth_webauthn/urls.py
1
from django.conf.urls import url
2
from django.contrib.auth.decorators import login_required
3
from django.views.decorators.csrf import requires_csrf_token
4
from django.views.decorators.http import require_POST
5

  
6
from authentic2.auth2_multifactor.decorators import auth_level_required
7
from authentic2.decorators import required
8

  
9
from . import views
10
from .authenticators import WebAuthnAuthenticator
11
from .decorators import secure_required
12

  
13

  
14
common_decorators = [login_required]
15
webauth_api_decorators = [secure_required, require_POST, requires_csrf_token]
16
restrict_auth_level = [auth_level_required(WebAuthnAuthenticator.auth_level,
17
                                           only_if_enabled=WebAuthnAuthenticator._id)]
18

  
19
urlpatterns = required(
20
    common_decorators + webauth_api_decorators, [
21
        url(r'^authenticate/begin/$', views.authenticate_begin, name='authenticate-begin'),
22
        url(r'^authenticate/proceed/$', views.authenticate_proceed, name='authenticate-proceed'),
23
    ]
24
)
25

  
26
urlpatterns += required(
27
    common_decorators + webauth_api_decorators + restrict_auth_level, [
28
        url(r'^register/begin/$', views.register_begin, name='register-begin'),
29
        url(r'^register/proceed/$', views.register_proceed, name='register-proceed'),
30
    ]
31
)
32

  
33
urlpatterns += required(
34
    common_decorators + restrict_auth_level, [
35
        url(r'^credential/delete/(?P<pk>\d+)/$', views.delete, name='webauthn-delete'),
36
    ]
37
)
src/authentic2/auth2_multifactor/auth_webauthn/utils.py
1
from random import SystemRandom
2

  
3
from webauthn import WebAuthnUser
4

  
5

  
6
def generate_challenge():
7
    return format(SystemRandom().getrandbits(256), '064x')
8

  
9

  
10
def get_hostname(request):
11
    host = request.get_host()
12
    if ':' in host:
13
        host = host.split(':')[0]
14
    return host
15

  
16

  
17
def get_origin(request):
18
    return 'https://' + request.get_host()
19

  
20

  
21
def get_webauthn_user_credentials(request):
22
    """The webauthn lib is made in such a way that in order to allow multiple
23
    credentials per user account, we have to create a list of WebAuthnUser, each
24
    in regards to the same user but with different credentials.
25
    """
26
    return [get_webauthn_user(request, cred) for cred in request.user.webauthn_credential.all()]
27

  
28

  
29
def get_webauthn_user(request, credential):
30
    return WebAuthnUser(user_id=request.user.uuid,
31
                        username=request.user.get_username(),
32
                        display_name=request.user.get_full_name(),
33
                        icon_url=None,
34
                        credential_id=credential.credential_id,
35
                        public_key=credential.public_key,
36
                        sign_count=credential.signature_count,
37
                        rp_id=get_hostname(request))
src/authentic2/auth2_multifactor/auth_webauthn/views.py
1
import logging
2

  
3
from webauthn import (WebAuthnMakeCredentialOptions, WebAuthnRegistrationResponse,
4
                      WebAuthnAssertionOptions, WebAuthnAssertionResponse)
5

  
6
from django.db import IntegrityError
7
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotFound
8
from django.urls import reverse
9
from django.views.generic.base import TemplateView
10
from django.views.generic.edit import FormMixin, FormView, DeleteView
11
from django.views.generic.list import ListView
12

  
13
from authentic2.utils import make_url
14

  
15
from . import app_settings
16
from .authenticators import WebAuthnAuthenticator
17
from .forms import CredentialForm
18
from .models import WebAuthnCredential
19
from .utils import (generate_challenge, get_hostname, get_origin,
20
                    get_webauthn_user_credentials, get_webauthn_user)
21

  
22
logger = logging.getLogger(__name__)
23

  
24
SESSION_DATA = 'webauthn_cache'
25
REG_CHALLENGE = 'register_challenge'
26
AUTH_CHALLENGE = 'authenticate_challenge'
27
CRED_NAME = 'credential_name'
28

  
29

  
30
class RegisterBegin(FormView):
31
    form_class = CredentialForm
32

  
33
    def get_form_kwargs(self):
34
        kwargs = super(RegisterBegin, self).get_form_kwargs()
35
        kwargs['instance'] = WebAuthnCredential(user=self.request.user)
36
        return kwargs
37

  
38
    def form_valid(self, form):
39
        name = form.cleaned_data['name']
40
        challenge = generate_challenge()
41
        self.request.session.setdefault(SESSION_DATA, {}).update({
42
            REG_CHALLENGE: challenge,
43
            CRED_NAME: name
44
        })
45
        options = WebAuthnMakeCredentialOptions(challenge=challenge,
46
                                                rp_name=app_settings.RP_NAME,
47
                                                rp_id=get_hostname(self.request),
48
                                                user_id=self.request.user.uuid,
49
                                                username=self.request.user.get_username(),
50
                                                display_name=self.request.user.get_full_name(),
51
                                                icon_url=None)
52
        return JsonResponse(options.registration_dict)
53

  
54
    def form_invalid(self, form):
55
        return JsonResponse({'error : form is not valid': form.errors}, status=400)
56

  
57

  
58
register_begin = RegisterBegin.as_view()
59

  
60

  
61
def register_proceed(request):
62
    try:
63
        session_data = request.session[SESSION_DATA]
64
    except KeyError:
65
        logger.warning(u'WebAuthn registration failure: /register/proceed '
66
                       'was called before /register/begin')
67
        return HttpResponseBadRequest()
68

  
69
    registration = WebAuthnRegistrationResponse(rp_id=get_hostname(request),
70
                                                origin=get_origin(request),
71
                                                registration_response=request.POST,
72
                                                challenge=session_data.pop(REG_CHALLENGE))
73
    try:
74
        credential = registration.verify()
75
    except Exception as e:
76
        logger.warning(u'WebAuthn registration failure: %s', e)
77
        return JsonResponse({'error': 'registration failure'}, status=400)
78

  
79
    try:
80
        request.user.webauthn_credential.create(name=session_data.pop(CRED_NAME),
81
                                                credential_id=credential.credential_id,
82
                                                public_key=credential.public_key,
83
                                                signature_count=credential.sign_count)
84
    except IntegrityError as e:
85
        logger.warning(u'WebAuthn error when registering new credential: %s', e)
86
        return HttpResponseBadRequest()
87

  
88
    request.user.enabled_auth_factors.get_or_create(
89
        authenticator_id=WebAuthnAuthenticator._id)
90
    request.session['auth_level'] = WebAuthnAuthenticator.auth_level
91

  
92
    return JsonResponse({'success': 'User successfully registered.'})
93

  
94

  
95
def authenticate_begin(request):
96
    challenge = generate_challenge()
97
    request.session.setdefault(SESSION_DATA, {})[AUTH_CHALLENGE] = challenge
98
    webauthn_user_credentials = get_webauthn_user_credentials(request)
99
    if not webauthn_user_credentials:
100
        return JsonResponse({'error': 'no registered credentials'}, status=400)
101
    assertion = WebAuthnAssertionOptions(webauthn_user_credentials, challenge)
102
    return JsonResponse(assertion.assertion_dict)
103

  
104

  
105
def authenticate_proceed(request):
106
    try:
107
        session_data = request.session[SESSION_DATA]
108
    except KeyError:
109
        logger.warning(u'WebAuthn authentication failure: /authenticate/proceed '
110
                       'was called before /authenticate/begin')
111
        return HttpResponseBadRequest()
112

  
113
    try:
114
        credential = request.user.webauthn_credential.get(credential_id=request.POST['id'])
115
    except request.user.webauthn_credential.model.DoesNotExist:
116
        return JsonResponse({'error': 'unknown credential'}, status=400)
117

  
118
    webauthn_user = get_webauthn_user(request, credential)
119
    assertion_response = WebAuthnAssertionResponse(webauthn_user=webauthn_user,
120
                                                   assertion_response=request.POST,
121
                                                   challenge=session_data.pop(AUTH_CHALLENGE),
122
                                                   origin=get_origin(request))
123
    try:
124
        sign_count = assertion_response.verify()
125
    except Exception as e:
126
        logger.warning(u'WebAuthn authentication failure: %s', e)
127
        return JsonResponse({'error': 'authentication failure'}, status=400)
128

  
129
    credential.signature_count = sign_count
130
    credential.save()
131
    request.session['auth_level'] = WebAuthnAuthenticator.auth_level
132
    return JsonResponse({'success': 1})
133

  
134

  
135
class Profile(FormMixin, ListView):
136
    template_name = 'auth_webauthn/profile.html'
137
    form_class = CredentialForm
138

  
139
    def get_queryset(self):
140
        return self.request.user.webauthn_credential.all()
141

  
142
    def get_context_data(self, **kwargs):
143
        try:
144
            self.request.user.enabled_auth_factors.get(
145
                authenticator_id=WebAuthnAuthenticator._id)
146
        except self.request.user.enabled_auth_factors.model.DoesNotExist:
147
            pass
148
        else:
149
            if self.request.session.get('auth_level', 1) < WebAuthnAuthenticator.auth_level:
150
                params = {
151
                    'next': self.request.get_full_path(),
152
                    'auth_level': WebAuthnAuthenticator.auth_level,
153
                }
154
                kwargs['insufficient_level_url'] = make_url('auth_login', params=params)
155
        return super(Profile, self).get_context_data(**kwargs)
156

  
157

  
158
webauthn_profile = Profile.as_view()
159

  
160

  
161
class Login(TemplateView):
162
    template_name = 'auth_webauthn/login.html'
163

  
164

  
165
webauthn_login = Login.as_view()
166

  
167

  
168
class Delete(DeleteView):
169
    model = WebAuthnCredential
170
    success_url = reverse('authenticators_profile')
171

  
172
    def dispatch(self, request, *args, **kwargs):
173
        cred = self.get_object()
174
        if cred.user != self.request.user:
175
            return HttpResponseNotFound()
176
        return super(Delete, self).dispatch(request, *args, **kwargs)
177

  
178
    def delete(self, request, *args, **kwargs):
179
        response = super(Delete, self).delete(request, *args, **kwargs)
180
        if not request.user.webauthn_credential.exists():
181
            factor = request.user.enabled_auth_factors.get(
182
                authenticator_id=WebAuthnAuthenticator._id)
183
            factor.delete()
184
        return response
185

  
186

  
187
delete = Delete.as_view()
src/authentic2/auth2_multifactor/decorators.py
1 1
from django.core.exceptions import PermissionDenied
2 2

  
3 3

  
4
def auth_level_required(auth_level):
4
def auth_level_required(auth_level, only_if_enabled=None):
5
    """Deny access to view if current authentication level is less than auth_level.
6

  
7
    only_if_enabled is optional and should be the ID of an authentication
8
    factor. When specified, access will be denied only if the factor is
9
    enabled.
10
    """
5 11
    def actual_decorator(func):
6 12
        actual_auth_level = auth_level() if callable(auth_level) else auth_level
7 13
        def wrapped(request, *args, **kwargs):
14
            if only_if_enabled:
15
                try:
16
                    request.user.enabled_auth_factors.get(authenticator_id=only_if_enabled)
17
                except request.user.enabled_auth_factors.model.DoesNotExist:
18
                    return func(request, *args, **kwargs)
8 19
            if request.session.get('auth_level', 1) < actual_auth_level:
9 20
                raise PermissionDenied
10 21
            return func(request, *args, **kwargs)
11
-