diff --git a/CHANGELOG.md b/CHANGELOG.md index 019394488af349885614cd5425059465ec51cdd2..096dbb5a5bf8e317f976934d6622ea8567d323ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.6.2] + +- Fix submit button label in the form for verifying your TOTP code. + +## [0.6.1] + +- Add TOTP as second factor authentication. Please note that you'll need to set + a `backend.dashboardUrl` value instead of the old `backend.loginPanelUrl` one + -- typically dropping the `/web` suffix to get the new value. +- Create a new backend endpoint for providing some environment variables to the + frontend, with the URLs of the Kratos and Hydra APIs. + +## [0.6.0] + +- Make it easier to add apps, by reading apps and oauthclients from configmaps + at startup. +- Reset alembic migration history. + ## [0.5.2] - Fix login welcome message diff --git a/README.md b/README.md index 58babdfcf403cca5c3e8aa89d18fa9c67929af6e..f7b4959eb46d8e00c4ec1f33c2c0f731a1ac0687 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,10 @@ These need to be available locally, because Kratos wants to run on the same domain as the front-end that serves the login interface. ### Setup +Before you start, make sure your machine has the required software installed, as per official documentation: https://docs.stackspin.net/en/v2/installation/install_cli.html#preparing-the-provisioning-machine. Please read through all subsections to set up your environment before -attempting to run the dashboard locally. +attempting to run the dashboard locally. #### 1. Stackspin cluster @@ -90,8 +91,6 @@ configure it, create a `local.env` file in the `frontend` directory: cp local.env.example local.env -and adjust the `REACT_APP_HYDRA_PUBLIC_URL` to the SSO URL of your cluster. - #### 3. Setup hosts file The application will run on `http://stackspin_proxy`. Add the following line to @@ -104,7 +103,8 @@ The application will run on `http://stackspin_proxy`. Add the following line to #### 4. Kubernetes access The script needs you to have access to the Kubernetes cluster that runs -Stackspin. Point the `KUBECONFIG` environment variable to a kubectl config. Attention points: +Stackspin. Point the `KUBECONFIG` environment variable to a kubectl config. +Attention points: * The kubeconfig will be mounted inside docker containers, so also make sure your Docker user can read it. @@ -121,3 +121,23 @@ After you've finished all setup steps, you can run everything using This sets a few environment variables based on what is in your cluster secrets, and run `docker compose up` to build and run all necessary components, including a reverse proxy and the backend flask application. + +## Testing as a part of Stackspin + +Sometimes you may want to make more fundamental changes to the dashboard that +might behave differently in the local development environment compared to a +regular Stackspin instance, i.e., one that's not a local/cluster hybrid. In +this case, you'll want to run your new version in a regular Stackspin cluster. + +To do that, make sure to increase the chart version number in `Chart.yaml`, and +push your work to a MR. The CI pipeline should then publish your new chart +version in the Gitlab helm chart repo for the dashboard project, but in the +`unstable` channel -- the `stable` channel is reserved for chart versions that +have been merged to the `main` branch. + +Once your package is published, use it by +1. changing the `spec.url` field of the `flux-system/dashboard` + `HelmRepository` object in the cluster where you want to run this, replacing + `stable` by `unstable`; and +2. changing the `spec.chart.spec.version` field of the `stackspin/dashboard` + `HelmRelease` to your chart version (the one from this chart's `Chart.yaml`). diff --git a/backend/Dockerfile b/backend/Dockerfile index c62abf74718d46c6a1825032ce60e84b44f3b84f..67b11bb62970ef62261b83e1adf933322de99af2 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 622173723d1215ddac05ddd36bfce17c8a8018b8..896f1035fd748df697776c66690ada75f16bd637 100644 --- a/backend/app.py +++ b/backend/app.py @@ -64,7 +64,6 @@ app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS app.logger.setLevel(logging.INFO) -app.logger.info("Starting dashboard backend.") cors = CORS(app) @@ -121,7 +120,6 @@ jwt = JWTManager(app) def expired_token_callback(*args): return jsonify({"errorMessage": "Unauthorized"}), 401 - @app.route("/") def index(): return "Stackspin API v1.0" diff --git a/backend/areas/__init__.py b/backend/areas/__init__.py index ae4261edec5b9a87f68832c8484f480d380cc0f4..b90dfee2fde7711293a5e0a3394548182bcab958 100644 --- a/backend/areas/__init__.py +++ b/backend/areas/__init__.py @@ -1,4 +1,6 @@ -from flask import Blueprint +from flask import Blueprint, jsonify + +from config import * api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1") @@ -7,3 +9,11 @@ api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1") @api_v1.route("/health") def api_index(): return "Stackspin API v1.0" + +@api_v1.route("/environment") +def api_environment(): + environment = { + "HYDRA_PUBLIC_URL": HYDRA_PUBLIC_URL, + "KRATOS_PUBLIC_URL": KRATOS_PUBLIC_URL, + } + return jsonify(environment) diff --git a/backend/areas/apps/apps.py b/backend/areas/apps/apps.py index 1bd55644f0a7b985062e8897423ed430318b247c..aa61d0417b932ec9bae5c3c422a3f90b87ca5ddd 100644 --- a/backend/areas/apps/apps.py +++ b/backend/areas/apps/apps.py @@ -29,6 +29,7 @@ def get_apps(): @api_v1.route('/apps/<string:slug>', methods=['GET']) @jwt_required() +@cross_origin() def get_app(slug): """Return data about a single app""" app = AppsService.get_app(slug) diff --git a/backend/areas/apps/apps_service.py b/backend/areas/apps/apps_service.py index 289529de75435c538e4796a36945a9ddaeab9d83..04958aa884b76119ac390dbda33927438ae950fa 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_admin_client: + kratos_identity_api = identity_api.IdentityApi(kratos_admin_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/roles/role_service.py b/backend/areas/roles/role_service.py index 3520b273e75026410d095ccbf7df26a6b097728b..77943f75c1f8409784d37c40e234b7bc9205731c 100644 --- a/backend/areas/roles/role_service.py +++ b/backend/areas/roles/role_service.py @@ -1,4 +1,4 @@ -from areas.apps.models import AppRole +from areas.apps.models import App, AppRole from .models import Role @@ -14,5 +14,6 @@ class RoleService: @staticmethod def is_user_admin(userId): - dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=1).first().role_id + dashboard_app_id = App.query.filter_by(slug='dashboard').first().id + dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=dashboard_app_id).first().role_id return dashboard_role_id == 1 diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py index ec94c44ce44bee2e33013e3e7b1ee4d55b31cb21..99169f5656437150e21bc611c0a08812288feb36 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 frontend_api, identity_api from config import KRATOS_ADMIN_URL from database import db @@ -15,8 +15,9 @@ 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_frontend_api = frontend_api.FrontendApi(kratos_client) +kratos_identity_api = identity_api.IdentityApi(kratos_client) class UserService: @staticmethod @@ -68,6 +69,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 +88,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_frontend_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_frontend_api.update_recovery_flow(flow, + update_recovery_flow_body=update_recovery_flow_body) @staticmethod def put_user(id, user_editing_id, data): @@ -181,15 +183,19 @@ class UserService: apps = App.query.all() app_roles = [] for app in apps: - tmp_app_role = AppRole.query.filter_by( - user_id=userId, app_id=app.id - ).first() - app_roles.append( - { - "name": app.slug, - "role_id": tmp_app_role.role_id if tmp_app_role else None, - } - ) + # Only show role when installed + app_status = app.get_status() + if app_status.installed: + + tmp_app_role = AppRole.query.filter_by( + user_id=userId, app_id=app.id + ).first() + app_roles.append( + { + "name": app.slug, + "role_id": tmp_app_role.role_id if tmp_app_role else None, + } + ) userRes["traits"]["app_roles"] = app_roles return userRes diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py index 08f22c6f5f9266e6efc2c9dbfcfcb115a6501388..b1740ebd57eba73348a1ac4127d2f8652261368c 100644 --- a/backend/areas/users/users.py +++ b/backend/areas/users/users.py @@ -1,7 +1,7 @@ from flask import jsonify, request -from flask_jwt_extended import get_jwt, jwt_required from flask_cors import cross_origin from flask_expects_json import expects_json +from flask_jwt_extended import get_jwt, jwt_required from areas import api_v1 from helpers import KratosApi diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py index 3d2fedc6f964b8a9984590663b853e9a6c66ec6f..4c8305c562e0168862a8be7b68957959cbb61e31 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 # @@ -61,9 +54,7 @@ def create_app(slug, name, external_url = None): """ current_app.logger.info(f"Creating app definition: {name} ({slug}") - obj = App() - obj.name = name - obj.slug = slug + obj = App(name=name, slug=slug) app_obj = App.query.filter_by(slug=slug).first() @@ -213,7 +204,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") @@ -256,7 +247,7 @@ def show_user(email): internal state/values of the user object :param email: Email address of the user to show """ - user = KratosUser.find_by_email(KRATOS_ADMIN, email) + user = KratosUser.find_by_email(kratos_identity_api, email) if user is not None: print(user) print("") @@ -288,7 +279,7 @@ def update_user(email, field, value): :param value: The value to set the field with """ current_app.logger.info(f"Looking for user with email: {email}") - user = KratosUser.find_by_email(KRATOS_ADMIN, email) + user = KratosUser.find_by_email(kratos_identity_api, email) if not user: current_app.logger.error(f"User with email {email} not found.") sys.exit(1) @@ -310,7 +301,7 @@ def delete_user(email): :param email: Email address of user to delete """ current_app.logger.info(f"Looking for user with email: {email}") - user = KratosUser.find_by_email(KRATOS_ADMIN, email) + user = KratosUser.find_by_email(kratos_identity_api, email) if not user: current_app.logger.error(f"User with email {email} not found.") sys.exit(1) @@ -327,12 +318,12 @@ def create_user(email): current_app.logger.info(f"Creating user with email: ({email})") # Create a user - user = KratosUser.find_by_email(KRATOS_ADMIN, email) + user = KratosUser.find_by_email(kratos_identity_api, email) if user: current_app.logger.info("User already exists. Not recreating") return - user = KratosUser(KRATOS_ADMIN) + user = KratosUser(kratos_identity_api) user.email = email user.save() @@ -357,7 +348,7 @@ def setpassword_user(email, password): try: # Get the ID of the user - kratos_user = KratosUser.find_by_email(KRATOS_ADMIN, email) + kratos_user = KratosUser.find_by_email(kratos_identity_api, email) if kratos_user is None: current_app.logger.error(f"User with email '{email}' not found") sys.exit(1) @@ -382,7 +373,7 @@ def setpassword_user(email, password): def list_user(): """Show a list of users in the database""" current_app.logger.info("Listing users") - users = KratosUser.find_all(KRATOS_ADMIN) + users = KratosUser.find_all(kratos_identity_api) for obj in users: print(obj) @@ -399,7 +390,7 @@ def recover_user(email): try: # Get the ID of the user - kratos_user = KratosUser.find_by_email(KRATOS_ADMIN, email) + kratos_user = KratosUser.find_by_email(kratos_identity_api, email) # Get a recovery URL url = kratos_user.get_recovery_link() diff --git a/backend/cluster_config.py b/backend/cluster_config.py index 20ec9e98023dfd01b46a30492f74057aea1a1aed..f814e1c4351e9e3ab56cefc07ce77146fb7d6d57 100644 --- a/backend/cluster_config.py +++ b/backend/cluster_config.py @@ -13,7 +13,7 @@ def populate_apps(): for app in App.query.all(): slug = app.slug database_apps[slug] = app - logging.info(f"database app: {slug}") + logging.debug(f"database app: {slug}") _populate_apps_from(database_apps, "stackspin-apps") _populate_apps_from(database_apps, "stackspin-apps-custom") @@ -27,19 +27,19 @@ def _populate_apps_from(database_apps, configmap_name): logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.") else: for app_slug, app_data in cm_apps.items(): - logging.info(f"configmap app: {app_slug}") + logging.debug(f"configmap app: {app_slug}") if app_slug in database_apps: - logging.info(f" already present in database") + logging.debug(f" already present in database") else: - logging.info(f" not present in database, adding!") + logging.debug(f" not present in database, adding!") data = yaml.safe_load(app_data) name = data["name"] - logging.info(f" name: {name}") + logging.debug(f" name: {name}") external = data.get("external", False) - logging.info(f" type external: {type(external)}") - logging.info(f" external: {external}") + logging.debug(f" type external: {type(external)}") + logging.debug(f" external: {external}") url = data.get("url", None) - logging.info(f" url: {url}") + logging.debug(f" url: {url}") new_app = App(slug=app_slug, name=name, external=external, url=url) db.session.add(new_app) db.session.commit() @@ -52,7 +52,7 @@ def populate_oauthclients(): for client in OAuthClientApp.query.all(): id = client.oauthclient_id database_oauthclients[id] = client - logging.info(f"database oauthclient: {id}") + logging.debug(f"database oauthclient: {id}") _populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients") _populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients-custom") @@ -65,11 +65,11 @@ def _populate_oauthclients_from(database_oauthclients, configmap_name): logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.") else: for client_id, client_app in cm_oauthclients.items(): - logging.info(f"configmap oauthclient: {client_id}") + logging.debug(f"configmap oauthclient: {client_id}") if client_id in database_oauthclients: - logging.info(f" already present in database") + logging.debug(f" already present in database") else: - logging.info(f" not present in database, adding!") + logging.debug(f" not present in database, adding!") # Take the value of the configmap mapping (`client_app`) and # interpret it as the slug of the app that this oauthclient # belongs to. @@ -78,6 +78,6 @@ def _populate_oauthclients_from(database_oauthclients, configmap_name): logging.error(f" could not find app with slug {client_app}") continue new_client = OAuthClientApp(oauthclient_id=client_id, app_id=app.id) - logging.info(f" new oauth client: {new_client}") + logging.debug(f" new oauth client: {new_client}") db.session.add(new_client) db.session.commit() diff --git a/backend/config.py b/backend/config.py index 2cb001778303acb3efbff62c07d9e07b1e797904..6e5d37ac1221925e8403612674225bd0ccf5314d 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") @@ -20,3 +21,5 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False # running in a Kubernetes pod. Set it to "false" to load the config from the # `KUBECONFIG` environment variable. LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG").lower() == "true" + +DEMO_INSTANCE = os.environ.get("DASHBOARD_DEMO_INSTANCE", "False").lower() in ('true', '1') diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py index dee31b4c229a513154c55eb12d72c0c1d7a65766..3615809e0adcd29abcdd167a69bb4ca6cf7aafb0 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: @@ -113,7 +113,7 @@ class KratosUser(): """ if self.__uuid: try: - self.api.admin_delete_identity(self.__uuid) + self.api.delete_identity(self.__uuid) return True except KratosApiException as error: raise BackendError( @@ -133,8 +133,8 @@ class KratosUser(): kratos_id = None # Get out user ID by iterating over all available IDs - data = api.admin_list_identities() - for kratos_obj in data.value: + data = api.list_identities() + for kratos_obj in data: # Unique identifier we use if kratos_obj.traits['email'] == email: kratos_id = str(kratos_obj.id) @@ -152,8 +152,8 @@ class KratosUser(): kratos_id = None return_list = [] # Get out user ID by iterating over all available IDs - data = api.admin_list_identities() - for kratos_obj in data.value: + data = api.list_identities() + for kratos_obj in data: kratos_id = str(kratos_obj.id) return_list.append(KratosUser(api, kratos_id)) @@ -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 05715b0ea460384fee1dd9668bd3c9810ed849ce..fc2836ad096a8f50d217569cc0a9b8eccdc5db34 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 @@ -21,10 +23,13 @@ MarkupSafe==2.1.1 mypy-extensions==0.4.3 NamedAtomicLock==1.1.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 PyYAML==6.0 regex==2022.3.15 @@ -35,8 +40,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 94ee4c37036fce38e47dcfe7c70e8ba87f4aac2d..25f09a006ace2a225fdf17d3f674a810a6a64fa4 100644 --- a/backend/web/login/login.py +++ b/backend/web/login/login.py @@ -8,10 +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 flask import abort, redirect, render_template, request, current_app +from ory_kratos_client.api import frontend_api, identity_api +from flask import abort, current_app, jsonify, redirect, render_template, request from database import db from helpers import KratosUser @@ -19,6 +24,8 @@ from config import * from web import web from areas.apps import AppRole, App, OAuthClientApp from areas.roles import RoleService +from areas.roles.models import Role +from areas.users.user_service import UserService # This is a circular import and should be solved differently @@ -26,20 +33,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) +kratos_public_frontend_api = frontend_api.FrontendApi(kratos_public_client) + ADMIN_ROLE_ID = 1 NO_ACCESS_ROLE_ID = 3 @@ -61,7 +75,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 +91,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 +109,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 +131,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 = kratos_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, demo=DEMO_INSTANCE) @web.route("/auth", methods=["GET", "POST"]) @@ -185,13 +214,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 +230,21 @@ 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, - ) + + try: + 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 + except Exception as e: + current_app.logger.error("Failure during accepting login request. Redirecting to logout, hopefully to wipe cookies") + current_app.logger.error(e) + return redirect("logout") return redirect(redirect_to) @@ -224,11 +264,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 +300,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 +322,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. @@ -294,11 +340,15 @@ def consent(): except AttributeError: current_app.logger.error(f"Could not find app for client {client_id}") return redirect( - consent_request.reject( - error="No access", - error_description="The user has no access for app", - error_hint="Contact your administrator", - status_code=401, + hydra_admin_api.reject_consent_request( + challenge, + # In previous versions of the hydra API client library, we + # could set these parameters, but that's no longer possible, + # not sure why. + # error="No access", + # error_description="The user has no access for app", + # error_hint="Contact your administrator", + # status_code=401, ) ) @@ -315,11 +365,15 @@ def consent(): # If there is no role in app_roles or the role_id for an app is null user has no permissions current_app.logger.error(f"User has no access for: {app_obj.name}") return redirect( - consent_request.reject( - error="No access", - error_description="The user has no access for app", - error_hint="Contact your administrator", - status_code=401, + hydra_admin_api.reject_consent_request( + challenge, + # In previous versions of the hydra API client library, we + # could set these parameters, but that's no longer possible, + # not sure why. + # error="No access", + # error_description="The user has no access for app", + # error_hint="Contact your administrator", + # status_code=401, ) ) else: @@ -337,14 +391,23 @@ def consent(): current_app.logger.info(f"{kratos_id} was granted access to {client_id}") # False positive: pylint: disable=no-member - return redirect( - consent_request.accept( - grant_scope=consent_request.requested_scope, - grant_access_token_audience=consent_request.requested_access_token_audience, - session=claims, - ) - ) + try: + redirectUrl = 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 + except: + # If an unexpected error occurs, logout, hopefully that wipes the + # relevant cookies + current_app.logger.error('Fatal processing consent, redirect to logout:' + str(e)) + return redirect("logout") + current_app.logger.info(f"Redirect to: {redirectUrl}") + return redirect(redirectUrl) @web.route("/status", methods=["GET", "POST"]) def status(): @@ -373,14 +436,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 = kratos_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 +488,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) @@ -439,9 +504,9 @@ def prelogout(): # Accept logout request and direct to hydra to remove cookies try: - hydra_return = logout_request.accept(subject=logout_request.subject) + hydra_return = hydra_admin_api.accept_logout_request(challenge) if hydra_return: - return redirect(hydra_return) + return redirect(hydra_return.redirect_to) except Exception as ex: current_app.logger.info("Error logging out hydra: %s", str(ex)) @@ -471,12 +536,23 @@ 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) + +if DEMO_INSTANCE: + @web.route("/demo-user", methods=["POST"]) + def demo_user(): + data = request.get_json() + defaults = { + "name": "", + "app_roles": [{"name": "dashboard", "role_id": Role.ADMIN_ROLE_ID}], + } + UserService.post_user({**defaults, **data}) + return jsonify("User created successfully. You should receive an email to confirm your address and set a password.") diff --git a/backend/web/static/base.js b/backend/web/static/base.js index 0e142ed6e01a4ccd417899ea44262e60d715e8fc..e7cab4a0d962dec9018d14fe703cb784a1a612da 100644 --- a/backend/web/static/base.js +++ b/backend/web/static/base.js @@ -49,12 +49,19 @@ 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); + + $('#totp_code').focus(); + $('#identifier').focus(); }, complete: function (obj) { // If we get a 410, the flow is expired, need to refresh the flow @@ -92,7 +99,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 +107,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 +126,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 +183,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 +218,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 +258,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 +281,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 +371,42 @@ 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') { + if (context == 'settings') { + label = 'Enroll TOTP device'; + } + else { + label = 'Verify'; + } + } + 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 +414,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 c563eb2b4a8cf1cd10f5a651a61215e31256f64e..6e4b74422493dd012741e897486e988641498bf7 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 9d5765f1fa1116554095827bf469a24fde7cf26a..081453028d4f0b389f77dbac03dbd0056cc2cb1b 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 844eef72ff644be2ae5fcebed74eb3d89016dbe6..11d9f8a5315dfb97662604ad906c3022e2729971 100644 --- a/backend/web/templates/login.html +++ b/backend/web/templates/login.html @@ -15,11 +15,72 @@ </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> +{% if demo %} + <br> + <script> + function submitSignup() { + let result = document.querySelector('#signup-result'); + let email = document.querySelector('#signup-email'); + let xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + let url = "/web/demo-user"; + xhr.open("POST", url, true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // In the success case, we get a plain (json) string; in the error + // case, we get an object with `errorMessage` property. + if (typeof(this.response) == 'object' && 'errorMessage' in this.response) { + window.console.log("Error in sign-up request."); + result.classList.remove('alert-success'); + result.classList.add('alert-danger'); + result.innerHTML = this.response.errorMessage; + } else { + result.classList.add('alert-success'); + result.classList.remove('alert-danger'); + result.innerHTML = this.response; + } + result.style.visibility = 'visible'; + } + }; + // Converting JSON data to string + var data = JSON.stringify({"email": email.value }); + // Sending data with the request + xhr.send(data); + } + </script> + <h4>Sign up for this demo instance</h4> + Enter your email address here to create an account on this Stackspin + instance. + <div class="alert alert-warning" style="margin-top: 1em;"> + Warning: this is a demo instance! That means that: + <ul> + <li>Anyone can create an account on this same instance, like yourself, + and will share the same set of users and data. So any data you create + or upload, including the email address you enter here, becomes + essentially public information.</li> + <li>Every night (Europe/Amsterdam time), this instance gets automatically + reset to an empty state, so any data you create or upload will be + destroyed.</li> + </ul> + </div> + <div class="form-group"> + <label for="signup-email">Email address</label> + <input type="email" class="form-control" id="signup-email" name="signup-email" placeholder="Your email address to sign up with."> + </div> + <div class="form-group"> + <button class="btn btn-primary" onclick="submitSignup()">Sign up</button> + <div id="signup-result" class="alert" style="visibility: hidden; margin-top: 1em;"></div> + </div> +{% endif %} {% endblock %} diff --git a/backend/web/templates/settings.html b/backend/web/templates/settings.html index 8cb290afa9f0d5e09b34177b1e0ec74529e2b2cd..1722e08446e722dbcfaf25dbf99366a1deaa6b5d 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/CHANGELOG.md b/deployment/helmchart/CHANGELOG.md index 2d56fd2de6e23b426de12f4ee8e68fe8a4aa0c2f..ea0d36a7adbe91918aa65af0d870359b582fe75a 100644 --- a/deployment/helmchart/CHANGELOG.md +++ b/deployment/helmchart/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.6.2] + +* Update dashboard to version 0.6.2 + +## [1.6.1] + +* Update dashboard to version 0.6.1 + ## [1.5.2] * Update dashboard to version 0.5.2 diff --git a/deployment/helmchart/Chart.yaml b/deployment/helmchart/Chart.yaml index b9c9d501158ed7769000f523dd20e090af7e3d75..62fef710abc91f367d240266818e2ccff64d4c55 100644 --- a/deployment/helmchart/Chart.yaml +++ b/deployment/helmchart/Chart.yaml @@ -1,7 +1,7 @@ annotations: category: Dashboard apiVersion: v2 -appVersion: 0.5.2 +appVersion: 0.6.2 dependencies: - name: common # https://artifacthub.io/packages/helm/bitnami/common diff --git a/deployment/helmchart/templates/configmaps.yaml b/deployment/helmchart/templates/configmaps.yaml index 8221461748db6101c286b75da03ca53a203ee0cc..679c700471025158185d80acd67c818b7bbc8746 100644 --- a/deployment/helmchart/templates/configmaps.yaml +++ b/deployment/helmchart/templates/configmaps.yaml @@ -19,10 +19,9 @@ 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_APP_HYDRA_PUBLIC_URL: {{ .Values.backend.oidc.baseUrl }} 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 2ab9d69006817d03e5d25e2e15b29cd40127598a..aba3ce4b92fb75e19a03066711d6475a67559195 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 521f3925b48d4c9902c15019392b2cb2fb45f276..343d5e54976054598bd4a4e85c7c8b342118f84c 100644 --- a/deployment/helmchart/values.yaml +++ b/deployment/helmchart/values.yaml @@ -68,7 +68,7 @@ dashboard: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/dashboard - tag: 0.6.0 + tag: 0.6.2 digest: "" ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. @@ -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 fe11ab8c1886ece8f46929221c59dbdeab4217f9..655be3e58c31e6d70fefdab2b1f40398e6fee636 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,12 @@ services: frontend: build: 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" + command: "yarn run start" stackspin_proxy: image: nginx:1.23.3 ports: @@ -31,6 +32,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 @@ -54,7 +56,7 @@ services: - kube_port_mysql entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"] kube_port_kratos_admin: - image: bitnami/kubectl:1.26.1 + image: bitnami/kubectl:1.26.2 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 8000 @@ -62,7 +64,7 @@ services: - "$KUBECONFIG:/.kube/config" entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address $$(hostname -i) service/kratos-admin 8000:80"] kube_port_hydra_admin: - image: bitnami/kubectl:1.26.1 + image: bitnami/kubectl:1.26.2 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 4445 @@ -70,7 +72,7 @@ services: - "$KUBECONFIG:/.kube/config" entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address $$(hostname -i) service/hydra-admin 4445:4445"] kube_port_kratos_public: - image: bitnami/kubectl:1.26.1 + image: bitnami/kubectl:1.26.2 user: "${KUBECTL_UID}:${KUBECTL_GID}" ports: - "8080:8080" @@ -80,7 +82,7 @@ services: - "$KUBECONFIG:/.kube/config" entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address 0.0.0.0 service/kratos-public 8080:80"] kube_port_mysql: - image: bitnami/kubectl:1.26.1 + image: bitnami/kubectl:1.26.2 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 3306 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ee080ade68a7b99b3ffb7cdb19a715f1bb21970c..6a7e219519bfc587a3d30f0548cb142c43ca94fc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,8 +1,15 @@ FROM node:18 -ADD . . +WORKDIR /home/node/app + +# 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 ce5f17913124ad997e6af938e3ac9a0e5bc6a56d..c32c7c4c8823ca6cc797fa21257da451eacfdd22 100644 --- a/frontend/local.env.example +++ b/frontend/local.env.example @@ -1,2 +1 @@ REACT_APP_API_URL=http://stackspin_proxy:8081/api/v1 -REACT_APP_HYDRA_PUBLIC_URL=https://sso.init.stackspin.net diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 0c783e1597290ed72404d259fe90b04d0ac4f62f..4d57a9aec1305b3a781976665d6dd3d13284a83e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,6 +1,7 @@ -import React, { Fragment, useMemo, useState } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; import { Disclosure, Menu, Transition } from '@headlessui/react'; import { MenuIcon, XIcon } from '@heroicons/react/outline'; +import { performApiCall } from 'src/services/api'; import { useAuth } from 'src/services/auth'; import Gravatar from 'react-gravatar'; import { Link, useLocation } from 'react-router-dom'; @@ -9,8 +10,6 @@ import _ from 'lodash'; import { UserModal } from '../UserModal'; -const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessions/logout`; - const navigation = [ { name: 'Dashboard', to: '/dashboard', requiresAdmin: false }, { name: 'Users', to: '/users', requiresAdmin: true }, @@ -29,16 +28,44 @@ function filterNavigationByDashboardRole(isAdmin: boolean) { return navigation.filter((item) => !item.requiresAdmin); } +export interface Environment { + HYDRA_PUBLIC_URL: string; + KRATOS_PUBLIC_URL: string; +} + +const defaultEnvironment: Environment = { + HYDRA_PUBLIC_URL: 'error-failed-to-load-env-from-backend', + KRATOS_PUBLIC_URL: 'error-failed-to-load-env-from-backend', +}; + // eslint-disable-next-line @typescript-eslint/no-empty-interface interface HeaderProps {} const Header: React.FC<HeaderProps> = () => { + const [environment, setEnvironment] = useState(defaultEnvironment); const [currentUserModal, setCurrentUserModal] = useState(false); const [currentUserId, setCurrentUserId] = useState(null); const { logOut, currentUser, isAdmin } = useAuth(); const { pathname } = useLocation(); + useEffect(() => { + let active = true; + async function loadEnvironment() { + const result = await performApiCall({ + path: '/environment', + }); + if (!active) { + return; + } + setEnvironment(result.data); + } + loadEnvironment(); + return () => { + active = false; + }; + }, []); + const currentUserModalOpen = (id: any) => { setCurrentUserId(id); setCurrentUserModal(true); @@ -51,14 +78,8 @@ 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`; - }, []); + const signOutUrl = `${environment.HYDRA_PUBLIC_URL}/oauth2/sessions/logout`; + const kratosSettingsUrl = `${environment.KRATOS_PUBLIC_URL}/self-service/settings/browser`; return ( <> @@ -136,6 +157,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/components/UserModal/UserModal.tsx b/frontend/src/components/UserModal/UserModal.tsx index 29e9738db998c6f1516d9e29357ba726d621a847..5e64122718e2f356ee8f7837e9ee6c525c896819 100644 --- a/frontend/src/components/UserModal/UserModal.tsx +++ b/frontend/src/components/UserModal/UserModal.tsx @@ -197,6 +197,11 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps) </select> </div> </div> + {userId && ( + <div className="sm:col-span-6"> + <Input control={control} name="id" label="UUID" required={false} disabled /> + </div> + )} </> )} </div> diff --git a/renovate.json b/renovate.json index 70fbf6501a77c71f7f5faa476bbc18c566fc08fc..3644c8c9361cc137031cbd352145bb7f4dade9be 100644 --- a/renovate.json +++ b/renovate.json @@ -5,5 +5,12 @@ ], "npm": { "enabled": false - } + }, + "packageRules": [ + { + "matchDepNames": ["node"], + "matchFiles": ["frontend/Dockerfile", ".gitlab-ci.yml"], + "allowedVersions": "!/^\d*[13579](-.*)?$/" + } + ] } diff --git a/run_app.sh b/run_app.sh index b4d203d9e219474aa2a57b229d1855e501485dd2..26e94e18936b32d7a93103ed8586bc24b0707900 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