From 2babd52ce12aeafaafe6a29b57b42ca8ef336371 Mon Sep 17 00:00:00 2001
From: Arie Peterson <arie@greenhost.nl>
Date: Wed, 11 Jan 2023 14:18:44 +0100
Subject: [PATCH] Add TOTP 2FA

---
 backend/Dockerfile                            |  20 +--
 backend/app.py                                |   1 -
 backend/areas/apps/apps_service.py            |  27 ++--
 backend/areas/users/user_service.py           |  23 ++--
 backend/cliapp/cliapp/cli.py                  |  23 ++--
 backend/config.py                             |   1 +
 backend/helpers/kratos_user.py                |  28 ++--
 backend/requirements.txt                      |  10 +-
 backend/web/login/login.py                    | 125 +++++++++++------
 backend/web/static/base.js                    | 129 ++++++++++++++----
 backend/web/static/style.css                  |  24 +++-
 backend/web/templates/base.html               |   4 +-
 backend/web/templates/login.html              |   7 +-
 backend/web/templates/settings.html           |  13 +-
 .../helmchart/templates/configmaps.yaml       |   6 +-
 .../helmchart/values-local.yaml.example       |   4 +-
 deployment/helmchart/values.yaml              |   4 +-
 docker-compose.yml                            |   3 +
 frontend/Dockerfile                           |   7 +-
 frontend/local.env.example                    |   1 +
 frontend/src/components/Header/Header.tsx     |  25 +++-
 frontend/src/services/auth/redux/selectors.ts |   1 +
 run_app.sh                                    |   4 +-
 23 files changed, 334 insertions(+), 156 deletions(-)

diff --git a/backend/Dockerfile b/backend/Dockerfile
index c62abf74..67b11bb6 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,20 +1,24 @@
 FROM python:3.11-slim
 
-## make a local directory
-RUN mkdir /app
-
 # set "app" as the working directory from which CMD, RUN, ADD references
 WORKDIR /app
 
-# now copy all the files in this directory to /app
-COPY . .
-
+# First install apt packages, so we can cache this even if requirements.txt
+# changes.
 # hadolint ignore=DL3008
 RUN apt-get update \
   && apt-get install --no-install-recommends -y gcc g++ libffi-dev libc6-dev \
   && apt-get clean \
