diff --git a/backend/web/login/login.py b/backend/web/login/login.py index e03b03ed3f07a347a40897f4b49aa0e485cb0647..2abbcc01e3bb35fea3b599c7ae08c4e5c27d3bb6 100644 --- a/backend/web/login/login.py +++ b/backend/web/login/login.py @@ -4,9 +4,10 @@ Hydra for OIDC sessions and MariaDB for application and role specifications. The application provides also several command line options to interact with the user entries in the database(s)""" +import ast +import json import urllib.parse import urllib.request -import ast import ory_hydra_client # hydra v2 @@ -129,17 +130,23 @@ def login(): """ # Check if we are logged in: - identity = get_auth() + (identity, auth_response) = get_auth() + # We ignore the potential `auth_response` in this case: that's for telling + # the user they have to upgrade their session to include a second factor, + # but we're already on the login page so there's no use for that here -- + # they'd be redirected by Kratos back to this same login page anyway, + # creating a redirect loop. Chances are that if `auth_response` is not + # None, we're actually serving or processing the TOTP form here. # List to contain messages pushed to the frontend messages = list() - refresh = False flow = request.args.get("flow") if flow: cookies = request.headers['cookie'] flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies) + # current_app.logger.info("flow found in login: {}".format(flow)) refresh = flow['refresh'] if refresh: message = { @@ -241,7 +248,12 @@ def auth(): abort(400, description="Challenge required when requesting authorization") # Check if we are logged in: - identity = get_auth() + (identity, auth_response) = get_auth() + + if auth_response is not None: + # According to `get_auth`, we have to send the user a response already, + # probably a redirect to let the user provide their second factor. + return auth_response # If the user is not logged in yet, we redirect to the login page # but before we do that, we set the "flow_state" cookie to auth. @@ -464,7 +476,7 @@ def status(): Show if there is an user is logged in. If not shows: not-auth """ - auth_status = get_auth() + (auth_status, auth_response) = get_auth() if auth_status: return auth_status.id @@ -475,27 +487,43 @@ def get_auth(): """Checks if user is logged in Queries the cookies. If an authentication cookie is found, it checks with Kratos if the cookie is still valid. If so, - the profile is returned. Otherwise False is returned. - :return: Profile or False if not logged in + the profile is returned. Otherwise False is returned, possibly with a + response to send to the user, for redirecting them to the kratos-suggested + url, for providing 2FA in particular. + :return: (Profile, None) or (False, None) or (False, Response) """ cookie = get_kratos_cookie() if not cookie: - return False + return False, None # Given a cookie, check if it is valid and get the profile try: api_response = kratos_public_frontend_api.to_session(cookie=cookie) # Get all traits from ID - return api_response.identity + return api_response.identity, None except ory_kratos_client.ApiException as ex: + # If it fails because the client needs to provide 2FA, we return a + # redirect response for use by the caller of this function. + if ex.body is not None: + body = json.loads(ex.body) + current_app.logger.info("Error in to_session: {}".format(body)) + error_id = body.get('error', {}).get('id') + if error_id == 'session_aal2_required': + current_app.logger.info("2FA requested by Kratos. Redirecting the user.") + redirect_url = body.get('redirect_browser_to') + if redirect_url is None: + response = None + else: + response = redirect(redirect_url) + return False, response current_app.logger.error( f"Exception when calling to_session(): {ex}\n" ) - return False + return False, None def get_kratos_cookie(): diff --git a/backend/web/static/base.js b/backend/web/static/base.js index 61d08200d5409383f40d02e7aa7d32530eb10142..bf79733c8c7eaf990a84556bf5753d67430be9e9 100644 --- a/backend/web/static/base.js +++ b/backend/web/static/base.js @@ -16,8 +16,8 @@ */ -// In default configuration the dashboed is on '/'. This can be overwritten -// before calling the scripts (and configured by the flask app +// In default configuration the dashboard is on '/'. This can be overwritten +// before calling the scripts (and configured by the flask app). var dashboard_url = ""; // Render a message by appending the data to the messages box. The message id is @@ -42,6 +42,7 @@ function renderMessage(id, message, type) { // case. function check_flow_auth() { var state = Cookies.get("flow_state"); + window.console.log("check_flow_auth: flow_state=" + state); var url = Cookies.get("auth_url"); // Redirect to the specified URL @@ -51,6 +52,15 @@ function check_flow_auth() { return; } + if (state == "recovery") { + Cookies.set("flow_state", ""); + // Set a custom cookie so the settings page knows we're in + // recovery context and can open the right tab. + Cookies.set("stackspin_context", "recovery"); + window.location.href = api_url + '/self-service/settings/browser'; + return; + } + // Some older stackspin releases, do not provide the dashboard_url, // flask writes 'None' as string in that case, we want to cover those // cases and revert to the default @@ -76,7 +86,6 @@ function check_flow_expired() { function flow_login() { var flow = $.urlParam("flow"); var uri = api_url + "self-service/login/flows?id=" + flow; - // Query the Kratos backend to know what fields to render for the // current flow $.ajax({ @@ -112,7 +121,6 @@ function flow_login() { function flow_settings_validate() { var flow = $.urlParam("flow"); var uri = api_url + "self-service/settings/flows?id=" + flow; - $.ajax({ type: "GET", url: uri, @@ -153,6 +161,7 @@ function flow_settings() { url: uri, success: function (data) { var state = Cookies.get("flow_state"); + var context = Cookies.get("stackspin_context"); // If we have confirmation the settings are saved, show the // notification @@ -203,6 +212,12 @@ function flow_settings() { }, }); }); + + // If we are in recovery context, switch to the password tab. + if (context == "recovery") { + $('#pills-password-tab').tab('show'); + Cookies.set('stackspin_context', ''); + } }, complete: function (obj) { // If we get a 410, the flow is expired, need to refresh the flow @@ -211,6 +226,25 @@ function flow_settings() { window.location.href = "settings"; } }, + error: function (xhr, textStatus, errorThrown) { + // Check if we got a 403 error from Kratos. + if (textStatus == "error" && xhr.status == 403) { + var response = $.parseJSON(xhr.responseText); + window.console.log(response); + if (response.error.id == "session_aal2_required") { + // Redirect so user can enter 2FA. + window.location.href = response.redirect_browser_to; + return; + } + } + // There was another error, one we don't specifically prepared for. + $("#contentProfileSaveFailed").show(); + + // 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", "validation"); + $("#contentPassword").html(html); + }, }); } diff --git a/docker-compose.yml b/docker-compose.yml index 7a275345a40ad6ee607fff4e84f7daa809d152bd..d8861940817bcf271a7dc059792c7ba2d57b88fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - "3000:3000" command: "yarn start --watch --verbose" stackspin_proxy: - image: nginx:1.25.1 + image: nginx:1.25.2 ports: - "8081:8081" volumes: @@ -57,7 +57,7 @@ services: - kube_port_mysql entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"] kube_port_kratos_admin: - image: bitnami/kubectl:1.27.4 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 8000 @@ -70,7 +70,7 @@ services: "kubectl -n stackspin port-forward --address $$(hostname -i) service/kratos-admin 8000:80", ] kube_port_hydra_admin: - image: bitnami/kubectl:1.27.4 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 4445 @@ -83,7 +83,7 @@ services: "kubectl -n stackspin port-forward --address $$(hostname -i) service/hydra-admin 4445:4445", ] kube_port_kratos_public: - image: bitnami/kubectl:1.27.4 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" ports: - "8080:8080" @@ -98,7 +98,7 @@ services: "kubectl -n stackspin port-forward --address 0.0.0.0 service/kratos-public 8080:80", ] kube_port_mysql: - image: bitnami/kubectl:1.27.4 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 3306