0001-add-multifactor-module-auth_webauthn.patch
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 "{{ object }}" ? |
|
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 |
- |