From aee0261b737fc2efca8b8a43f3d5d5e8f349005d Mon Sep 17 00:00:00 2001 From: Arie Peterson <arie@greenhost.nl> Date: Tue, 13 Feb 2024 16:40:43 +0100 Subject: [PATCH] Implementing WebAuthn registration and login --- backend/areas/users/user_service.py | 6 ++- backend/areas/users/users.py | 14 ++++-- backend/cliapp/cliapp/cli.py | 26 ++++++++--- backend/web/login/login.py | 4 +- backend/web/static/base.js | 67 ++++++++++++++++++++--------- backend/web/templates/base.html | 3 +- backend/web/templates/login.html | 1 + backend/web/templates/settings.html | 8 +++- 8 files changed, 94 insertions(+), 35 deletions(-) diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py index 8b7270d8..8e28a133 100644 --- a/backend/areas/users/user_service.py +++ b/backend/areas/users/user_service.py @@ -112,9 +112,13 @@ class UserService: return UserService.get_user(res["id"]) @staticmethod - def reset_2fa(id): + def reset_totp(id): KratosApi.delete("/admin/identities/{}/credentials/totp".format(id)) + @staticmethod + def reset_webauthn(id): + KratosApi.delete("/admin/identities/{}/credentials/webauthn".format(id)) + @staticmethod def __start_recovery_flow(email): diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py index 9427a1d1..5ed0e050 100644 --- a/backend/areas/users/users.py +++ b/backend/areas/users/users.py @@ -36,12 +36,20 @@ def get_user_recovery(id): res = UserService.create_recovery_link(id) return jsonify(res) -@api_v1.route("/users/<string:id>/reset_2fa", methods=["POST"]) +@api_v1.route("/users/<string:id>/reset_totp", methods=["POST"]) @jwt_required() @cross_origin() @admin_required() -def reset_2fa(id): - res = UserService.reset_2fa(id) +def reset_totp(id): + res = UserService.reset_totp(id) + return jsonify(res) + +@api_v1.route("/users/<string:id>/reset_webauthn", methods=["POST"]) +@jwt_required() +@cross_origin() +@admin_required() +def reset_webauthn(id): + res = UserService.reset_webauthn(id) return jsonify(res) # This is supposed to be called by Kratos as a webhook after a user has diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py index 542c8593..868006fe 100644 --- a/backend/cliapp/cliapp/cli.py +++ b/backend/cliapp/cliapp/cli.py @@ -390,9 +390,7 @@ def recover_user(email): """Get recovery link for a user, to manual update the user/use :param email: Email address of the user """ - current_app.logger.info(f"Trying to send recover email for user: {email}") - try: # Get the ID of the user kratos_user = KratosUser.find_by_email(kratos_identity_api, email) @@ -405,9 +403,25 @@ def recover_user(email): current_app.logger.error(f"Error while getting reset link: {error}") -@user_cli.command("reset_2fa") +@user_cli.command("reset_totp") +@click.argument("email") +def reset_totp(email): + """Remove configured totp second factor for a user. + :param email: Email address of the user + """ + current_app.logger.info(f"Removing totp second factor for user: {email}") + try: + # Get the ID of the user + kratos_user = KratosUser.find_by_email(kratos_identity_api, email) + # Get a recovery URL + UserService.reset_totp(kratos_user.uuid) + except Exception as error: # pylint: disable=broad-except + current_app.logger.error(f"Error while removing totp second factor: {error}") + + +@user_cli.command("reset_webauthn") @click.argument("email") -def reset_2fa(email): +def reset_webauthn(email): """Remove configured second factor for a user. :param email: Email address of the user """ @@ -418,9 +432,9 @@ def reset_2fa(email): # Get the ID of the user kratos_user = KratosUser.find_by_email(kratos_identity_api, email) # Get a recovery URL - UserService.reset_2fa(kratos_user.uuid) + UserService.reset_webauthn(kratos_user.uuid) except Exception as error: # pylint: disable=broad-except - current_app.logger.error(f"Error while removing second factor: {error}") + current_app.logger.error(f"Error while removing webauthn second factor: {error}") cli.cli.add_command(user_cli) diff --git a/backend/web/login/login.py b/backend/web/login/login.py index 6198d851..2baac66f 100644 --- a/backend/web/login/login.py +++ b/backend/web/login/login.py @@ -138,7 +138,7 @@ def login(): # but we're already on the login page so there's no use for that here -- # they'd be redirected by Kratos back to this same login page anyway, # creating a redirect loop. Chances are that if `auth_response` is not - # None, we're actually serving or processing the TOTP form here. + # None, we're actually serving or processing the TOTP/WebAuthn form here. # List to contain messages pushed to the frontend messages = list() @@ -213,7 +213,7 @@ def login(): # `identity and refresh` # User is already logged in, but "refresh" is specified, meaning that # we should ask the user to authenticate again. This is necessary when - # you want to change protected fields (password, TOTP) in the + # you want to change protected fields (password, 2FA) in the # self-service settings, and your session is too old. # or `not identity` # User is not logged in yet. diff --git a/backend/web/static/base.js b/backend/web/static/base.js index 510d0c23..129a1e7f 100644 --- a/backend/web/static/base.js +++ b/backend/web/static/base.js @@ -98,10 +98,10 @@ function flow_login() { // Render login form (group: password) var form_html = render_form(data, group, "login"); $("#contentLogin_" + group).html(form_html); - // Hide the recovery link on the TOTP entry - // form. It's not really useful at that point, and - // you get a redirect loop if you use it. - if (group == 'totp') { + // Hide the recovery link on the 2FA entry + // forms. It's not really useful at that point, and + // you may get a redirect loop if you use it. + if (group == 'totp' || group == 'webauthn') { $("#recoveryLink").hide(); } } @@ -156,8 +156,8 @@ function flow_settings_validate() { } // Render the settings flow, this is where users can change their personal -// settings, like name, password and totp (second factor). The form contents -// are defined by Kratos. +// settings, like name, password and second factors (2FA, WebAuthn). The form +// contents are defined by Kratos. function flow_settings() { // Get the details from the current flow from kratos var flow = $.urlParam("flow"); @@ -184,12 +184,13 @@ function flow_settings() { if (state == "recovery") { $("#contentProfile").hide(); $("#contentTotp").hide(); + $("#contentWebAuthn").hide(); } // Render messages given from the API render_messages(data); - // Render the forms (password, profile, totp) based on the fields we got + // Render the settings forms based on the fields we got // from the API. var html = render_form(data, "password", "settings"); $("#pills-password").html(html); @@ -200,6 +201,9 @@ function flow_settings() { html = render_form(data, "totp", "settings"); $("#pills-totp").html(html); + html = render_form(data, "webauthn", "settings"); + $("#pills-webauthn").html(html); + // If the submit button is hit, execute the POST with Ajax. $("#formpassword").submit(function (e) { // avoid to execute the actual submit of the form. @@ -397,6 +401,11 @@ function render_messages(data) { function getFormElement(node, context) { console.log("Getting form element", node); + if (node.attributes.node_type == "script") { + window.console.log("Skipping because node_type is script"); + return ''; + } + if (node.type == "img") { return ( ` @@ -481,18 +490,6 @@ function getFormElement(node, context) { ); } - if (name == "identifier") { - return getFormInput( - "email", - name, - value, - "E-mail address", - "Please provide your e-mail address to log in", - null, - messages - ); - } - if (name == "password") { return getFormInput( "password", @@ -519,6 +516,18 @@ function getFormElement(node, context) { ); } + if (name == "identifier") { + return getFormInput( + "email", + name, + value, + "E-mail address", + "Please provide your e-mail address to log in", + null, + messages + ); + } + if (name == "totp_code") { return getFormInput( "totp_code", @@ -533,6 +542,21 @@ function getFormElement(node, context) { ); } + if (type == "button") { + var label = node.meta.label.text || "Unknown"; + // if (name == "webauthn_login_trigger") { + // label = "Confirm with WebAuthn"; + // } + const oc = node.attributes.onclick; + return ( + `<div class="form-group flex justify-end py-2"> + <button type="button" name="` + name + `" onclick='` + oc + `' class="inline-flex h-10 items-center px-2.5 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">` + + label + + `</button> + </div>` + ); + } + if (type == "submit") { var label = "Save"; if (name == "totp_unlink") { @@ -554,6 +578,9 @@ function getFormElement(node, context) { if (context == "recovery") { label = "Send recovery link"; } + if (name == "webauthn_remove") { + label = node.meta.label.text; + } return ( `<div class="form-group flex justify-end py-2"> <input type="hidden" name="` + @@ -568,7 +595,7 @@ function getFormElement(node, context) { ); } - return getFormInput("input", name, value, name, null, null, messages); + return getFormInput("input", name, value, node.meta.label.text || name, null, null, messages); } // Usually called by getFormElement, generic function to generate an diff --git a/backend/web/templates/base.html b/backend/web/templates/base.html index 178fa584..bcf15f82 100644 --- a/backend/web/templates/base.html +++ b/backend/web/templates/base.html @@ -12,6 +12,7 @@ {% if demo %} <script src="static/js/demo.js"></script> {% endif %} + <script src="/kratos/.well-known/ory/webauthn.js"></script> <title>Your Stackspin Account</title> </html> @@ -28,7 +29,7 @@ <div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8"> <div - class="mx-auto w-full h-full md:w-2/3 md:max-w-lg bg-white shadow rounded-lg space-y-8 mb-4 md:mb-0 p-8 pt-0" + class="mx-auto w-full h-full md:w-2/3 md:max-w-xl bg-white shadow rounded-lg space-y-8 mb-4 md:mb-0 p-8 pt-0" > <div id="contentFlowExpired" diff --git a/backend/web/templates/login.html b/backend/web/templates/login.html index 3c623bf7..281e2af6 100644 --- a/backend/web/templates/login.html +++ b/backend/web/templates/login.html @@ -82,6 +82,7 @@ <div id="contentMessages"></div> <div id="contentLogin_password"></div> <div id="contentLogin_totp"></div> +<div id="contentLogin_webauthn"></div> <footer class="font-medium text-primary-600 mt-0 pt-2 border-t-gray-50 border-t-2 flex justify-end" > diff --git a/backend/web/templates/settings.html b/backend/web/templates/settings.html index 1e70c0f7..59b14496 100644 --- a/backend/web/templates/settings.html +++ b/backend/web/templates/settings.html @@ -23,17 +23,21 @@ <a class="nav-link" id="pills-password-tab" data-toggle="pill" href="#pills-password" role="tab" aria-controls="pills-password" aria-selected="false">Change password</a> <a class="nav-link" id="pills-totp-tab" data-toggle="pill" href="#pills-totp" role="tab" aria-controls="pills-totp" - aria-selected="false">2nd factor authentication</a> + aria-selected="false">2nd factor: TOTP</a> + <a class="nav-link" id="pills-webauthn-tab" data-toggle="pill" href="#pills-webauthn" role="tab" aria-controls="pills-webauthn" + aria-selected="false">2nd factor: WebAuthn</a> </div> <div class="tab-content" id="pills-tabContent"> <div class="tab-pane fade show active" id="pills-profile" role="tabpanel" aria-labelledby="pills-profile-tab">... </div> <div class="tab-pane fade" id="pills-password" role="tabpanel" aria-labelledby="pills-password-tab">...</div> <div class="tab-pane fade" id="pills-totp" role="tabpanel" aria-labelledby="pills-totp-tab">...</div> + <div class="tab-pane fade" id="pills-webauthn" role="tabpanel" aria-labelledby="pills-webauthn-tab">...</div> </div> <div id="contentProfile"></div> <div id="contentPassword"></div> <div id="contentTotp"></div> +<div id="contentWebauthn"></div> -{% endblock %} \ No newline at end of file +{% endblock %} -- GitLab