-  && rm -rf /var/lib/apt/lists/* \
-  && pip install --no-cache-dir -r requirements.txt
+  && rm -rf /var/lib/apt/lists/*
+
+# Now copy the python dependencies specification.
+COPY requirements.txt .
+
+# Install python dependencies.
+RUN pip install --no-cache-dir -r requirements.txt
+
+# now copy all the files in this directory to /app
+COPY . .
 
 # Listen to port 80 at runtime
 EXPOSE 5000
diff --git a/backend/app.py b/backend/app.py
index 9d5aec62..76dddf1a 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -65,7 +65,6 @@ db.init_app(app)
 
 
 app.logger.setLevel(logging.INFO)
-app.logger.info("Starting dashboard backend.")
 
 app.register_blueprint(api_v1)
 app.register_blueprint(web)
diff --git a/backend/areas/apps/apps_service.py b/backend/areas/apps/apps_service.py
index 289529de..9afd6cfa 100644
--- a/backend/areas/apps/apps_service.py
+++ b/backend/areas/apps/apps_service.py
@@ -1,7 +1,7 @@
 from flask import current_app
 from flask_jwt_extended import get_jwt
 import ory_kratos_client
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.api import identity_api
 
 from .models import App, AppRole
 from config import *
@@ -19,18 +19,19 @@ class AppsService:
         apps = App.query.all()
 
         kratos_admin_api_configuration = ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-        KRATOS_ADMIN = kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
-
-        user_id = get_jwt()['user_id']
-        current_app.logger.info(f"user_id: {user_id}")
-        # Get the related user object
-        current_app.logger.info(f"Info: Getting user from admin {user_id}")
-        user = KratosUser(KRATOS_ADMIN, user_id)
-        if not user:
-            current_app.logger.error(f"User not found in database: {user_id}")
-            return []
-
-        return [app.to_dict() for app in apps if user_has_access(user, app)]
+        with ory_kratos_client.ApiClient(kratos_admin_api_configuration) as kratos_client:
+            kratos_identity_api = identity_api.IdentityApi(kratos_client)
+
+            user_id = get_jwt()['user_id']
+            current_app.logger.info(f"user_id: {user_id}")
+            # Get the related user object
+            current_app.logger.info(f"Info: Getting user from admin {user_id}")
+            user = KratosUser(kratos_identity_api, user_id)
+            if not user:
+                current_app.logger.error(f"User not found in database: {user_id}")
+                return []
+
+            return [app.to_dict() for app in apps if user_has_access(user, app)]
 
     @staticmethod
     def get_app(slug):
diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py
index ec94c44c..a5869261 100644
--- a/backend/areas/users/user_service.py
+++ b/backend/areas/users/user_service.py
@@ -1,7 +1,7 @@
 import ory_kratos_client
-from ory_kratos_client.model.submit_self_service_recovery_flow_body \
-    import SubmitSelfServiceRecoveryFlowBody
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.model.update_recovery_flow_body \
+    import UpdateRecoveryFlowBody
+from ory_kratos_client.api import identity_api
 from config import KRATOS_ADMIN_URL
 
 from database import db
@@ -15,8 +15,8 @@ from helpers.error_handler import KratosError
 
 kratos_admin_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-KRATOS_ADMIN = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
+kratos_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+kratos_identity_api = identity_api.IdentityApi(kratos_client)
 
 class UserService:
     @staticmethod
@@ -68,6 +68,8 @@ class UserService:
                 db.session.add(app_role)
             db.session.commit()
 
+        # We start a recovery flow immediately after creating the
+        # user, so the user can set their initial password.
         UserService.__start_recovery_flow(data["email"])
 
         return UserService.get_user(res["id"])
@@ -85,14 +87,13 @@ class UserService:
         :param email: Email to send recovery link to
         :type email: str
         """
-        api_response = KRATOS_ADMIN.initialize_self_service_recovery_flow_without_browser()
+        api_response = kratos_identity_api.create_native_recovery_flow()
         flow = api_response['id']
         # Submit the recovery flow to send an email to the new user.
-        submit_self_service_recovery_flow_body = \
-            SubmitSelfServiceRecoveryFlowBody(method="link", email=email)
-        api_response = KRATOS_ADMIN.submit_self_service_recovery_flow(flow,
-                submit_self_service_recovery_flow_body=
-                    submit_self_service_recovery_flow_body)
+        update_recovery_flow_body = \
+            UpdateRecoveryFlowBody(method="link", email=email)
+        api_response = kratos_identity_api.submit_self_service_recovery_flow(flow,
+                update_recovery_flow_body=update_recovery_flow_body)
 
     @staticmethod
     def put_user(id, user_editing_id, data):
diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py
index 3d2fedc6..751e58a2 100644
--- a/backend/cliapp/cliapp/cli.py
+++ b/backend/cliapp/cliapp/cli.py
@@ -7,11 +7,11 @@ the user entries in the database(s)"""
 import sys
 
 import click
-import hydra_client
+import ory_hydra_client
 import ory_kratos_client
 from flask import current_app
 from flask.cli import AppGroup
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.api import identity_api
 from sqlalchemy import func
 
 from config import HYDRA_ADMIN_URL, KRATOS_ADMIN_URL, KRATOS_PUBLIC_URL
@@ -22,21 +22,14 @@ from areas.apps import AppRole, App
 from database import db
 
 # APIs
-# Create HYDRA & KRATOS API interfaces
-HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL)
 
-# Kratos has an admin and public end-point. We create an API for them
-# both. The kratos implementation has bugs, which forces us to set
-# the discard_unknown_keys to True.
+# Kratos has an admin and public end-point. We create an API for the admin one.
+# The kratos implementation has bugs, which forces us to set the
+# discard_unknown_keys to True.
 kratos_admin_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-KRATOS_ADMIN = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
-
-kratos_public_api_configuration = \
-    ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
-KRATOS_PUBLIC = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_public_api_configuration))
+kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+kratos_identity_api = identity_api.IdentityApi(kratos_admin_client)
 
 ##############################################################################
 # CLI INTERFACE                                                              #
@@ -213,7 +206,7 @@ def setrole(email, app_slug, role):
     current_app.logger.info(f"Assigning role {role} to {email} for app {app_slug}")
 
     # Find user
-    user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+    user = KratosUser.find_by_email(kratos_identity_api, email)
 
     if role not in ("admin", "user"):
         print("At this point only the roles 'admin' and 'user' are accepted")
diff --git a/backend/config.py b/backend/config.py
index 2cb00177..1972c594 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -6,6 +6,7 @@ HYDRA_CLIENT_SECRET = os.environ.get("HYDRA_CLIENT_SECRET")
 HYDRA_AUTHORIZATION_BASE_URL = os.environ.get("HYDRA_AUTHORIZATION_BASE_URL")
 TOKEN_URL = os.environ.get("TOKEN_URL")
 
+DASHBOARD_URL = os.environ.get("DASHBOARD_URL")
 LOGIN_PANEL_URL = os.environ.get("LOGIN_PANEL_URL")
 
 HYDRA_PUBLIC_URL = os.environ.get("HYDRA_PUBLIC_URL")
diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py
index dee31b4c..d242b72c 100644
--- a/backend/helpers/kratos_user.py
+++ b/backend/helpers/kratos_user.py
@@ -9,10 +9,10 @@ import urllib.request
 from typing import Dict
 from urllib.request import Request
 
-from ory_kratos_client.model.admin_create_identity_body import AdminCreateIdentityBody
-from ory_kratos_client.model.admin_create_self_service_recovery_link_body \
-    import AdminCreateSelfServiceRecoveryLinkBody
-from ory_kratos_client.model.admin_update_identity_body import AdminUpdateIdentityBody
+from ory_kratos_client.model.create_identity_body import CreateIdentityBody
+from ory_kratos_client.model.create_recovery_link_for_identity_body \
+    import CreateRecoveryLinkForIdentityBody
+from ory_kratos_client.model.update_identity_body import UpdateIdentityBody
 from ory_kratos_client.rest import ApiException as KratosApiException
 
 from .classes import RedirectFilter
@@ -39,7 +39,7 @@ class KratosUser():
         self.state = 'active'
         if uuid:
             try:
-                obj = api.admin_get_identity(uuid)
+                obj = api.get_identity(uuid)
                 if obj:
                     self.__uuid = uuid
                     try:
@@ -82,26 +82,26 @@ class KratosUser():
 
         # If we have a UUID, we are updating
         if self.__uuid:
-            body = AdminUpdateIdentityBody(
+            body = UpdateIdentityBody(
                 schema_id="default",
                 state=self.state,
                 traits=traits,
             )
             try:
-                api_response = self.api.admin_update_identity(self.__uuid,
-                        admin_update_identity_body=body)
+                api_response = self.api.update_identity(self.__uuid,
+                        update_identity_body=body)
             except KratosApiException as error:
                 raise BackendError(f"Unable to save entry, kratos replied with:{error}") from error
         else:
 
-            body = AdminCreateIdentityBody(
+            body = CreateIdentityBody(
                 schema_id="default",
                 traits=traits,
             )
             try:
                 # Create an Identity
-                api_response = self.api.admin_create_identity(
-                        admin_create_identity_body=body)
+                api_response = self.api.create_identity(
+                        create_identity_body=body)
                 if api_response.id:
                     self.__uuid = api_response.id
             except KratosApiException as error:
@@ -200,14 +200,14 @@ class KratosUser():
 
         try:
             # Create body request to get recovery link with admin API
-            body = AdminCreateSelfServiceRecoveryLinkBody(
+            body = CreateRecoveryLinkForIdentityBody(
                 expires_in="15m",
                 identity_id=self.__uuid
             )
 
             # Get recovery link from admin API
-            call = self.api.admin_create_self_service_recovery_link(
-                admin_create_self_service_recovery_link_body=body)
+            call = self.api.create_recovery_link_for_identity(
+                create_recovery_link_for_identity_body=body)
 
             url = call.recovery_link
         except KratosApiException:
diff --git a/backend/requirements.txt b/backend/requirements.txt
index eae5bd29..44114da2 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,6 +9,8 @@ Flask==2.0.3
 Flask-Cors==3.0.10
 flask-expects-json==1.7.0
 Flask-JWT-Extended==4.3.1
+Flask-Migrate==4.0.1
+Flask-SQLAlchemy==2.5.1
 gunicorn==20.1.0
 idna==3.3
 install==1.3.5
@@ -20,10 +22,13 @@ kubernetes==24.2.0
 MarkupSafe==2.1.1
 mypy-extensions==0.4.3
 oauthlib==3.2.0
+ory-kratos-client==0.11.0
+ory-hydra-client==1.11.8
 pathspec==0.9.0
 platformdirs==2.5.1
 pycparser==2.21
 PyJWT==2.3.0
+pymysql==1.0.2
 pyrsistent==0.18.1
 regex==2022.3.15
 requests==2.27.1
@@ -33,8 +38,3 @@ tomli==1.2.3
 typing-extensions==4.1.1
 urllib3==1.26.8
 Werkzeug==2.0.3
-ory-kratos-client==0.9.0a2
-pymysql
-Flask-SQLAlchemy
-hydra-client
-Flask-Migrate
diff --git a/backend/web/login/login.py b/backend/web/login/login.py
index 94ee4c37..83c7d2f2 100644
--- a/backend/web/login/login.py
+++ b/backend/web/login/login.py
@@ -8,9 +8,15 @@ import urllib.parse
 import urllib.request
 import ast
 
-import hydra_client
+import ory_hydra_client
+# hydra v2
+# from ory_hydra_client.api import o_auth2_api
+from ory_hydra_client.api import admin_api
+from ory_hydra_client.models import AcceptConsentRequest, AcceptLoginRequest, ConsentRequestSession
+import ory_hydra_client.exceptions as hydra_exceptions
 import ory_kratos_client
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.api import identity_api
+from ory_kratos_client.api import frontend_api
 from flask import abort, redirect, render_template, request, current_app
 
 from database import db
@@ -26,20 +32,27 @@ from areas.roles import RoleService
 
 # APIs
 # Create HYDRA & KRATOS API interfaces
-HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL)
+hydra_admin_api_configuration = \
+    ory_hydra_client.Configuration(host=HYDRA_ADMIN_URL, discard_unknown_keys=True)
+hydra_client = ory_hydra_client.ApiClient(hydra_admin_api_configuration)
+# hydra v2
+# oauth2_api = o_auth2_api.OAuth2Api(hydra_client)
+hydra_admin_api = admin_api.AdminApi(hydra_client)
 
 # Kratos has an admin and public end-point. We create an API for them
 # both. The kratos implementation has bugs, which forces us to set
 # the discard_unknown_keys to True.
 kratos_admin_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-KRATOS_ADMIN = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
+kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+admin_identity_api = identity_api.IdentityApi(kratos_admin_client)
+admin_frontend_api = frontend_api.FrontendApi(kratos_admin_client)
 
 kratos_public_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
-KRATOS_PUBLIC = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_public_api_configuration))
+kratos_public_client = ory_kratos_client.ApiClient(kratos_public_api_configuration)
+public_frontend_api = frontend_api.FrontendApi(kratos_public_client)
+
 ADMIN_ROLE_ID = 1
 NO_ACCESS_ROLE_ID = 3
 
@@ -61,7 +74,7 @@ def recovery():
     if not flow:
         return redirect(KRATOS_PUBLIC_URL + "self-service/recovery/browser")
 
-    return render_template("recover.html", api_url=KRATOS_PUBLIC_URL)
+    return render_template("recover.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
 
 
 @web.route("/settings", methods=["GET", "POST"])
@@ -77,7 +90,7 @@ def settings():
     if not flow:
         return redirect(KRATOS_PUBLIC_URL + "self-service/settings/browser")
 
-    return render_template("settings.html", api_url=KRATOS_PUBLIC_URL)
+    return render_template("settings.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
 
 
 @web.route("/error", methods=["GET"])
@@ -95,13 +108,13 @@ def error():
     api_response = ""
     try:
         # Get Self-Service Errors
-        api_response = KRATOS_ADMIN.get_self_service_error(error_id)
+        api_response = admin_frontend_api.get_flow_error(error_id)
     except ory_kratos_client.ApiException as ex:
         current_app.logger.error(
-            "Exception when calling V0alpha2Api->get_self_service_error: %s\n",
+            "Exception when calling get_self_service_error: %s\n",
             ex)
 
-    return render_template("error.html", error_message=api_response)
+    return render_template("error.html", dashboard_url=DASHBOARD_URL, error_message=api_response)
 
 
 @web.route("/login", methods=["GET", "POST"])
@@ -117,22 +130,37 @@ def login():
     # Check if we are logged in:
     identity = get_auth()
 
-    if identity:
+    refresh = False
+    flow = request.args.get("flow")
+    if flow:
+        cookies = request.headers['cookie']
+        flow = public_frontend_api.get_login_flow(flow, cookie=cookies)
+        refresh = flow['refresh']
+
+    if identity and not refresh:
+        # We are already logged in, and don't need to refresh.
         if 'name' in identity['traits']:
             # Add a space in front of the "name" so the template can put it
             # between "Welcome" and the comma
             name = " " + identity['traits']['name']
         else:
             name = ""
-        return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, name=name)
-
-    flow = request.args.get("flow")
+        return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL, name=name)
 
     # If we do not have a flow, get one.
     if not flow:
         return redirect(KRATOS_PUBLIC_URL + "self-service/login/browser")
 
-    return render_template("login.html", api_url=KRATOS_PUBLIC_URL)
+    # If we end up here, then either:
+    # `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
+    #     self-service settings, and your session is too old.
+    # or `not identity`
+    #     User is not logged in yet.
+    # In either case, we present the login screen now.
+    return render_template("login.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL, refresh=refresh)
 
 
 @web.route("/auth", methods=["GET", "POST"])
@@ -185,13 +213,15 @@ def auth():
     current_app.logger.info("User is logged in. We can authorize the user")
 
     try:
-        login_request = HYDRA.login_request(challenge)
-    except hydra_client.exceptions.NotFound:
+        # hydra v2
+        # login_request = oauth2_api.get_o_auth2_login_request(challenge)
+        login_request = hydra_admin_api.get_login_request(challenge)
+    except hydra_exceptions.NotFoundException:
         current_app.logger.error(
             f"Not Found. Login request not found. challenge={challenge}"
         )
         abort(404, description="Login request not found. Please try again.")
-    except hydra_client.exceptions.HTTPError:
+    except hydra_exceptions.ApiException:
         current_app.logger.error(
             f"Conflict. Login request has been used already. challenge={challenge}"
         )
@@ -199,12 +229,15 @@ def auth():
 
     # Authorize the user
     # False positive: pylint: disable=no-member
-    redirect_to = login_request.accept(
-        identity.id,
-        remember=True,
-        # Remember session for 7d
-        remember_for=60 * 60 * 24 * 7,
-    )
+    redirect_to = hydra_admin_api.accept_login_request(
+        challenge,
+        accept_login_request=AcceptLoginRequest(
+            identity.id,
+            remember=True,
+            # Remember session for 7d
+            remember_for=60 * 60 * 24 * 7,
+        )
+    ).redirect_to
 
     return redirect(redirect_to)
 
@@ -224,11 +257,13 @@ def consent():
             403, description="Consent request required. Do not call this page directly"
         )
     try:
-        consent_request = HYDRA.consent_request(challenge)
-    except hydra_client.exceptions.NotFound:
+        # hydra v2
+        # consent_request = oauth2_api.get_o_auth2_consent_request(challenge)
+        consent_request = hydra_admin_api.get_consent_request(challenge)
+    except hydra_exceptions.NotFoundException:
         current_app.logger.error(f"Not Found. Consent request {challenge} not found")
         abort(404, description="Consent request does not exist. Please try again")
-    except hydra_client.exceptions.HTTPError:
+    except hydra_exceptions.ApiException:
         current_app.logger.error(f"Conflict. Consent request {challenge} already used")
         abort(503, description="Consent request already used. Please try again")
 
@@ -258,7 +293,7 @@ def consent():
 
     # Get the related user object
     current_app.logger.info(f"Info: Getting user from admin {kratos_id}")
-    user = KratosUser(KRATOS_ADMIN, kratos_id)
+    user = KratosUser(admin_identity_api, kratos_id)
     if not user:
         current_app.logger.error(f"User not found in database: {kratos_id}")
         abort(401, description="User not found. Please try again.")
@@ -280,12 +315,16 @@ def consent():
             current_app.logger.info(f"{kratos_id} was granted admin access to {client_id}")
             # Get claims for this user, provided the current app
             claims = user.get_claims(None, ['admin'])
+            current_app.logger.info(f"claims: {claims}")
             return redirect(
-                consent_request.accept(
-                    grant_scope=consent_request.requested_scope,
-                    grant_access_token_audience=consent_request.requested_access_token_audience,
-                    session=claims,
-                )
+                hydra_admin_api.accept_consent_request(
+                    challenge,
+                    accept_consent_request=AcceptConsentRequest(
+                        grant_scope=consent_request.requested_scope,
+                        grant_access_token_audience=consent_request.requested_access_token_audience,
+                        session=ConsentRequestSession(**claims),
+                    )
+                ).redirect_to
             )
 
     # Resolve to which app the client_id belongs.
@@ -373,14 +412,14 @@ def get_auth():
 
     # Given a cookie, check if it is valid and get the profile
     try:
-        api_response = KRATOS_PUBLIC.to_session(cookie=cookie)
+        api_response = public_frontend_api.to_session(cookie=cookie)
 
         # Get all traits from ID
         return api_response.identity
 
     except ory_kratos_client.ApiException as ex:
         current_app.logger.error(
-            f"Exception when calling V0alpha2Api->to_session(): {ex}\n"
+            f"Exception when calling to_session(): {ex}\n"
         )
 
     return False
@@ -425,11 +464,13 @@ def prelogout():
     if not challenge:
         abort(403)
     try:
-        logout_request = HYDRA.logout_request(challenge)
-    except hydra_client.exceptions.NotFound:
+        # hydra v2
+        # logout_request = oauth2_api.get_o_auth2_logout_request(challenge)
+        logout_request = hydra_admin_api.get_logout_request(challenge)
+    except hydra_exceptions.NotFoundException:
         current_app.logger.error("Logout request with challenge '%s' not found", challenge)
         abort(404, "Hydra session invalid or not found")
-    except hydra_client.exceptions.HTTPError:
+    except hydra_exceptions.ApiException:
         current_app.logger.error(
             "Conflict. Logout request with challenge '%s' has been used already.",
             challenge)
@@ -471,12 +512,12 @@ def logout():
     try:
         # Create a Logout URL for Browsers
         kratos_api_response = \
-            KRATOS_ADMIN.create_self_service_logout_flow_url_for_browsers(
+            admin_frontend_api.create_browser_logout_flow(
                 cookie=kratos_cookie)
         current_app.logger.info(kratos_api_response)
     except ory_kratos_client.ApiException as ex:
         current_app.logger.error("Exception when calling"
-            " V0alpha2Api->create_self_service_logout_flow_url_for_browsers: %s\n",
+            " create_self_service_logout_flow_url_for_browsers: %s\n",
             ex)
     return redirect(kratos_api_response.logout_url)
 
diff --git a/backend/web/static/base.js b/backend/web/static/base.js
index 0e142ed6..847f3c15 100644
--- a/backend/web/static/base.js
+++ b/backend/web/static/base.js
@@ -49,9 +49,13 @@ function flow_login() {
     type: 'GET',
     url: uri,
     success: function (data) {
-      // Render login form (group: password)
-      var form_html = render_form(data, 'password');
-      $('#contentLogin').html(form_html);
+      // Determine which groups to show.
+      var groups = scrape_groups(data);
+      for (const group of groups) {
+        // Render login form (group: password)
+        var form_html = render_form(data, group, 'login');
+        $('#contentLogin_' + group).html(form_html);
+      }
 
       var messages_html = render_messages(data);
       $('#contentMessages').html(messages_html);
@@ -92,7 +96,7 @@ function flow_settings_validate() {
 
         // For now, this code assumes that only the password can fail
         // validation. Other forms might need to be added in the future.
-        html = render_form(data, 'password');
+        html = render_form(data, 'password', 'validation');
         $('#contentPassword').html(html);
       }
     },
@@ -100,7 +104,8 @@ function flow_settings_validate() {
 }
 
 // Render the settings flow, this is where users can change their personal
-// settings, like name and password. The form contents are defined by Kratos
+// settings, like name, password and totp (second factor). The form contents
+// are defined by Kratos.
 function flow_settings() {
   // Get the details from the current flow from kratos
   var flow = $.urlParam('flow');
@@ -118,20 +123,24 @@ function flow_settings() {
         Cookies.set('flow_state', 'settings');
       }
 
-      // Hide prfile section if we are in recovery state
+      // Hide everything except password section if we are in recovery state,
       // so the user is not confused by other fields. The user
-      // probably want to setup a password only first.
+      // probably wants to setup a password only first.
       if (state == 'recovery') {
         $('#contentProfile').hide();
+        $('#contentTotp').hide();
       }
 
-      // Render the password & profile form based on the fields we got
-      // from the API
-      var html = render_form(data, 'password');
-      $('#contentPassword').html(html);
+      // Render the forms (password, profile, totp) based on the fields we got
+      // from the API.
+      var html = render_form(data, 'password', 'settings');
+      $('#pills-password').html(html);
+
+      html = render_form(data, 'profile', 'settings');
+      $('#pills-profile').html(html);
 
-      html = render_form(data, 'profile');
-      $('#contentProfile').html(html);
+      html = render_form(data, 'totp', 'settings');
+      $('#pills-totp').html(html);
 
       // If the submit button is hit, execute the POST with Ajax.
       $('#formpassword').submit(function (e) {
@@ -171,7 +180,7 @@ function flow_recover() {
     url: uri,
     success: function (data) {
       // Render the recover form, method 'link'
-      var html = render_form(data, 'link');
+      var html = render_form(data, 'link', 'recovery');
       $('#contentRecover').html(html);
 
       // Do form post as an AJAX call
@@ -206,27 +215,36 @@ function flow_recover() {
   });
 }
 
+// Based on Kratos UI data, decide which node groups to process.
+function scrape_groups(data) {
+  var nodes = new Set();
+  for (const node of data.ui.nodes) {
+    if (node.group != 'default') {
+      nodes.add(node.group);
+    }
+  }
+  return nodes;
+}
+
 // Based on Kratos UI data and a group name, get the full form for that group.
 // kratos groups elements which belongs together in a group and should be posted
 // at once. The elements in the default group should be part of all other
 // groups.
 //
 // data: data object as returned form the API
-// group: group to render.
-function render_form(data, group) {
+// group: group to render
+// context: string to specify the context of this form. We need this because
+//   the Kratos UI data is not sufficient in some cases to decide things like
+//   texts and button labels.
+function render_form(data, group, context) {
   // Create form
   var action = data.ui.action;
   var method = data.ui.method;
   var form = "<form id='form" + group + "' method='" + method + "' action='" + action + "'>";
 
   for (const node of data.ui.nodes) {
-    var name = node.attributes.name;
-    var type = node.attributes.type;
-    var value = node.attributes.value;
-    var messages = node.messages;
-
     if (node.group == 'default' || node.group == group) {
-      var elm = getFormElement(type, name, value, messages);
+      var elm = getFormElement(node, context);
       form += elm;
     }
   }
@@ -237,7 +255,7 @@ function render_form(data, group) {
 // Check if there are any general messages to show to the user and render them
 function render_messages(data) {
   var messages = data.ui.messages;
-  if (messages == []) {
+  if (typeof message == 'undefined' || messages == []) {
     return '';
   }
   var html = '<ul>';
@@ -260,8 +278,37 @@ function render_messages(data) {
 // name: name of the field. Used when posting data
 // value: If there is already a value known, show it
 // messages: error messages related to the field
-function getFormElement(type, name, value, messages) {
-  console.log('Getting form element', type, name, value, messages);
+function getFormElement(node, context) {
+  console.log('Getting form element', node);
+
+  if (node.type == 'img') {
+    return (
+      `
+            <img id="` +
+      node.attributes.id +
+      `" src='` +
+      node.attributes.src +
+      `'>`
+    );
+  }
+
+  if (node.type == 'text') {
+    return (
+      `
+            <span id="` +
+      node.attributes.id +
+      `" class="form-display form-display-` +
+      node.attributes.text.type +
+      `">` +
+      node.attributes.text.text +
+      `</span>`
+    );
+  }
+
+  var name = node.attributes.name;
+  var type = node.attributes.type;
+  var value = node.attributes.value;
+  var messages = node.messages;
 
   if (value == undefined) {
     value = '';
@@ -321,7 +368,37 @@ function getFormElement(type, name, value, messages) {
     );
   }
 
+  if (name == 'totp_code') {
+    return getFormInput(
+      'totp_code',
+      name,
+      value,
+      'TOTP code',
+      'Please enter the code from your TOTP/authenticator app.',
+      null,
+      messages,
+    );
+  }
+
   if (type == 'submit') {
+    var label = 'Save';
+    if (name == 'totp_unlink') {
+      label = 'Forget saved TOTP device';
+    }
+    else if (node.group == 'totp') {
+      label = 'Enroll TOTP device';
+    }
+    if (name == 'method' && value == 'password') {
+      if (context == 'settings') {
+        label = 'Update password';
+      }
+      else {
+        label = 'Log in';
+      }
+    }
+    if (context == 'recovery') {
+      label = 'Send recovery link';
+    }
     return (
       `<div class="form-group">
             <input type="hidden" name="` +
@@ -329,7 +406,7 @@ function getFormElement(type, name, value, messages) {
       `" value="` +
       value +
       `">
-             <button type="submit" class="btn btn-primary">Go!</button>
+             <button type="submit" class="btn btn-primary">` + label + `</button>
             </div>`
     );
   }
diff --git a/backend/web/static/style.css b/backend/web/static/style.css
index c563eb2b..6e4b7442 100644
--- a/backend/web/static/style.css
+++ b/backend/web/static/style.css
@@ -1,5 +1,3 @@
-
-
 div.loginpanel {
     width: 644px;
     margin-left: auto;
@@ -10,3 +8,25 @@ div.loginpanel {
 button {
     margin-top: 10px;
 }
+
+.form-display {
+    font-family: monospace;
+    display: block;
+    width: 100%;
+    padding: .375rem .75rem;
+    font-size: 1rem;
+    line-height: 1.5;
+    color: #495057;
+    background-color: #fff;
+    background-clip: padding-box;
+    border: 1px solid #ced4da;
+    border-radius: .25rem;
+}
+
+#pills-tab {
+    margin-bottom: 1rem;
+}
+
+#totp_qr {
+    padding: 2rem;
+}
diff --git a/backend/web/templates/base.html b/backend/web/templates/base.html
index 9d5765f1..08145302 100644
--- a/backend/web/templates/base.html
+++ b/backend/web/templates/base.html
@@ -2,8 +2,8 @@
 <html>
     <link rel="stylesheet" href="static/css/bootstrap.min.css">
     <link rel="stylesheet" href="static/style.css">
-    <script src="static/js/bootstrap.bundle.min.js"></script>
     <script src="static/js/jquery-3.6.0.min.js"></script>
+    <script src="static/js/bootstrap.bundle.min.js"></script>
     <script src="static/js/js.cookie.min.js"></script>
     <script src="static/base.js"></script>
     <title>Stackspin Account</title>
@@ -32,7 +32,7 @@
         style='display:none'>Your request is expired. Please resubmit your request faster.</div>
 
 
-<img src='static/logo.svg'/><br/><br/>
+<a href="{{ dashboard_url }}"><img src='static/logo.svg'/></a><br/><br/>
 
 {% block content %}{% endblock %}
 
diff --git a/backend/web/templates/login.html b/backend/web/templates/login.html
index 844eef72..515472f1 100644
--- a/backend/web/templates/login.html
+++ b/backend/web/templates/login.html
@@ -15,9 +15,12 @@
 </script>
 
 
-
+{% if refresh %}
+    <div class="alert alert-warning">Please confirm your credentials to complete this action.</div>
+{% endif %}
     <div id="contentMessages"></div>
-    <div id="contentLogin"></div>
+    <div id="contentLogin_password"></div>
+    <div id="contentLogin_totp"></div>
     <div id="contentHelp">
         <a href='recovery'>Set new password</a> | <a href='https://stackspin.net'>About stackspin</a>
     </div>
diff --git a/backend/web/templates/settings.html b/backend/web/templates/settings.html
index 8cb290af..1722e084 100644
--- a/backend/web/templates/settings.html
+++ b/backend/web/templates/settings.html
@@ -15,7 +15,6 @@
 </script>
 
 
-
     <div id="contentMessages"></div>
     <div id="contentProfileSaved" 
         class='alert alert-success' 
@@ -23,8 +22,20 @@
     <div id="contentProfileSaveFailed" 
         class='alert alert-danger' 
         style='display:none'>Your changes are not saved. Please check the fields for errors.</div>
+
+<div class="nav nav-pills" id="pills-tab" role="tablist">
+  <a class="nav-link active" id="pills-home-tab" data-toggle="pill" href="#pills-profile" role="tab" aria-controls="pills-profile" aria-selected="true">Profile</a>
+  <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">Second factor authentication</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>
     <div id="contentProfile"></div>
     <div id="contentPassword"></div>
+    <div id="contentTotp"></div>
 
 
 {% endblock %}
diff --git a/deployment/helmchart/templates/configmaps.yaml b/deployment/helmchart/templates/configmaps.yaml
index 82214617..8aa1b69a 100644
--- a/deployment/helmchart/templates/configmaps.yaml
+++ b/deployment/helmchart/templates/configmaps.yaml
@@ -19,10 +19,12 @@ data:
   KRATOS_PUBLIC_URL: {{ .Values.backend.kratos.publicUrl }}
   KRATOS_ADMIN_URL: {{ .Values.backend.kratos.adminUrl }}
   HYDRA_PUBLIC_URL: {{ .Values.backend.oidc.baseUrl }}
-  # React can only read this env variable if it's prepended with REACT_APP
+  # React can only read env variables if they're prepended with REACT_APP.
   REACT_APP_HYDRA_PUBLIC_URL: {{ .Values.backend.oidc.baseUrl }}
+  REACT_APP_KRATOS_PUBLIC_URL: {{ .Values.backend.kratos.publicUrl }}
   HYDRA_ADMIN_URL: {{ .Values.backend.hydra.adminUrl }}
-  LOGIN_PANEL_URL: {{ .Values.backend.loginPanelUrl }}
+  DASHBOARD_URL: {{ .Values.backend.dashboardUrl }}
+  LOGIN_PANEL_URL: {{ .Values.backend.dashboardUrl }}/web
   DATABASE_URL: {{ .Values.backend.databaseUrl }}
   LOAD_INCLUSTER_CONFIG: "true"
   # {{- if .Values.backend.smtp.enabled }}
diff --git a/deployment/helmchart/values-local.yaml.example b/deployment/helmchart/values-local.yaml.example
index 2ab9d690..aba3ce4b 100644
--- a/deployment/helmchart/values-local.yaml.example
+++ b/deployment/helmchart/values-local.yaml.example
@@ -17,8 +17,8 @@ backend:
   kratos:
     publicUrl: https://sso.stackspin.example.org/kratos
 
-  # Public URL of login panel
-  loginPanelUrl: https://dashboard.stackspin.example.org/web/
+  # Public URL of dashboard
+  dashboardUrl: https://dashboard.stackspin.example.org
 
   # Database connection
   # databaseUrl: mysql+pymysql://stackspin:password@single-sign-on-database-mariadb/stackspin
diff --git a/deployment/helmchart/values.yaml b/deployment/helmchart/values.yaml
index 17a0c539..a7be7179 100644
--- a/deployment/helmchart/values.yaml
+++ b/deployment/helmchart/values.yaml
@@ -263,8 +263,8 @@ backend:
   hydra:
     adminUrl: http://hydra-admin:4445
 
-  # Public URL of login panel
-  loginPanelUrl: https://dashboard.stackspin.example.org/web/
+  # Public URL of dashboard
+  dashboardUrl: https://dashboard.stackspin.example.org
   databaseUrl: mysql+pymysql://stackspin:stackspin@single-sign-on-database-mariadb/stackspin
 
   initialUser:
diff --git a/docker-compose.yml b/docker-compose.yml
index c18e1512..c5c20e4f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,6 +5,8 @@ services:
       context: ./frontend
     working_dir: "/home/node/app"
     env_file: ./frontend/local.env
+    # volumes:
+    #   - ./frontend/src:/home/node/app/src
     ports:
       - "3000:3000"
     # command: "yarn start"
@@ -31,6 +33,7 @@ services:
       - HYDRA_PUBLIC_URL=https://sso.$DOMAIN
 
       # Local path overrides
+      - DASHBOARD_URL=http://localhost:3000
       - KRATOS_PUBLIC_URL=http://stackspin_proxy:8081/kratos
       - KRATOS_ADMIN_URL=http://kube_port_kratos_admin:8000
       - HYDRA_ADMIN_URL=http://kube_port_hydra_admin:4445
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index ee080ade..ac27b43e 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,8 +1,13 @@
 FROM node:18
 
-ADD . .
+# First copy only files necessary for installing dependencies, so that we can
+# cache that step even when our own source code changes.
+COPY package.json yarn.lock .
 
 RUN yarn install
 
+# Now copy the rest of the source.
+COPY . .
+
 ENV NODE_OPTIONS="--openssl-legacy-provider"
 CMD yarn start
diff --git a/frontend/local.env.example b/frontend/local.env.example
index ce5f1791..48059cce 100644
--- a/frontend/local.env.example
+++ b/frontend/local.env.example
@@ -1,2 +1,3 @@
 REACT_APP_API_URL=http://stackspin_proxy:8081/api/v1
 REACT_APP_HYDRA_PUBLIC_URL=https://sso.init.stackspin.net
+REACT_APP_KRATOS_PUBLIC_URL=http://stackspin_proxy:8081/kratos
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
index 0c783e15..022c8dea 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -10,6 +10,7 @@ import _ from 'lodash';
 import { UserModal } from '../UserModal';
 
 const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessions/logout`;
+const KRATOS_PUBLIC_URL = process.env.REACT_APP_KRATOS_PUBLIC_URL;
 
 const navigation = [
   { name: 'Dashboard', to: '/dashboard', requiresAdmin: false },
@@ -52,12 +53,11 @@ const Header: React.FC<HeaderProps> = () => {
   const navigationItems = filterNavigationByDashboardRole(isAdmin);
 
   const signOutUrl = useMemo(() => {
-    const { hostname } = window.location;
-    // If we are developing locally, we need to use the init cluster's public URL
-    if (hostname === 'localhost') {
-      return HYDRA_LOGOUT_URL;
-    }
-    return `https://${hostname.replace(/^dashboard/, 'sso')}/oauth2/sessions/logout`;
+    return HYDRA_LOGOUT_URL;
+  }, []);
+
+  const kratosSettingsUrl = useMemo(() => {
+    return `${KRATOS_PUBLIC_URL}/self-service/settings/browser`;
   }, []);
 
   return (
@@ -136,6 +136,19 @@ const Header: React.FC<HeaderProps> = () => {
                             </a>
                           )}
                         </Menu.Item>
+                        <Menu.Item>
+                          {({ active }) => (
+                            <a
+                              href={kratosSettingsUrl}
+                              className={classNames(
+                                active ? 'bg-gray-100 cursor-pointer' : '',
+                                'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
+                              )}
+                            >
+                              Authentication settings
+                            </a>
+                          )}
+                        </Menu.Item>
                         <Menu.Item>
                           {({ active }) => (
                             <a
diff --git a/frontend/src/services/auth/redux/selectors.ts b/frontend/src/services/auth/redux/selectors.ts
index 0feaae54..0e7d26b1 100644
--- a/frontend/src/services/auth/redux/selectors.ts
+++ b/frontend/src/services/auth/redux/selectors.ts
@@ -10,6 +10,7 @@ export const getAuthToken = (state: State) => state.auth.token;
 export const getCurrentUser = (state: State) => state.auth.userInfo;
 
 export const getIsAdmin = (state: State) => {
+  window.console.log(state.auth.userInfo);
   // check since old users wont have this
   if (state.auth.userInfo) {
     if (!state.auth.userInfo.app_roles) {
diff --git a/run_app.sh b/run_app.sh
index b4d203d9..26e94e18 100755
--- a/run_app.sh
+++ b/run_app.sh
@@ -2,6 +2,8 @@
 
 set -euo pipefail
 
+dockerComposeArgs=$@
+
 export DATABASE_PASSWORD=$(kubectl get secret -n flux-system stackspin-single-sign-on-variables -o jsonpath --template '{.data.dashboard_database_password}' | base64 -d)
 export DOMAIN=$(kubectl get secret -n flux-system stackspin-cluster-variables -o jsonpath --template '{.data.domain}' | base64 -d)
 export HYDRA_CLIENT_SECRET=$(kubectl get secret -n flux-system stackspin-dashboard-local-oauth-variables -o jsonpath --template '{.data.client_secret}' | base64 -d)
@@ -29,4 +31,4 @@ if [[ -z "$HYDRA_CLIENT_SECRET" ]]; then
     exit 1
 fi
 
-KUBECTL_UID=${UID:-1001} KUBECTL_GID=${GID:-0} docker compose up
+KUBECTL_UID=${UID:-1001} KUBECTL_GID=${GID:-0} docker compose up $dockerComposeArgs
-- 
GitLab