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/package.json b/package.json index b5d6e58f27..b8f87f3e53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redash-client", - "version": "24.09.0-dev", + "version": "24.10.0-dev", "description": "The frontend part of Redash.", "main": "index.js", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index dd67129dfc..b6c1900d6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ force-exclude = ''' [tool.poetry] name = "redash" -version = "24.09.0-dev" +version = "24.10.0-dev" description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data." authors = ["Arik Fraimovich "] # to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord diff --git a/redash/__init__.py b/redash/__init__.py index 0bd35de321..4459042d24 100644 --- a/redash/__init__.py +++ b/redash/__init__.py @@ -14,7 +14,7 @@ from redash.destinations import import_destinations from redash.query_runner import import_query_runners -__version__ = "24.09.0-dev" +__version__ = "24.10.0-dev" if os.environ.get("REMOTE_DEBUG"): 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..17bd9e1351 --- /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_ISSUER_URL) + oauth = OAuth(app) + oauth.register( + name="oidc", + server_metadata_url=settings.OIDC_ISSUER_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 ad905036bf..8beaf3bad0 100644 --- a/redash/handlers/authentication.py +++ b/redash/handlers/authentication.py @@ -27,6 +27,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): try: user_id = validate_token(token) @@ -88,12 +98,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, @@ -203,6 +216,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", @@ -210,7 +224,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 75c438cff2..934479a97a 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -138,6 +138,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_ISSUER_URL = os.environ.get("REDASH_OIDC_ISSUER_URL", "") +OIDC_ENABLED = bool(OIDC_CLIENT_ID and OIDC_CLIENT_SECRET and OIDC_ISSUER_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 %} diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8cfbef69eb..5ce33ab8df 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -17,6 +17,7 @@ get_login_url, hmac_load_user_from_request, jwt_auth, + oidc, org_settings, sign, ) @@ -177,6 +178,32 @@ def test_prefers_api_key_over_session_user_id(self): self.assertEqual(rv.status_code, 200) +class TestCreateAndLoginUserOIDC(BaseTestCase): + def test_logins_valid_user(self): + user = self.factory.create_user(email="test@example.com") + with patch("redash.authentication.login_user") as login_user_mock: + oidc.create_and_login_user(self.factory.org, user.name, user.email) + login_user_mock.assert_called_once_with(user, remember=True) + + def test_creates_vaild_new_user(self): + email = "test@example.com" + name = "Test User" + + with patch("redash.authentication.login_user") as login_user_mock: + oidc.create_and_login_user(self.factory.org, name, email) + + self.assertTrue(login_user_mock.called) + user = models.User.query.filter(models.User.email == email).one() + self.assertEqual(user.email, email) + + def test_updates_user_name(self): + user = self.factory.create_user(email="test@example.com") + + with patch("redash.authentication.login_user") as login_user_mock: + create_and_login_user(self.factory.org, "New Name", user.email) + login_user_mock.assert_called_once_with(user, remember=True) + + class TestCreateAndLoginUser(BaseTestCase): def test_logins_valid_user(self): user = self.factory.create_user(email="test@example.com")