From 76961262f4ca2620c76d1a6e509de5d454dde58b Mon Sep 17 00:00:00 2001 From: Palash Karmore Date: Tue, 27 Feb 2024 11:07:54 +0530 Subject: [PATCH] Add generic OIDC authentication flow (#6781) - Added a generic authentication flow for OIDC. - Modified some templates to display OIDC login button. - Added new env variables to support the OIDC auth flow. Mainly, OIDC discovery endpoint is configurable now. - Added new routs for OIDC auth. - Manually tested same flow with Google (without domain verification) and AWS Cognito. --- client/app/assets/images/openid.svg | 1 + redash/authentication/__init__.py | 7 +- redash/authentication/oidc.py | 106 ++++++++++++++++++++++++++++ redash/handlers/authentication.py | 16 +++++ redash/settings/__init__.py | 5 ++ redash/templates/invite.html | 9 ++- redash/templates/login.html | 7 ++ 7 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 client/app/assets/images/openid.svg create mode 100644 redash/authentication/oidc.py diff --git a/client/app/assets/images/openid.svg b/client/app/assets/images/openid.svg new file mode 100644 index 0000000000..06c237cb80 --- /dev/null +++ b/client/app/assets/images/openid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/redash/authentication/__init__.py b/redash/authentication/__init__.py index c7fa638085..fef3fd30e3 100644 --- a/redash/authentication/__init__.py +++ b/redash/authentication/__init__.py @@ -242,6 +242,7 @@ def init_app(app): from redash.authentication.google_oauth import ( create_google_oauth_blueprint, ) + from redash.authentication.oidc import create_oidc_blueprint login_manager.init_app(app) login_manager.anonymous_user = models.AnonymousUser @@ -257,12 +258,14 @@ def extend_session(): # Authlib's flask oauth client requires a Flask app to initialize for blueprint in [ create_google_oauth_blueprint(app), + create_oidc_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]: - csrf.exempt(blueprint) - app.register_blueprint(blueprint) + if blueprint: + csrf.exempt(blueprint) + app.register_blueprint(blueprint) user_logged_in.connect(log_user_logged_in) login_manager.request_loader(request_loader) diff --git a/redash/authentication/oidc.py b/redash/authentication/oidc.py new file mode 100644 index 0000000000..0718554de2 --- /dev/null +++ b/redash/authentication/oidc.py @@ -0,0 +1,106 @@ +import logging + +import requests +from authlib.integrations.flask_client import OAuth +from flask import Blueprint, flash, redirect, request, session, url_for + +from redash import models, settings +from redash.authentication import ( + create_and_login_user, + get_next_path, + logout_and_redirect_to_index, +) +from redash.authentication.org_resolving import current_org + + +def create_oidc_blueprint(app): + if not settings.OIDC_ENABLED: + return None + + oauth = OAuth(app) + + logger = logging.getLogger("oidc") + blueprint = Blueprint("oidc", __name__) + + def get_oidc_config(url): + resp = requests.get(url=url) + if resp.status_code != 200: + logger.warning( + f"Unable to get configuration details (response code {resp.status_code}). Configuration URL: {url}" + ) + return None + return resp.json() + + oidc_config = get_oidc_config(settings.OIDC_COFIGURATION_URL) + oauth = OAuth(app) + oauth.register( + name="oidc", + server_metadata_url=settings.OIDC_COFIGURATION_URL, + client_kwargs={ + "scope": "openid email profile", + }, + ) + + def get_user_profile(access_token): + headers = {"Authorization": "Bearer {}".format(access_token)} + response = requests.get(oidc_config["userinfo_endpoint"], headers=headers) + + if response.status_code == 401: + logger.warning("Failed getting user profile (response code 401).") + return None + + return response.json() + + @blueprint.route("//oidc", endpoint="authorize_org") + def org_login(org_slug): + session["org_slug"] = current_org.slug + return redirect(url_for(".authorize", next=request.args.get("next", None))) + + @blueprint.route("/oidc", endpoint="authorize") + def login(): + redirect_uri = url_for(".callback", _external=True) + + next_path = request.args.get("next", url_for("redash.index", org_slug=session.get("org_slug"))) + logger.debug("Callback url: %s", redirect_uri) + logger.debug("Next is: %s", next_path) + + session["next_url"] = next_path + + return oauth.oidc.authorize_redirect(redirect_uri) + + @blueprint.route("/oidc/callback", endpoint="callback") + def authorized(): + logger.debug("Authorized user inbound") + + resp = oauth.oidc.authorize_access_token() + user = resp.get("userinfo") + if user: + session["user"] = user + + access_token = resp["access_token"] + + if access_token is None: + logger.warning("Access token missing in call back request.") + flash("Validation error. Please retry.") + return redirect(url_for("redash.login")) + + profile = get_user_profile(access_token) + if profile is None: + flash("Validation error. Please retry.") + return redirect(url_for("redash.login")) + + if "org_slug" in session: + org = models.Organization.get_by_slug(session.pop("org_slug")) + else: + org = current_org + + user = create_and_login_user(org, profile["name"], profile["email"]) + if user is None: + return logout_and_redirect_to_index() + + unsafe_next_path = session.get("next_url") or url_for("redash.index", org_slug=org.slug) + next_path = get_next_path(unsafe_next_path) + + return redirect(next_path) + + return blueprint diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py index 62b20531c3..088f31f135 100644 --- a/redash/handlers/authentication.py +++ b/redash/handlers/authentication.py @@ -28,6 +28,16 @@ def get_google_auth_url(next_path): return google_auth_url +def get_oidc_auth_url(next_path): + if not settings.OIDC_ENABLED: + return None + if settings.MULTI_ORG: + oidc_auth_url = url_for("oidc.authorize_org", next=next_path, org_slug=current_org.slug) + else: + oidc_auth_url = url_for("oidc.authorize", next=next_path) + return oidc_auth_url + + def render_token_login_page(template, org_slug, token, invite): error_message = None try: @@ -90,12 +100,15 @@ def render_token_login_page(template, org_slug, token, invite): return redirect(url_for("redash.index", org_slug=org_slug)) google_auth_url = get_google_auth_url(url_for("redash.index", org_slug=org_slug)) + oidc_auth_url = get_oidc_auth_url(url_for("redash.index", org_slug=org_slug)) return ( render_template( template, show_google_openid=settings.GOOGLE_OAUTH_ENABLED, + show_oidc_login=settings.OIDC_ENABLED, google_auth_url=google_auth_url, + oidc_auth_url=oidc_auth_url, show_saml_login=current_org.get_setting("auth_saml_enabled"), show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED, show_ldap_login=settings.LDAP_LOGIN_ENABLED, @@ -205,6 +218,7 @@ def login(org_slug=None): flash("Password login is not enabled for your organization.") google_auth_url = get_google_auth_url(next_path) + oidc_auth_url = get_oidc_auth_url(next_path) return render_template( "login.html", @@ -212,7 +226,9 @@ def login(org_slug=None): next=next_path, email=request.form.get("email", ""), show_google_openid=settings.GOOGLE_OAUTH_ENABLED, + show_oidc_login=settings.OIDC_ENABLED, google_auth_url=google_auth_url, + oidc_auth_url=oidc_auth_url, show_password_login=current_org.get_setting("auth_password_login_enabled"), show_saml_login=current_org.get_setting("auth_saml_enabled"), show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED, diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 973beffc55..111b4631ec 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -139,6 +139,11 @@ GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "") GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) +OIDC_CLIENT_ID = os.environ.get("REDASH_OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = os.environ.get("REDASH_OIDC_CLIENT_SECRET", "") +OIDC_COFIGURATION_URL = os.environ.get("REDASH_OIDC_COFIGURATION_URL", "") +OIDC_ENABLED = bool(OIDC_CLIENT_ID and OIDC_CLIENT_SECRET and OIDC_COFIGURATION_URL) + # If Redash is behind a proxy it might sometimes receive a X-Forwarded-Proto of HTTP # even if your actual Redash URL scheme is HTTPS. This will cause Flask to build # the SAML redirect URL incorrect thus failing auth. This is especially common if diff --git a/redash/templates/invite.html b/redash/templates/invite.html index e52dbe4b41..5882289b83 100644 --- a/redash/templates/invite.html +++ b/redash/templates/invite.html @@ -6,7 +6,7 @@
- {% if show_google_openid or show_saml_login or show_remote_user_login or show_ldap_login %} + {% if show_google_openid or show_oidc_login or show_saml_login or show_remote_user_login or show_ldap_login %} To create your account, please choose a password or login with your SSO provider. {% else %} To create your account, please choose a password. @@ -28,6 +28,13 @@ {% endif %} + {% if show_oidc_login %} + + {% endif %} + {% if show_saml_login %} {% endif %} diff --git a/redash/templates/login.html b/redash/templates/login.html index 926a084444..51ffa455d2 100644 --- a/redash/templates/login.html +++ b/redash/templates/login.html @@ -20,6 +20,13 @@ {% endif %} + {% if show_oidc_login %} + + {% endif %} + {% if show_saml_login %} {% endif %}