Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/app/assets/images/microsoft_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 (
<DynamicComponent name="OrganizationSettings.AzureLoginSettings" {...props}>
<h4>Microsoft Work or School Account Login</h4>
<Form.Item label="Allowed User Domains">
<Select
mode="tags"
value={values.auth_azure_apps_domains}
onChange={value => onChange({ auth_azure_apps_domains: value })}
/>
{!isEmpty(values.auth_azure_apps_domains) && (
<Alert
message={
<p>
Any user registered with a <strong>{join(values.auth_azure_apps_domains, ", ")}</strong> work or school
account will be able to login. If they don't have an existing user, a new user will be created and join
the <strong>Default</strong> group.
</p>
}
className="m-t-15"
/>
)}
</Form.Item>
</DynamicComponent>
);
}

AzureLoginSettings.propTypes = SettingsEditorPropTypes;

AzureLoginSettings.defaultProps = SettingsEditorDefaultProps;
7 changes: 6 additions & 1 deletion client/app/pages/settings/components/AuthSettings/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 };
}
Expand All @@ -31,6 +35,7 @@ export default function AuthSettings(props) {
<hr />
<PasswordLoginSettings {...props} onChange={handleChange} />
<GoogleLoginSettings {...props} onChange={handleChange} />
<AzureLoginSettings {...props} onChange={handleChange} />
<SAMLSettings {...props} onChange={handleChange} />
</DynamicComponent>
);
Expand Down
3 changes: 2 additions & 1 deletion redash/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
139 changes: 139 additions & 0 deletions redash/authentication/azure_oauth.py
Original file line number Diff line number Diff line change
@@ -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("/<org_slug>/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
25 changes: 25 additions & 0 deletions redash/cli/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
25 changes: 21 additions & 4 deletions redash/cli/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -89,6 +96,7 @@ def create(
groups,
is_admin=False,
google_auth=False,
azure_auth=False,
password=None,
organization="default",
):
Expand All @@ -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:
Expand All @@ -132,14 +141,21 @@ 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",
default=None,
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.
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading