From 7c7b404e5719da4168f6191f216e0f4148300356 Mon Sep 17 00:00:00 2001
From: George Fisher
Date: Fri, 15 Jul 2022 11:32:32 -0700
Subject: [PATCH 01/10] Azure Oauth Login for Redash
---
client/app/assets/images/microsoft_logo.svg | 1 +
.../AuthSettings/AzureLoginSettings.jsx | 49 +++++++++++++++++++
.../components/AuthSettings/index.jsx | 4 +-
redash/authentication/__init__.py | 3 +-
redash/cli/organization.py | 25 ++++++++++
redash/cli/users.py | 25 ++++++++--
redash/handlers/authentication.py | 20 +++++++-
redash/handlers/settings.py | 4 ++
redash/models/organizations.py | 5 ++
redash/settings/__init__.py | 5 ++
redash/templates/invite.html | 11 ++++-
redash/templates/login.html | 9 +++-
tests/handlers/test_settings.py | 20 ++++++++
tests/test_authentication.py | 41 ++++++++++++++++
14 files changed, 211 insertions(+), 11 deletions(-)
create mode 100644 client/app/assets/images/microsoft_logo.svg
create mode 100644 client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
diff --git a/client/app/assets/images/microsoft_logo.svg b/client/app/assets/images/microsoft_logo.svg
new file mode 100644
index 0000000000..1f73976483
--- /dev/null
+++ b/client/app/assets/images/microsoft_logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
new file mode 100644
index 0000000000..abeee43e1b
--- /dev/null
+++ b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
@@ -0,0 +1,49 @@
+import { isEmpty, join } from "lodash";
+import React from "react";
+import Form from "antd/lib/form";
+import Select from "antd/lib/select";
+import Alert from "antd/lib/alert";
+import DynamicComponent from "@/components/DynamicComponent";
+import { clientConfig } from "@/services/auth";
+import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
+
+export default function AzureLoginSettings(props) {
+ const { values, onChange } = props;
+
+ if (!clientConfig.azureLoginEnabled) {
+ return (
+
+ azure login enabled {clientConfig.azureLoginEnabled}
+
+ );
+ }
+
+ return (
+
+ Microsoft Work or School Account Login
+
+
+ }
+ className="m-t-15"
+ />
+ )}
+
+
+ );
+}
+
+AzureLoginSettings.propTypes = SettingsEditorPropTypes;
+
+AzureLoginSettings.defaultProps = SettingsEditorDefaultProps;
diff --git a/client/app/pages/settings/components/AuthSettings/index.jsx b/client/app/pages/settings/components/AuthSettings/index.jsx
index 5d74f20aee..85cd89160c 100644
--- a/client/app/pages/settings/components/AuthSettings/index.jsx
+++ b/client/app/pages/settings/components/AuthSettings/index.jsx
@@ -6,6 +6,7 @@ import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-typ
import PasswordLoginSettings from "./PasswordLoginSettings";
import GoogleLoginSettings from "./GoogleLoginSettings";
+import AzureLoginSettings from "./AzureLoginSettings";
import SAMLSettings from "./SAMLSettings";
export default function AuthSettings(props) {
@@ -14,7 +15,7 @@ export default function AuthSettings(props) {
changes => {
const allSettings = { ...values, ...changes };
const allAuthMethodsDisabled =
- !clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !allSettings.auth_saml_enabled;
+ !clientConfig.azureLoginEnabled &&!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !allSettings.auth_saml_enabled;
if (allAuthMethodsDisabled) {
changes = { ...changes, auth_password_login_enabled: true };
}
@@ -31,6 +32,7 @@ export default function AuthSettings(props) {
+
);
diff --git a/redash/authentication/__init__.py b/redash/authentication/__init__.py
index f06cd3cdb2..6d10467d7f 100644
--- a/redash/authentication/__init__.py
+++ b/redash/authentication/__init__.py
@@ -249,6 +249,7 @@ def init_app(app):
)
from redash.authentication.google_oauth import create_google_oauth_blueprint
+ from redash.authentication.azure_oauth import create_azure_oauth_blueprint
login_manager.init_app(app)
login_manager.anonymous_user = models.AnonymousUser
@@ -262,7 +263,7 @@ def extend_session():
from redash.security import csrf
# Authlib's flask oauth client requires a Flask app to initialize
- for blueprint in [create_google_oauth_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]:
+ for blueprint in [create_google_oauth_blueprint(app), create_azure_oauth_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]:
csrf.exempt(blueprint)
app.register_blueprint(blueprint)
diff --git a/redash/cli/organization.py b/redash/cli/organization.py
index 45c73551fc..5e95381a33 100644
--- a/redash/cli/organization.py
+++ b/redash/cli/organization.py
@@ -33,6 +33,31 @@ def show_google_apps_domains():
)
)
+@manager.command()
+@argument("domains")
+def set_azure_apps_domains(domains):
+ """
+ Sets the allowable domains to the comma separated list DOMAINS.
+ """
+ organization = models.Organization.query.first()
+ k = models.Organization.SETTING_AZURE_APPS_DOMAINS
+ organization.settings[k] = domains.split(",")
+ models.db.session.add(organization)
+ models.db.session.commit()
+ print(
+ "Updated list of allowed domains to: {}".format(
+ organization.azure_apps_domains
+ )
+ )
+
+@manager.command()
+def show_azure_apps_domains():
+ organization = models.Organization.query.first()
+ print(
+ "Current list of Azure Apps domains: {}".format(
+ ", ".join(organization.azure_apps_domains)
+ )
+ )
@manager.command(name="list")
def list_command():
diff --git a/redash/cli/users.py b/redash/cli/users.py
index fc6a4420ee..3493eb289a 100644
--- a/redash/cli/users.py
+++ b/redash/cli/users.py
@@ -71,6 +71,13 @@ def grant_admin(email, organization="default"):
default=False,
help="user uses Google Auth to login",
)
+@option(
+ "--azure",
+ "azure_auth",
+ is_flag=True,
+ default=False,
+ help="user uses Azure AD Auth to login",
+)
@option(
"--password",
"password",
@@ -89,6 +96,7 @@ def create(
groups,
is_admin=False,
google_auth=False,
+ azure_auth=False,
password=None,
organization="default",
):
@@ -98,14 +106,15 @@ def create(
print("Creating user (%s, %s) in organization %s..." % (email, name, organization))
print("Admin: %r" % is_admin)
print("Login with Google Auth: %r\n" % google_auth)
+ print("Login with Azure Auth: %r\n" % azure_auth)
org = models.Organization.get_by_slug(organization)
groups = build_groups(org, groups, is_admin)
user = models.User(org=org, email=email, name=name, group_ids=groups)
- if not password and not google_auth:
+ if not password and not google_auth and not azure_auth:
password = prompt("Password", hide_input=True, confirmation_prompt=True)
- if not google_auth:
+ if not google_auth and not azure_auth:
user.hash_password(password)
try:
@@ -132,6 +141,13 @@ def create(
default=False,
help="user uses Google Auth to login",
)
+@option(
+ "--azure",
+ "azure_auth",
+ is_flag=True,
+ default=False,
+ help="user uses Azure AD Auth to login",
+)
@option(
"--password",
"password",
@@ -139,7 +155,7 @@ def create(
help="Password for root user who don't use Google Auth "
"(leave blank for prompt).",
)
-def create_root(email, name, google_auth=False, password=None, organization="default"):
+def create_root(email, name, google_auth=False, azure_auth=False, password=None, organization="default"):
"""
Create root user.
"""
@@ -148,6 +164,7 @@ def create_root(email, name, google_auth=False, password=None, organization="def
% (email, name, organization)
)
print("Login with Google Auth: %r\n" % google_auth)
+ print("Login with Azure Auth: %r\n" % azure_auth)
user = models.User.query.filter(models.User.email == email).first()
if user is not None:
@@ -183,7 +200,7 @@ def create_root(email, name, google_auth=False, password=None, organization="def
name=name,
group_ids=[admin_group.id, default_group.id],
)
- if not google_auth:
+ if not google_auth and not azure_auth:
user.hash_password(password)
try:
diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py
index c4e467c2eb..d09c946a8e 100644
--- a/redash/handlers/authentication.py
+++ b/redash/handlers/authentication.py
@@ -29,7 +29,15 @@ def get_google_auth_url(next_path):
else:
google_auth_url = url_for("google_oauth.authorize", next=next_path)
return google_auth_url
-
+
+def get_azure_auth_url(next_path):
+ if settings.MULTI_ORG:
+ azure_auth_url = url_for(
+ "azure_oauth.authorize_org", next=next_path, org_slug=current_org.slug
+ )
+ else:
+ azure_auth_url = url_for("azure_oauth.authorize", next=next_path)
+ return azure_auth_url
def render_token_login_page(template, org_slug, token, invite):
try:
@@ -94,11 +102,15 @@ def render_token_login_page(template, org_slug, token, invite):
google_auth_url = get_google_auth_url(url_for("redash.index", org_slug=org_slug))
+ azure_auth_url = get_azure_auth_url(url_for("redash.index", org_slug=org_slug))
+
return (
render_template(
template,
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
google_auth_url=google_auth_url,
+ show_azure_openid=settings.AZURE_OAUTH_ENABLED,
+ azure_auth_url=azure_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,
@@ -219,9 +231,10 @@ def login(org_slug=None):
flash("Password login is not enabled for your organization.")
-
google_auth_url = get_google_auth_url(next_path)
+ azure_auth_url = get_azure_auth_url(next_path)
+
return render_template(
"login.html",
org_slug=org_slug,
@@ -229,6 +242,8 @@ def login(org_slug=None):
email=request.form.get("email", ""),
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
google_auth_url=google_auth_url,
+ show_azure_openid=settings.AZURE_OAUTH_ENABLED,
+ azure_auth_url=azure_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,
@@ -302,6 +317,7 @@ def client_config():
"dashboardRefreshIntervals": settings.DASHBOARD_REFRESH_INTERVALS,
"queryRefreshIntervals": settings.QUERY_REFRESH_INTERVALS,
"googleLoginEnabled": settings.GOOGLE_OAUTH_ENABLED,
+ "azureLoginEnabled": settings.AZURE_OAUTH_ENABLED,
"ldapLoginEnabled": settings.LDAP_LOGIN_ENABLED,
"pageSize": settings.PAGE_SIZE,
"pageSizeOptions": settings.PAGE_SIZE_OPTIONS,
diff --git a/redash/handlers/settings.py b/redash/handlers/settings.py
index d684f42c35..68f7e6528f 100644
--- a/redash/handlers/settings.py
+++ b/redash/handlers/settings.py
@@ -21,6 +21,7 @@ def get_settings_with_defaults(defaults, org):
settings[setting] = current_value
settings["auth_google_apps_domains"] = org.google_apps_domains
+ settings["auth_azure_apps_domains"] = org.azure_apps_domains
return settings
@@ -44,6 +45,9 @@ def post(self):
if k == "auth_google_apps_domains":
previous_values[k] = self.current_org.google_apps_domains
self.current_org.settings[Organization.SETTING_GOOGLE_APPS_DOMAINS] = v
+ elif k == "auth_azure_apps_domains":
+ previous_values[k] = self.current_org.azure_apps_domains
+ self.current_org.settings[Organization.SETTING_AZURE_APPS_DOMAINS] = v
else:
previous_values[k] = self.current_org.get_setting(
k, raise_on_missing=False
diff --git a/redash/models/organizations.py b/redash/models/organizations.py
index 18dd9f1898..0e580050c0 100644
--- a/redash/models/organizations.py
+++ b/redash/models/organizations.py
@@ -12,6 +12,7 @@
@generic_repr("id", "name", "slug")
class Organization(TimestampMixin, db.Model):
SETTING_GOOGLE_APPS_DOMAINS = "google_apps_domains"
+ SETTING_AZURE_APPS_DOMAINS = "azure_apps_domains"
SETTING_IS_PUBLIC = "is_public"
id = primary_key("Organization")
@@ -44,6 +45,10 @@ def default_group(self):
def google_apps_domains(self):
return self.settings.get(self.SETTING_GOOGLE_APPS_DOMAINS, [])
+ @property
+ def azure_apps_domains(self):
+ return self.settings.get(self.SETTING_AZURE_APPS_DOMAINS, [])
+
@property
def is_public(self):
return self.settings.get(self.SETTING_IS_PUBLIC, False)
diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py
index 8b9392d1eb..523d833d26 100644
--- a/redash/settings/__init__.py
+++ b/redash/settings/__init__.py
@@ -171,6 +171,11 @@
GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
+AZURE_CLIENT_ID = os.environ.get("REDASH_AZURE_CLIENT_ID", "")
+AZURE_CLIENT_SECRET = os.environ.get("REDASH_AZURE_CLIENT_SECRET", "")
+AZURE_TENANT_ID = os.environ.get("REDASH_AZURE_TENANT", "")
+AZURE_OAUTH_ENABLED = bool(AZURE_CLIENT_ID and AZURE_CLIENT_SECRET)
+
# 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..daeabf12c5 100644
--- a/redash/templates/invite.html
+++ b/redash/templates/invite.html
@@ -27,7 +27,14 @@
Login with Google
{% endif %}
-
+
+ {% if show_azure_openid %}
+
+
+ Sign in with Microsoft work or school account
+
+ {% endif %}
+
{% if show_saml_login %}
SAML Login
{% endif %}
@@ -40,7 +47,7 @@
LDAP/SSO Login
{% endif %}
- {% if show_google_openid or show_saml_login or show_remote_user_login or show_ldap_login %}
+ {% if show_google_openid or show_azure_openid or show_saml_login or show_remote_user_login or show_ldap_login %}
{% endif %}
diff --git a/redash/templates/login.html b/redash/templates/login.html
index 926a084444..f5438991ef 100644
--- a/redash/templates/login.html
+++ b/redash/templates/login.html
@@ -19,6 +19,13 @@
Login with Google
{% endif %}
+
+ {% if show_azure_openid %}
+
+
+ Sign in with Microsoft work or school account
+
+ {% endif %}
{% if show_saml_login %}
SAML Login
@@ -33,7 +40,7 @@
{% endif %}
{% if show_password_login %}
- {% if show_google_openid or show_saml_login or show_remote_user_login or show_ldap_login %}
+ {% if show_google_openid or show_azure_openid or show_saml_login or show_remote_user_login or show_ldap_login %}
{% endif %}
diff --git a/tests/handlers/test_settings.py b/tests/handlers/test_settings.py
index 6c9e33b9a9..e94c4257fc 100644
--- a/tests/handlers/test_settings.py
+++ b/tests/handlers/test_settings.py
@@ -40,6 +40,18 @@ def test_updates_google_apps_domains(self):
updated_org = Organization.get_by_slug(self.factory.org.slug)
self.assertEqual(updated_org.google_apps_domains, domains)
+ def test_updates_azure_apps_domains(self):
+ admin = self.factory.create_admin()
+ domains = ["example.com"]
+ rv = self.make_request(
+ "post",
+ "/api/settings/organization",
+ data={"auth_azure_apps_domains": domains},
+ user=admin,
+ )
+ updated_org = Organization.get_by_slug(self.factory.org.slug)
+ self.assertEqual(updated_org.azure_apps_domains, domains)
+
def test_get_returns_google_appas_domains(self):
admin = self.factory.create_admin()
domains = ["example.com"]
@@ -47,3 +59,11 @@ def test_get_returns_google_appas_domains(self):
rv = self.make_request("get", "/api/settings/organization", user=admin)
self.assertEqual(rv.json["settings"]["auth_google_apps_domains"], domains)
+
+ def test_get_returns_azure_appas_domains(self):
+ admin = self.factory.create_admin()
+ domains = ["example.com"]
+ admin.org.settings[Organization.SETTING_AZURE_APPS_DOMAINS] = domains
+
+ rv = self.make_request("get", "/api/settings/organization", user=admin)
+ self.assertEqual(rv.json["settings"]["auth_azure_apps_domains"], domains)
diff --git a/tests/test_authentication.py b/tests/test_authentication.py
index 91be52ea76..141f288c3d 100644
--- a/tests/test_authentication.py
+++ b/tests/test_authentication.py
@@ -12,6 +12,7 @@
sign,
)
from redash.authentication.google_oauth import create_and_login_user, verify_profile
+from redash.authentication.azure_oauth import create_and_login_user, verify_profile as verify_profile_azure
from redash.utils import utcnow
from sqlalchemy.orm.exc import NoResultFound
from tests import BaseTestCase
@@ -238,6 +239,46 @@ def test_user_not_in_domain_but_account_exists(self):
self.assertTrue(verify_profile(self.factory.org, profile))
+class TestVerifyProfileAzure(BaseTestCase):
+ def test_no_domain_allowed_for_org(self):
+ profile = dict(email="arik@example.com")
+ self.assertFalse(verify_profile_azure(self.factory.org, profile))
+
+ def test_domain_not_in_org_domains_list(self):
+ profile = dict(email="arik@example.com")
+ self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [
+ "example.org"
+ ]
+ self.assertFalse(verify_profile_azure(self.factory.org, profile))
+
+ def test_domain_in_org_domains_list(self):
+ profile = dict(email="arik@example.com")
+ self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [
+ "example.com"
+ ]
+ self.assertTrue(verify_profile_azure(self.factory.org, profile))
+
+ self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [
+ "example.org",
+ "example.com",
+ ]
+ self.assertTrue(verify_profile_azure(self.factory.org, profile))
+
+ def test_org_in_public_mode_accepts_any_domain(self):
+ profile = dict(email="arik@example.com")
+ self.factory.org.settings[models.Organization.SETTING_IS_PUBLIC] = True
+ self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = []
+ self.assertTrue(verify_profile_azure(self.factory.org, profile))
+
+ def test_user_not_in_domain_but_account_exists(self):
+ profile = dict(email="arik@example.com")
+ self.factory.create_user(email="arik@example.com")
+ self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [
+ "example.org"
+ ]
+ self.assertTrue(verify_profile_azure(self.factory.org, profile))
+
+
class TestGetLoginUrl(BaseTestCase):
def test_when_multi_org_enabled_and_org_exists(self):
with self.app.test_request_context("/{}/".format(self.factory.org.slug)):
From 926bce45456eeeebc6a0141c6a2c600f7c5519ee Mon Sep 17 00:00:00 2001
From: George Fisher
Date: Fri, 15 Jul 2022 22:51:32 -0700
Subject: [PATCH 02/10] Missing file
---
redash/authentication/azure_oauth.py | 139 +++++++++++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 redash/authentication/azure_oauth.py
diff --git a/redash/authentication/azure_oauth.py b/redash/authentication/azure_oauth.py
new file mode 100644
index 0000000000..06189d013c
--- /dev/null
+++ b/redash/authentication/azure_oauth.py
@@ -0,0 +1,139 @@
+import logging
+import requests
+from flask import redirect, url_for, Blueprint, flash, request, session
+
+
+from redash import models, settings
+from redash.authentication import (
+ create_and_login_user,
+ logout_and_redirect_to_index,
+ get_next_path,
+)
+from redash.authentication.org_resolving import current_org
+
+from authlib.integrations.flask_client import OAuth
+
+
+def verify_profile(org, profile):
+ if org.is_public:
+ return True
+
+ email = profile["email"]
+ domain = email.split("@")[-1]
+
+ if domain in org.azure_apps_domains:
+ return True
+
+ if org.has_user(email) == 1:
+ return True
+
+ return False
+
+
+def create_azure_oauth_blueprint(app):
+ oauth = OAuth(app)
+
+ logger = logging.getLogger("azure_oauth")
+ blueprint = Blueprint("azure_oauth", __name__)
+
+ if not settings.AZURE_TENANT_ID:
+ # multi-tenant
+ CONF_URL = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"
+ else:
+ CONF_URL = "https://login.microsoftonline.com/" + settings.AZURE_TENANT_ID + "/v2.0/.well-known/openid-configuration"
+
+ oauth = OAuth(app)
+ oauth.register(
+ name="azure",
+ server_metadata_url=CONF_URL,
+ client_kwargs={"scope": "openid email profile"},
+ )
+
+ def get_user_profile(access_token):
+ headers = {"Authorization": "Bearer {}".format(access_token)}
+
+ logger.debug("Graph call =" + access_token + "=")
+
+ response = requests.get(
+ "https://graph.microsoft.com/oidc/userinfo", headers=headers
+ )
+
+ if response.status_code == 401:
+ logger.warning("Failed getting user profile (response code 401).")
+ return None
+
+ return response.json()
+
+ @blueprint.route("//oauth/azure", 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("/oauth/azure", 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.azure.authorize_redirect(redirect_uri)
+
+ @blueprint.route("/oauth/azure_callback", endpoint="callback")
+ def authorized():
+
+ logger.debug("Authorized user inbound")
+
+ resp = oauth.azure.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
+
+ if not verify_profile(org, profile):
+ logger.warning(
+ "User tried to login with unauthorized domain name: %s (org: %s)",
+ profile["email"],
+ org,
+ )
+ flash(
+ "Your Azure AD account ({}) isn't allowed.".format(profile["email"])
+ )
+ return redirect(url_for("redash.login", org_slug=org.slug))
+
+ # Do not read picture URL as applications often do not have Graph API permissions
+ 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
From a9b75eda347d802b0e2801e60be737b434c905a7 Mon Sep 17 00:00:00 2001
From: George Fisher
Date: Sat, 16 Jul 2022 11:37:13 -0700
Subject: [PATCH 03/10] Fix additional spacing in login settings page due to
debug statment not removed
---
.../settings/components/AuthSettings/AzureLoginSettings.jsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
index abeee43e1b..1407fcb6e9 100644
--- a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
+++ b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
@@ -11,11 +11,7 @@ export default function AzureLoginSettings(props) {
const { values, onChange } = props;
if (!clientConfig.azureLoginEnabled) {
- return (
-
- azure login enabled {clientConfig.azureLoginEnabled}
-
- );
+ return null;
}
return (
From 4a0e9b98452d44785dd2aa194e64a0fe46278c10 Mon Sep 17 00:00:00 2001
From: George Fisher
Date: Mon, 18 Jul 2022 12:05:00 -0700
Subject: [PATCH 04/10] Add restyled change
---
client/app/pages/settings/components/AuthSettings/index.jsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/client/app/pages/settings/components/AuthSettings/index.jsx b/client/app/pages/settings/components/AuthSettings/index.jsx
index 85cd89160c..b03e60e390 100644
--- a/client/app/pages/settings/components/AuthSettings/index.jsx
+++ b/client/app/pages/settings/components/AuthSettings/index.jsx
@@ -15,7 +15,10 @@ export default function AuthSettings(props) {
changes => {
const allSettings = { ...values, ...changes };
const allAuthMethodsDisabled =
- !clientConfig.azureLoginEnabled &&!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !allSettings.auth_saml_enabled;
+ !clientConfig.googleLoginEnabled &&
+ !clientConfig.azureLoginEnabled &&
+ !clientConfig.ldapLoginEnabled &&
+ !allSettings.auth_saml_enabled;
if (allAuthMethodsDisabled) {
changes = { ...changes, auth_password_login_enabled: true };
}
From 6197a1aa67add035fb24086b934eaed6c3c56f71 Mon Sep 17 00:00:00 2001
From: George Fisher
Date: Mon, 18 Jul 2022 12:08:26 -0700
Subject: [PATCH 05/10] Add restyled change - fix whitespace
---
client/app/pages/settings/components/AuthSettings/index.jsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/client/app/pages/settings/components/AuthSettings/index.jsx b/client/app/pages/settings/components/AuthSettings/index.jsx
index b03e60e390..774e02286f 100644
--- a/client/app/pages/settings/components/AuthSettings/index.jsx
+++ b/client/app/pages/settings/components/AuthSettings/index.jsx
@@ -15,9 +15,9 @@ export default function AuthSettings(props) {
changes => {
const allSettings = { ...values, ...changes };
const allAuthMethodsDisabled =
- !clientConfig.googleLoginEnabled &&
- !clientConfig.azureLoginEnabled &&
- !clientConfig.ldapLoginEnabled &&
+ !clientConfig.googleLoginEnabled &&
+ !clientConfig.azureLoginEnabled &&
+ !clientConfig.ldapLoginEnabled &&
!allSettings.auth_saml_enabled;
if (allAuthMethodsDisabled) {
changes = { ...changes, auth_password_login_enabled: true };
From 57069eca5a1b8de443b25e3982d34929d6dfb9f5 Mon Sep 17 00:00:00 2001
From: George Fisher
Date: Mon, 10 Oct 2022 14:32:22 -0700
Subject: [PATCH 06/10] Add Azure RBAC
---
.../AuthSettings/AzureLoginSettings.jsx | 17 ++++++
redash/authentication/azure_oauth.py | 57 +++++++++++++++++--
redash/handlers/settings.py | 4 ++
redash/models/organizations.py | 5 ++
tests/handlers/test_settings.py | 22 ++++++-
5 files changed, 98 insertions(+), 7 deletions(-)
diff --git a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
index 1407fcb6e9..d55032facd 100644
--- a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
+++ b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
@@ -36,6 +36,23 @@ export default function AzureLoginSettings(props) {
/>
)}
+
+
);
}
diff --git a/redash/authentication/azure_oauth.py b/redash/authentication/azure_oauth.py
index 06189d013c..73827b267f 100644
--- a/redash/authentication/azure_oauth.py
+++ b/redash/authentication/azure_oauth.py
@@ -1,5 +1,7 @@
import logging
import requests
+import base64
+import json
from flask import redirect, url_for, Blueprint, flash, request, session
@@ -29,10 +31,31 @@ def verify_profile(org, profile):
return False
+def get_roles_in_id_token(id_token, logger):
+ logger.debug("Validating ID token")
+ id_token_parts = id_token.split(".")
+ if len(id_token_parts) < 2:
+ logger.warning("Malformed ID token")
+ decoded_token_json = json.loads(base64.b64decode(id_token_parts[1] + '=='))
+ logger.debug("Successfully decoded token")
+ if "roles" in decoded_token_json:
+ roles = decoded_token_json["roles"]
+ logger.debug("Found roles: " + (", ".join(roles)))
+ return roles
+ return []
+
+def verify_roles(org, roles, logger):
+ if org.azure_roles:
+ if not roles:
+ return False
+ for azure_role in org.azure_roles:
+ logger.debug("Verifying role: " + azure_role)
+ if azure_role in roles:
+ logger.debug("Role verified: " + azure_role)
+ return True
+ return False
def create_azure_oauth_blueprint(app):
- oauth = OAuth(app)
-
logger = logging.getLogger("azure_oauth")
blueprint = Blueprint("azure_oauth", __name__)
@@ -52,8 +75,6 @@ def create_azure_oauth_blueprint(app):
def get_user_profile(access_token):
headers = {"Authorization": "Bearer {}".format(access_token)}
- logger.debug("Graph call =" + access_token + "=")
-
response = requests.get(
"https://graph.microsoft.com/oidc/userinfo", headers=headers
)
@@ -95,14 +116,15 @@ def authorized():
session["user"] = user
access_token = resp["access_token"]
+ id_token = resp["id_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:
+ if id_token is None:
+ logger.warning("Id token missing in call back request.")
flash("Validation error. Please retry.")
return redirect(url_for("redash.login"))
@@ -111,6 +133,29 @@ def authorized():
else:
org = current_org
+ profile = get_user_profile(access_token)
+
+ if org.azure_roles:
+ roles = get_roles_in_id_token(id_token, logger)
+ else:
+ roles = []
+
+ if not verify_roles(org, roles, logger):
+ logger.warning(
+ "User tried to login without authorized role assignment: %s. Valid roles are: %s. Provided roles are: %s",
+ profile["email"],
+ ", ".join(org.azure_roles),
+ ", ".join(roles),
+ )
+ flash(
+ "Your Azure AD account ({}) isn't allowed as you are not assigned a required role: {}. Your assigned roles are: {}".format(profile["email"], ", ".join(org.azure_roles), ", ".join(roles))
+ )
+ return redirect(url_for("redash.login", org_slug=org.slug))
+
+ if profile is None:
+ flash("Validation error. Please retry.")
+ return redirect(url_for("redash.login"))
+
if not verify_profile(org, profile):
logger.warning(
"User tried to login with unauthorized domain name: %s (org: %s)",
diff --git a/redash/handlers/settings.py b/redash/handlers/settings.py
index 68f7e6528f..ba30e2f07f 100644
--- a/redash/handlers/settings.py
+++ b/redash/handlers/settings.py
@@ -22,6 +22,7 @@ def get_settings_with_defaults(defaults, org):
settings["auth_google_apps_domains"] = org.google_apps_domains
settings["auth_azure_apps_domains"] = org.azure_apps_domains
+ settings["auth_azure_roles"] = org.azure_roles
return settings
@@ -48,6 +49,9 @@ def post(self):
elif k == "auth_azure_apps_domains":
previous_values[k] = self.current_org.azure_apps_domains
self.current_org.settings[Organization.SETTING_AZURE_APPS_DOMAINS] = v
+ elif k == "auth_azure_roles":
+ previous_values[k] = self.current_org.azure_roles
+ self.current_org.settings[Organization.SETTING_AZURE_ROLES] = v
else:
previous_values[k] = self.current_org.get_setting(
k, raise_on_missing=False
diff --git a/redash/models/organizations.py b/redash/models/organizations.py
index 0e580050c0..76eb10d13b 100644
--- a/redash/models/organizations.py
+++ b/redash/models/organizations.py
@@ -13,6 +13,7 @@
class Organization(TimestampMixin, db.Model):
SETTING_GOOGLE_APPS_DOMAINS = "google_apps_domains"
SETTING_AZURE_APPS_DOMAINS = "azure_apps_domains"
+ SETTING_AZURE_ROLES = "azure_roles"
SETTING_IS_PUBLIC = "is_public"
id = primary_key("Organization")
@@ -49,6 +50,10 @@ def google_apps_domains(self):
def azure_apps_domains(self):
return self.settings.get(self.SETTING_AZURE_APPS_DOMAINS, [])
+ @property
+ def azure_roles(self):
+ return self.settings.get(self.SETTING_AZURE_ROLES, [])
+
@property
def is_public(self):
return self.settings.get(self.SETTING_IS_PUBLIC, False)
diff --git a/tests/handlers/test_settings.py b/tests/handlers/test_settings.py
index e94c4257fc..9b6e02962e 100644
--- a/tests/handlers/test_settings.py
+++ b/tests/handlers/test_settings.py
@@ -52,6 +52,18 @@ def test_updates_azure_apps_domains(self):
updated_org = Organization.get_by_slug(self.factory.org.slug)
self.assertEqual(updated_org.azure_apps_domains, domains)
+ def test_updates_azure_roles(self):
+ admin = self.factory.create_admin()
+ roles = ["admin"]
+ rv = self.make_request(
+ "post",
+ "/api/settings/organization",
+ data={"auth_azure_roles": roles},
+ user=admin,
+ )
+ updated_org = Organization.get_by_slug(self.factory.org.slug)
+ self.assertEqual(updated_org.azure_apps_domains, domains)
+
def test_get_returns_google_appas_domains(self):
admin = self.factory.create_admin()
domains = ["example.com"]
@@ -60,10 +72,18 @@ def test_get_returns_google_appas_domains(self):
rv = self.make_request("get", "/api/settings/organization", user=admin)
self.assertEqual(rv.json["settings"]["auth_google_apps_domains"], domains)
- def test_get_returns_azure_appas_domains(self):
+ def test_get_returns_azure_app_domains(self):
admin = self.factory.create_admin()
domains = ["example.com"]
admin.org.settings[Organization.SETTING_AZURE_APPS_DOMAINS] = domains
rv = self.make_request("get", "/api/settings/organization", user=admin)
self.assertEqual(rv.json["settings"]["auth_azure_apps_domains"], domains)
+
+ def test_get_returns_azure_roles(self):
+ admin = self.factory.create_admin()
+ roles = ["admin"]
+ admin.org.settings[Organization.SETTING_AZURE_ROLES] = domains
+
+ rv = self.make_request("get", "/api/settings/organization", user=admin)
+ self.assertEqual(rv.json["settings"]["auth_azure_roles"], roles)
From 8bad0b9db139ec951e75fd9d51447993f3127ca7 Mon Sep 17 00:00:00 2001
From: "Restyled.io"
Date: Mon, 10 Oct 2022 21:32:33 +0000
Subject: [PATCH 07/10] Restyled by prettier
---
.../settings/components/AuthSettings/AzureLoginSettings.jsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
index d55032facd..5b1fab4020 100644
--- a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
+++ b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
@@ -37,11 +37,7 @@ export default function AzureLoginSettings(props) {
)}
-