django-mfa3

multi factor authentication for django
git clone https://git.ce9e.org/django-mfa3.git

commit
c1d45f9f973b310f7225a3604e17e6d7a2dfc2ad
parent
c3896f0e1343e938df0ecab89102a9620de3eff4
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2025-02-03 15:06
fido2: use JSON encoding

Diffstat

M README.md 2 +-
M mfa/methods/fido2.py 44 +++++++++++++-------------------------------
M mfa/static/mfa/fido2.js 38 +++++++++++---------------------------
M mfa/templates/mfa/auth_FIDO2.html 1 -
M mfa/templates/mfa/create_FIDO2.html 1 -
M tests/tests.py 6 ------

6 files changed, 25 insertions, 67 deletions


diff --git a/README.md b/README.md

@@ -29,7 +29,7 @@ pip install django-mfa3
   29    29     `settings.py` for a full list of settings.
   30    30 4.  Register URLs: `path('mfa/', include('mfa.urls', namespace='mfa')`
   31    31 5.  The included templates are just examples, so you should [replace them](https://docs.djangoproject.com/en/stable/howto/overriding-templates/) with your own
   32    -1 6.  FIDO2 requires client side code. You can either implement it yourself or use the included fido2.js (in which case you will have to provide the third party library [cbor-js](https://www.npmjs.com/package/cbor-js)).
   -1    32 6.  FIDO2 requires client side code. You can either implement it yourself or use the included fido2.js (in which case you will have to provide the third party library [@github/webauthn-json](https://www.npmjs.com/package/@github/webauthn-json)).
   33    33 7.  Somewhere in your app, add a link to `'mfa:list'`
   34    34 
   35    35 ## Enforce MFA

diff --git a/mfa/methods/fido2.py b/mfa/methods/fido2.py

@@ -1,32 +1,23 @@
    1    -1 from fido2 import cbor
   -1     1 import json
   -1     2 
    2     3 from fido2.features import webauthn_json_mapping
    3     4 from fido2.server import Fido2Server
    4     5 from fido2.utils import websafe_decode
    5     6 from fido2.utils import websafe_encode
    6    -1 from fido2.webauthn import AttestationObject
    7     7 from fido2.webauthn import AttestedCredentialData
    8    -1 from fido2.webauthn import AuthenticatorData
    9    -1 from fido2.webauthn import CollectedClientData
   10     8 from fido2.webauthn import PublicKeyCredentialRpEntity
   -1     9 from fido2.webauthn import PublicKeyCredentialUserEntity
   11    10 
   12    11 from .. import settings
   13    12 
   14    13 name = 'FIDO2'
   15    14 
   16    -1 webauthn_json_mapping.enabled = False
   -1    15 webauthn_json_mapping.enabled = True
   17    16 fido2 = Fido2Server(
   18    17     PublicKeyCredentialRpEntity(id=settings.DOMAIN, name=settings.SITE_TITLE),
   19    18 )
   20    19 
   21    20 
   22    -1 def encode(data):
   23    -1     return cbor.encode(data).hex()
   24    -1 
   25    -1 
   26    -1 def decode(s):
   27    -1     return cbor.decode(bytes.fromhex(s))
   28    -1 
   29    -1 
   30    21 def get_credentials(user):
   31    22     keys = user.mfakey_set.filter(method=name)
   32    23     return [AttestedCredentialData(websafe_decode(key.secret)) for key in keys]
@@ -34,24 +25,19 @@ def get_credentials(user):
   34    25 
   35    26 def register_begin(user):
   36    27     registration_data, state = fido2.register_begin(
   37    -1         {
   38    -1             'id': str(user.id).encode('utf-8'),
   39    -1             'name': user.get_username(),
   40    -1             'displayName': user.get_full_name(),
   41    -1         },
   -1    28         PublicKeyCredentialUserEntity(
   -1    29             id=str(user.id).encode('utf-8'),
   -1    30             name=user.get_username(),
   -1    31             display_name=user.get_full_name(),
   -1    32         ),
   42    33         get_credentials(user),
   43    34         user_verification=settings.FIDO2_USER_VERIFICATION,
   44    35     )
   45    -1     return encode(registration_data), state
   -1    36     return json.dumps(dict(registration_data)), state
   46    37 
   47    38 
   48    39 def register_complete(state, request_data):
   49    -1     data = decode(request_data)
   50    -1     auth_data = fido2.register_complete(
   51    -1         state,
   52    -1         CollectedClientData(data['clientData']),
   53    -1         AttestationObject(data['attestationObject']),
   54    -1     )
   -1    40     auth_data = fido2.register_complete(state, json.loads(request_data))
   55    41     return websafe_encode(auth_data.credential_data)
   56    42 
   57    43 
@@ -61,16 +47,12 @@ def authenticate_begin(user):
   61    47         credentials,
   62    48         user_verification=settings.FIDO2_USER_VERIFICATION,
   63    49     )
   64    -1     return encode(auth_data), state
   -1    50     return json.dumps(dict(auth_data)), state
   65    51 
   66    52 
   67    53 def authenticate_complete(state, user, request_data):
   68    -1     data = decode(request_data)
   69    54     fido2.authenticate_complete(
   70    55         state,
   71    56         get_credentials(user),
   72    -1         data['credentialId'],
   73    -1         CollectedClientData(data['clientData']),
   74    -1         AuthenticatorData(data['authenticatorData']),
   75    -1         data['signature'],
   -1    57         json.loads(request_data),
   76    58     )

diff --git a/mfa/static/mfa/fido2.js b/mfa/static/mfa/fido2.js

@@ -1,29 +1,16 @@
    1    -1 var encode = function(data) {
    2    -1     var buffer = CBOR.encode(data);
    3    -1     var arr = new Uint8Array(buffer);
    4    -1     return arr.reduce((s, b) => s + b.toString(16).padStart(2, '0'), '');
    5    -1 };
    6    -1 
    7    -1 var decode = function(hex) {
    8    -1     var arr = new Uint8Array(hex.length / 2);
    9    -1     for (var i = 0; i < arr.length; i += 1) {
   10    -1         arr[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
   11    -1     }
   12    -1     return CBOR.decode(arr.buffer);
   13    -1 };
   -1     1 import * as webauthnJSON from 'https://cdn.jsdelivr.net/npm/@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js';
   14     2 
   15     3 var initCreate = function() {
   16     4     var form = document.querySelector('form[data-fido2-create]');
   17     5     if (form) {
   18    -1         var options = decode(form.dataset.fido2Create);
   -1     6         var options = webauthnJSON.parseCreationOptionsFromJSON(
   -1     7             JSON.parse(form.dataset.fido2Create)
   -1     8         );
   19     9         form.addEventListener('submit', function(event) {
   20    10             event.preventDefault();
   21    11 
   22    -1             navigator.credentials.create(options).then(attestation => {
   23    -1                 this.code.value = encode({
   24    -1                     'attestationObject': new Uint8Array(attestation.response.attestationObject),
   25    -1                     'clientData': new Uint8Array(attestation.response.clientDataJSON),
   26    -1                 });
   -1    12             webauthnJSON.create(options).then(attestation => {
   -1    13                 this.code.value = JSON.stringify(attestation);
   27    14                 form.submit();
   28    15             }).catch(alert);
   29    16         });
@@ -33,17 +20,14 @@ var initCreate = function() {
   33    20 var initAuth = function() {
   34    21     var form = document.querySelector('form[data-fido2-auth]');
   35    22     if (form) {
   36    -1         var options = decode(form.dataset.fido2Auth);
   -1    23         var options = webauthnJSON.parseRequestOptionsFromJSON(
   -1    24             JSON.parse(form.dataset.fido2Auth)
   -1    25         );
   37    26         form.addEventListener('submit', function(event) {
   38    27             event.preventDefault();
   39    28 
   40    -1             navigator.credentials.get(options).then(assertion => {
   41    -1                 this.code.value = encode({
   42    -1                     'credentialId': new Uint8Array(assertion.rawId),
   43    -1                     'authenticatorData': new Uint8Array(assertion.response.authenticatorData),
   44    -1                     'clientData': new Uint8Array(assertion.response.clientDataJSON),
   45    -1                     'signature': new Uint8Array(assertion.response.signature),
   46    -1                 });
   -1    29             webauthnJSON.get(options).then(assertion => {
   -1    30                 this.code.value = JSON.stringify(assertion);
   47    31                 form.submit();
   48    32             }).catch(alert);
   49    33         });

diff --git a/mfa/templates/mfa/auth_FIDO2.html b/mfa/templates/mfa/auth_FIDO2.html

@@ -14,5 +14,4 @@
   14    14     <a href="{% url 'mfa:auth' 'recovery' %}">Use recovery code instead</a>
   15    15 </form>
   16    16 
   17    -1 <script src="{% static 'cbor-js/cbor.js' %}"></script>
   18    17 <script src="{% static 'mfa/fido2.js' %}" type="module"></script>

diff --git a/mfa/templates/mfa/create_FIDO2.html b/mfa/templates/mfa/create_FIDO2.html

@@ -15,5 +15,4 @@
   15    15     <button>Create</button>
   16    16 </form>
   17    17 
   18    -1 <script src="{% static 'cbor-js/cbor.js' %}"></script>
   19    18 <script src="{% static 'mfa/fido2.js' %}" type="module"></script>

diff --git a/tests/tests.py b/tests/tests.py

@@ -187,12 +187,6 @@ class FIDO2Test(MFATestCase):
  187   187         res = self.client.get('/mfa/create/FIDO2/')
  188   188         self.assertEqual(res.status_code, 200)
  189   189 
  190    -1     def test_encode(self):
  191    -1         self.assertEqual(fido2.encode({'foo': [1, 2]}), 'a163666f6f820102')
  192    -1 
  193    -1     def test_decode(self):
  194    -1         self.assertEqual(fido2.decode('a163666f6f820102'), {'foo': [1, 2]})
  195    -1 
  196   190     def test_origin_https(self):
  197   191         for domain, value, expected in [
  198   192             ('example.com', 'https://example.com', True),