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