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