django-mfa3

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

commit
44be1fd05bad2783daa8d6a85aa6a25417e30093
parent
fbbc19dcaa50b50e40e5476ed774d48b056cbe42
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2025-05-21 14:41
Merge pull request #13 from xi/fido2-json

fido2: use JSON encoding

Diffstat

M README.md 2 +-
M mfa/methods/fido2.py 44 +++++++++++++-------------------------------
M mfa/static/mfa/fido2.js 84 ++++++++++++++++++++++++-------------------------------------
A mfa/static/mfa/vendor/webauthn-json.browser-ponyfill.js 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M mfa/templates/mfa/auth_FIDO2.html 3 +--
M mfa/templates/mfa/create_FIDO2.html 3 +--
M tests/tests.py 6 ------

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

diff --git a/mfa/static/mfa/vendor/webauthn-json.browser-ponyfill.js b/mfa/static/mfa/vendor/webauthn-json.browser-ponyfill.js

@@ -0,0 +1,243 @@
   -1     1 /*!
   -1     2  * @github/webauthn-json 2.1.1
   -1     3  * Copyright (c) 2019 GitHub, Inc.
   -1     4  *
   -1     5  * Permission is hereby granted, free of charge, to any person obtaining a copy
   -1     6  * of this software and associated documentation files (the "Software"), to
   -1     7  * deal in the Software without restriction, including without limitation the
   -1     8  * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
   -1     9  * sell copies of the Software, and to permit persons to whom the Software is
   -1    10  * furnished to do so, subject to the following conditions:
   -1    11  *
   -1    12  * The above copyright notice and this permission notice shall be included in
   -1    13  * all copies or substantial portions of the Software.
   -1    14  *
   -1    15  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
   -1    16  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
   -1    17  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
   -1    18  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
   -1    19  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
   -1    20  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
   -1    21  * IN THE SOFTWARE.
   -1    22  */
   -1    23 
   -1    24 // src/webauthn-json/base64url.ts
   -1    25 function base64urlToBuffer(baseurl64String) {
   -1    26   const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4);
   -1    27   const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding;
   -1    28   const str = atob(base64String);
   -1    29   const buffer = new ArrayBuffer(str.length);
   -1    30   const byteView = new Uint8Array(buffer);
   -1    31   for (let i = 0; i < str.length; i++) {
   -1    32     byteView[i] = str.charCodeAt(i);
   -1    33   }
   -1    34   return buffer;
   -1    35 }
   -1    36 function bufferToBase64url(buffer) {
   -1    37   const byteView = new Uint8Array(buffer);
   -1    38   let str = "";
   -1    39   for (const charCode of byteView) {
   -1    40     str += String.fromCharCode(charCode);
   -1    41   }
   -1    42   const base64String = btoa(str);
   -1    43   const base64urlString = base64String.replace(/\+/g, "-").replace(
   -1    44     /\//g,
   -1    45     "_"
   -1    46   ).replace(/=/g, "");
   -1    47   return base64urlString;
   -1    48 }
   -1    49 
   -1    50 // src/webauthn-json/convert.ts
   -1    51 var copyValue = "copy";
   -1    52 var convertValue = "convert";
   -1    53 function convert(conversionFn, schema, input) {
   -1    54   if (schema === copyValue) {
   -1    55     return input;
   -1    56   }
   -1    57   if (schema === convertValue) {
   -1    58     return conversionFn(input);
   -1    59   }
   -1    60   if (schema instanceof Array) {
   -1    61     return input.map((v) => convert(conversionFn, schema[0], v));
   -1    62   }
   -1    63   if (schema instanceof Object) {
   -1    64     const output = {};
   -1    65     for (const [key, schemaField] of Object.entries(schema)) {
   -1    66       if (schemaField.derive) {
   -1    67         const v = schemaField.derive(input);
   -1    68         if (v !== void 0) {
   -1    69           input[key] = v;
   -1    70         }
   -1    71       }
   -1    72       if (!(key in input)) {
   -1    73         if (schemaField.required) {
   -1    74           throw new Error(`Missing key: ${key}`);
   -1    75         }
   -1    76         continue;
   -1    77       }
   -1    78       if (input[key] == null) {
   -1    79         output[key] = null;
   -1    80         continue;
   -1    81       }
   -1    82       output[key] = convert(
   -1    83         conversionFn,
   -1    84         schemaField.schema,
   -1    85         input[key]
   -1    86       );
   -1    87     }
   -1    88     return output;
   -1    89   }
   -1    90 }
   -1    91 function derived(schema, derive) {
   -1    92   return {
   -1    93     required: true,
   -1    94     schema,
   -1    95     derive
   -1    96   };
   -1    97 }
   -1    98 function required(schema) {
   -1    99   return {
   -1   100     required: true,
   -1   101     schema
   -1   102   };
   -1   103 }
   -1   104 function optional(schema) {
   -1   105   return {
   -1   106     required: false,
   -1   107     schema
   -1   108   };
   -1   109 }
   -1   110 
   -1   111 // src/webauthn-json/basic/schema.ts
   -1   112 var publicKeyCredentialDescriptorSchema = {
   -1   113   type: required(copyValue),
   -1   114   id: required(convertValue),
   -1   115   transports: optional(copyValue)
   -1   116 };
   -1   117 var simplifiedExtensionsSchema = {
   -1   118   appid: optional(copyValue),
   -1   119   appidExclude: optional(copyValue),
   -1   120   credProps: optional(copyValue)
   -1   121 };
   -1   122 var simplifiedClientExtensionResultsSchema = {
   -1   123   appid: optional(copyValue),
   -1   124   appidExclude: optional(copyValue),
   -1   125   credProps: optional(copyValue)
   -1   126 };
   -1   127 var credentialCreationOptions = {
   -1   128   publicKey: required({
   -1   129     rp: required(copyValue),
   -1   130     user: required({
   -1   131       id: required(convertValue),
   -1   132       name: required(copyValue),
   -1   133       displayName: required(copyValue)
   -1   134     }),
   -1   135     challenge: required(convertValue),
   -1   136     pubKeyCredParams: required(copyValue),
   -1   137     timeout: optional(copyValue),
   -1   138     excludeCredentials: optional([publicKeyCredentialDescriptorSchema]),
   -1   139     authenticatorSelection: optional(copyValue),
   -1   140     attestation: optional(copyValue),
   -1   141     extensions: optional(simplifiedExtensionsSchema)
   -1   142   }),
   -1   143   signal: optional(copyValue)
   -1   144 };
   -1   145 var publicKeyCredentialWithAttestation = {
   -1   146   type: required(copyValue),
   -1   147   id: required(copyValue),
   -1   148   rawId: required(convertValue),
   -1   149   authenticatorAttachment: optional(copyValue),
   -1   150   response: required({
   -1   151     clientDataJSON: required(convertValue),
   -1   152     attestationObject: required(convertValue),
   -1   153     transports: derived(
   -1   154       copyValue,
   -1   155       (response) => {
   -1   156         var _a;
   -1   157         return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || [];
   -1   158       }
   -1   159     )
   -1   160   }),
   -1   161   clientExtensionResults: derived(
   -1   162     simplifiedClientExtensionResultsSchema,
   -1   163     (pkc) => pkc.getClientExtensionResults()
   -1   164   )
   -1   165 };
   -1   166 var credentialRequestOptions = {
   -1   167   mediation: optional(copyValue),
   -1   168   publicKey: required({
   -1   169     challenge: required(convertValue),
   -1   170     timeout: optional(copyValue),
   -1   171     rpId: optional(copyValue),
   -1   172     allowCredentials: optional([publicKeyCredentialDescriptorSchema]),
   -1   173     userVerification: optional(copyValue),
   -1   174     extensions: optional(simplifiedExtensionsSchema)
   -1   175   }),
   -1   176   signal: optional(copyValue)
   -1   177 };
   -1   178 var publicKeyCredentialWithAssertion = {
   -1   179   type: required(copyValue),
   -1   180   id: required(copyValue),
   -1   181   rawId: required(convertValue),
   -1   182   authenticatorAttachment: optional(copyValue),
   -1   183   response: required({
   -1   184     clientDataJSON: required(convertValue),
   -1   185     authenticatorData: required(convertValue),
   -1   186     signature: required(convertValue),
   -1   187     userHandle: required(convertValue)
   -1   188   }),
   -1   189   clientExtensionResults: derived(
   -1   190     simplifiedClientExtensionResultsSchema,
   -1   191     (pkc) => pkc.getClientExtensionResults()
   -1   192   )
   -1   193 };
   -1   194 
   -1   195 // src/webauthn-json/basic/api.ts
   -1   196 function createRequestFromJSON(requestJSON) {
   -1   197   return convert(base64urlToBuffer, credentialCreationOptions, requestJSON);
   -1   198 }
   -1   199 function createResponseToJSON(credential) {
   -1   200   return convert(
   -1   201     bufferToBase64url,
   -1   202     publicKeyCredentialWithAttestation,
   -1   203     credential
   -1   204   );
   -1   205 }
   -1   206 function getRequestFromJSON(requestJSON) {
   -1   207   return convert(base64urlToBuffer, credentialRequestOptions, requestJSON);
   -1   208 }
   -1   209 function getResponseToJSON(credential) {
   -1   210   return convert(
   -1   211     bufferToBase64url,
   -1   212     publicKeyCredentialWithAssertion,
   -1   213     credential
   -1   214   );
   -1   215 }
   -1   216 
   -1   217 // src/webauthn-json/basic/supported.ts
   -1   218 function supported() {
   -1   219   return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential);
   -1   220 }
   -1   221 
   -1   222 // src/webauthn-json/browser-ponyfill.ts
   -1   223 async function create(options) {
   -1   224   const response = await navigator.credentials.create(
   -1   225     options
   -1   226   );
   -1   227   response.toJSON = () => createResponseToJSON(response);
   -1   228   return response;
   -1   229 }
   -1   230 async function get(options) {
   -1   231   const response = await navigator.credentials.get(
   -1   232     options
   -1   233   );
   -1   234   response.toJSON = () => getResponseToJSON(response);
   -1   235   return response;
   -1   236 }
   -1   237 export {
   -1   238   create,
   -1   239   get,
   -1   240   createRequestFromJSON as parseCreationOptionsFromJSON,
   -1   241   getRequestFromJSON as parseRequestOptionsFromJSON,
   -1   242   supported
   -1   243 };

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    -1 <script src="{% static 'mfa/fido2.js' %}"></script>
   -1    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    -1 <script src="{% static 'mfa/fido2.js' %}"></script>
   -1    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),