diff --git a/src/appier_extras/parts/admin/models/account.py b/src/appier_extras/parts/admin/models/account.py index fe995958..e0c52af8 100644 --- a/src/appier_extras/parts/admin/models/account.py +++ b/src/appier_extras/parts/admin/models/account.py @@ -68,6 +68,8 @@ class Account(base.Base, authenticable.Authenticable): confirmation_token = appier.field(safe=True, private=True, meta="secret") + fido2_enabled = appier.field(type=bool, description="FIDO2 Enabled") + otp_enabled = appier.field(type=bool, description="OTP Enabled") otp_secret = appier.field( @@ -932,6 +934,32 @@ def remove_role_s(self, name): self.roles_l.remove(_role) self.save() + @appier.operation( + name="Add Credential", + parameters=( + ("Credential ID", "credential_id", str), + ("Credential Data", "credential_data", str), + ), + ) + def add_credential_s(self, credential_id, credential_data): + from . import credential + + _credential = credential.Credential( + credential_id=credential_id, credential_data=credential_data, account=self + ) + _credential.save() + self.fido2_enabled = True + self.save() + + @appier.operation( + name="Remove Credential", parameters=(("Credential ID", "credential_id", str),) + ) + def remove_credential_s(self, credential_id): + from . import credential + + _credential = credential.Credential.get(credential_id=credential_id) + _credential.delete() + @appier.operation(name="Fix Roles", level=2) def fix_children_s(self): self.roles = [role for role in self.roles if role and hasattr(role, "tokens_a")] @@ -1047,6 +1075,8 @@ def two_factor_enabled(self): @property def two_factor_method(self): + if self.fido2_enabled: + return "fido2" if self.otp_enabled: return "otp" return None @@ -1060,6 +1090,12 @@ def otp_uri(self): self.otp_secret, ) + @property + def credentials_data(self): + from . import credential + + return credential.Credential.credentials_data_account(self) + def _set_session(self, unset=True, safes=[], method="set", two_factor=True): cls = self.__class__ if unset: diff --git a/src/appier_extras/parts/admin/models/credential.py b/src/appier_extras/parts/admin/models/credential.py index 2709b8b4..007e6ff0 100644 --- a/src/appier_extras/parts/admin/models/credential.py +++ b/src/appier_extras/parts/admin/models/credential.py @@ -1,68 +1,86 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Hive Appier Framework -# Copyright (c) 2008-2024 Hive Solutions Lda. -# -# This file is part of Hive Appier Framework. -# -# Hive Appier Framework is free software: you can redistribute it and/or modify -# it under the terms of the Apache License as published by the Apache -# Foundation, either version 2.0 of the License, or (at your option) any -# later version. -# -# Hive Appier Framework is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# Apache License for more details. -# -# You should have received a copy of the Apache License along with -# Hive Appier Framework. If not, see . - -__author__ = "João Magalhães " -""" The author(s) of the module """ - -__copyright__ = "Copyright (c) 2008-2024 Hive Solutions Lda." -""" The copyright for the module """ - -__license__ = "Apache License, Version 2.0" -""" The license for the module """ - -import appier - -from appier_extras.parts.admin.models import base - - -class Credential(base.Base): - - credential_id = appier.field( - index=True, immutable=True, description="Credential ID" - ) - - credential_data = appier.field(immutable=True) - - account = appier.field(type=appier.reference("Account", name="id"), immutable=True) - - @classmethod - def validate(cls): - return super(Credential, cls).validate() + [ - appier.not_null("credential_id"), - appier.not_empty("credential_id"), - appier.not_null("credential_data"), - appier.not_empty("credential_data"), - ] - - @classmethod - def list_names(cls): - return ["credential_id", "description", "account"] - - def post_create(self): - base.Base.post_create(self) - account = self.account.reload() - if not self in account.credentials: - account.credentials.append(self) - account.save() - - @classmethod - def get_by_credential_id(cls, credential_id, *args, **kwargs): - return cls.get(credential_id=credential_id, eager=("account",), *args, **kwargs) +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Hive Appier Framework +# Copyright (c) 2008-2024 Hive Solutions Lda. +# +# This file is part of Hive Appier Framework. +# +# Hive Appier Framework is free software: you can redistribute it and/or modify +# it under the terms of the Apache License as published by the Apache +# Foundation, either version 2.0 of the License, or (at your option) any +# later version. +# +# Hive Appier Framework is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# Apache License for more details. +# +# You should have received a copy of the Apache License along with +# Hive Appier Framework. If not, see . + +__author__ = "João Magalhães " +""" The author(s) of the module """ + +__copyright__ = "Copyright (c) 2008-2024 Hive Solutions Lda." +""" The copyright for the module """ + +__license__ = "Apache License, Version 2.0" +""" The license for the module """ + +import base64 + +import appier + +from appier_extras.parts.admin.models import base + + +class Credential(base.Base): + + credential_id = appier.field( + index=True, immutable=True, description="Credential ID" + ) + + credential_data = appier.field(immutable=True) + + account = appier.field(type=appier.reference("Account", name="id"), immutable=True) + + @classmethod + def validate(cls): + return super(Credential, cls).validate() + [ + appier.not_null("credential_id"), + appier.not_empty("credential_id"), + appier.not_null("credential_data"), + appier.not_empty("credential_data"), + ] + + @classmethod + def list_names(cls): + return ["credential_id", "description", "account"] + + def post_create(self): + base.Base.post_create(self) + account = self.account.reload() + if not self in account.credentials: + account.credentials.append(self) + account.save() + + def post_delete(self): + base.Base.post_create(self) + account = self.account.reload() + account.save() + + @classmethod + def get_by_credential_id(cls, credential_id, *args, **kwargs): + return cls.get(credential_id=credential_id, eager=("account",), *args, **kwargs) + + @classmethod + def find_by_account(cls, account, *args, **kwargs): + return cls.find(account=account.id, *args, **kwargs) + + @classmethod + def credentials_data_account(cls, account, *args, **kwargs): + credentials = cls.find_by_account(account=account, *args, **kwargs) + return [ + base64.b64decode(credential.credential_data) for credential in credentials + ] diff --git a/src/appier_extras/parts/admin/part.py b/src/appier_extras/parts/admin/part.py index 3fbf5adf..3a627f48 100644 --- a/src/appier_extras/parts/admin/part.py +++ b/src/appier_extras/parts/admin/part.py @@ -121,6 +121,8 @@ def load(self): self.owner.two_factor_route_admin = "admin.two_factor" self.owner.otp_route = "admin.otp" self.owner.otp_route_admin = "admin.otp" + self.owner.fido2_route = "admin.fido2" + self.owner.fido2_route_admin = "admin.fido2" self.owner.admin_account = self.account_c self.owner.admin_role = self.role_c self.owner.admin_login_redirect = "admin.index" @@ -681,6 +683,10 @@ def two_factor(self): next = self.field("next") error = self.field("error") two_factor_method = self.session["2fa.method"] + if two_factor_method == "fido2": + return self.redirect( + self.url_for(self.owner.fido2_route_admin, next=next, error=error) + ) if two_factor_method == "otp": return self.redirect( self.url_for(self.owner.otp_route_admin, next=next, error=error) @@ -740,27 +746,67 @@ def fido2(self): next = self.field("next") error = self.field("error") + # @TODO: think if this belongs here + username = self.session["2fa.username"] + account = self.account_c.get(username=username) + + if not account.enabled: + raise appier.OperationalError(message="Account is not enabled", code=403) + + import fido2.webauthn + + credentials_data = [ + fido2.webauthn.AttestedCredentialData(credential_data) + for credential_data in account.credentials_data + ] + # @TODO this must be moved into the Account model fido2_server = self._get_fido2_server() + auth_data, state = fido2_server.authenticate_begin(credentials_data) + state_json = json.dumps(state) + self.session["state"] = state_json - return self.template("fido2.html.tpl", next=next, error=error) + auth_data_d = dict(auth_data) + auth_data_json = json.dumps(auth_data_d, cls=utils.BytesEncoder) + + return self.template( + "fido2.html.tpl", next=next, error=error, auth_data=auth_data_json + ) def fido2_login(self): + next = self.field("next") + response = self.field("response") + state_json = self.session["state"] + + response_data = json.loads(response) + state = json.loads(state_json) + username = self.session["2fa.username"] + account = self.account_c.get(username=username) - fido2_server = appier.import_pip("fido2_server") + if not account.enabled: + raise appier.OperationalError(message="Account is not enabled", code=403) - # @TODO: this must be moved to the account model - registration_data, state = fido2_server.register_begin( - dict( - id=username.encode("utf-8"), - name=username, - displayName=username, - ), - user_verification="discouraged", - ) + import fido2.webauthn - self.session["2fa.username"] = username + credentials_data = [ + fido2.webauthn.AttestedCredentialData(credential_data) + for credential_data in account.credentials_data + ] + + # @TODO this must be moved into the Account model + fido2_server = self._get_fido2_server() + + fido2_server.authenticate_complete(state, credentials_data, response_data) + + # updates the current session with the proper + # values to correctly authenticate the user + account.touch_login_s() + account._set_account() + + # redirects the current operation to the next URL or in + # alternative to the root index of the administration + return self.redirect(next or self.url_for(self.owner.admin_login_redirect)) def fido2_register(self): next = self.field("next") @@ -780,12 +826,11 @@ def fido2_register(self): user_verification="discouraged", ) state_json = json.dumps(state) + self.session["state"] = state_json registration_data_d = dict(registration_data) registration_data_json = json.dumps(registration_data_d, cls=utils.BytesEncoder) - self.session["state"] = state_json - return self.template( "fido2_register.html.tpl", next=next, @@ -807,14 +852,17 @@ def fido2_register_do(self): # converts the credential data into a Base64 encoded string # the strings is structured in the WebAuthn standard format + credential_id_b64 = base64.b64encode( + auth_data.credential_data.credential_id + ).decode("utf-8") credential_data_b64 = base64.b64encode(auth_data.credential_data).decode( "utf-8" ) - # prints the information that should be save in the - # account for proper FIDO2 authentication - print(credential_data_b64) - print(auth_data.counter) + # adds the new credential to the account effectively enabling + # FIDO2 based authentication for the account + account = self.account_c.from_session(meta=True) + account.add_credential_s(credential_id_b64, credential_data_b64) # redirects the current operation to the next URL or in # alternative to the root index of the administration diff --git a/src/appier_extras/parts/admin/static/css/layout.css b/src/appier_extras/parts/admin/static/css/layout.css index 1a7a87c5..735e3d46 100644 --- a/src/appier_extras/parts/admin/static/css/layout.css +++ b/src/appier_extras/parts/admin/static/css/layout.css @@ -424,6 +424,10 @@ body.mobile-s .pages > .page.other { margin: 0px auto 0px auto; } -.fido2 { +.fido2-register { + display: none; +} + +.fido2-auth { display: none; } diff --git a/src/appier_extras/parts/admin/static/js/main.js b/src/appier_extras/parts/admin/static/js/main.js index 7aa11620..c5a15143 100644 --- a/src/appier_extras/parts/admin/static/js/main.js +++ b/src/appier_extras/parts/admin/static/js/main.js @@ -33,10 +33,15 @@ var message = jQuery(".header-message", matchedObject); message.umessage(); - // retrieves the reference to the FIDO2 element and registers it - // for the base FIDO2 plugin so that it's properly initialized - var fido2 = jQuery(".fido2", matchedObject); - fido2.ufido2(); + // retrieves the reference to the FIDO2 Auth element and registers it + // for the base FIDO2 Auth plugin so that it's properly initialized + var fido2 = jQuery(".fido2-auth", matchedObject); + fido2.ufido2auth(); + + // retrieves the reference to the FIDO2 Register element and registers it + // for the base FIDO2 Register plugin so that it's properly initialized + var fido2 = jQuery(".fido2-register", matchedObject); + fido2.ufido2register(); }; })(jQuery); @@ -68,19 +73,43 @@ })(jQuery); (function(jQuery) { - jQuery.fn.ufido2 = function(options) { + jQuery.fn.ufido2auth = function(options) { var matchedObject = this; matchedObject.each(function(index, element) { var _element = jQuery(element); var form = _element.parents("form"); - var contents = JSON.parse(_element.text()); + var authData = JSON.parse(_element.text()); - //@TODO: need to soft-code this making it more flexible - contents.publicKey.user.id = base64ToUint8Array(contents.publicKey.user.id); - contents.publicKey.challenge = base64ToUint8Array(contents.publicKey.challenge); + authData.publicKey.challenge = base64ToUint8Array(authData.publicKey.challenge); + authData.publicKey.allowCredentials.forEach(function(credential) { + credential.id = base64ToUint8Array(credential.id); + }); + + navigator.credentials.get(authData).then(function(response) { + var responseInput = jQuery("input[type=\"hidden\"][name=\"response\"]", + form); + var serializedResponse = serializePublicKeyCredential(response); + responseInput.uxvalue(JSON.stringify(serializedResponse)); + form.submit(); + }); + }); + }; +})(jQuery); + +(function(jQuery) { + jQuery.fn.ufido2register = function(options) { + var matchedObject = this; + matchedObject.each(function(index, element) { + var _element = jQuery(element); + var form = _element.parents("form"); + + var registrationData = JSON.parse(_element.text()); + + registrationData.publicKey.user.id = base64ToUint8Array(registrationData.publicKey.user.id); + registrationData.publicKey.challenge = base64ToUint8Array(registrationData.publicKey.challenge); - navigator.credentials.create(contents).then(function(credential) { + navigator.credentials.create(registrationData).then(function(credential) { var credentialInput = jQuery("input[type=\"hidden\"][name=\"credential\"]", form); var serializedCredential = serializePublicKeyCredential(credential); diff --git a/src/appier_extras/parts/admin/templates/fluid/fido2.html.tpl b/src/appier_extras/parts/admin/templates/fluid/fido2.html.tpl new file mode 100644 index 00000000..61089854 --- /dev/null +++ b/src/appier_extras/parts/admin/templates/fluid/fido2.html.tpl @@ -0,0 +1,21 @@ +{% extends "admin/admin.simple.html.tpl" %} +{% block title %}FIDO2{% endblock %} +{% block body_style %}{{ super() }} {% if background %}background:url({{ background }});{% endif %}{% endblock %} +{% block content %} + +{% endblock %} diff --git a/src/appier_extras/parts/admin/templates/fluid/fido2_register.html.tpl b/src/appier_extras/parts/admin/templates/fluid/fido2_register.html.tpl index b6480cfb..48c9d908 100644 --- a/src/appier_extras/parts/admin/templates/fluid/fido2_register.html.tpl +++ b/src/appier_extras/parts/admin/templates/fluid/fido2_register.html.tpl @@ -15,7 +15,7 @@
-
{{ registration_data }}
+
{{ registration_data }}
{% endblock %}