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..1407fcb6e9
--- /dev/null
+++ b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx
@@ -0,0 +1,45 @@
+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 null;
+ }
+
+ 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..774e02286f 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,10 @@ export default function AuthSettings(props) {
changes => {
const allSettings = { ...values, ...changes };
const allAuthMethodsDisabled =
- !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 };
}
@@ -31,6 +35,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/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
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)):