Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generic OIDC authentication flow #6783

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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/openid.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 OIDCLoginSettings(props) {
const { values, onChange } = props;

if (!clientConfig.oidcLoginEnabled) {
return null;
}

return (
<DynamicComponent name="OrganizationSettings.OIDCLoginSettings" {...props}>
<h4>OIDC Login</h4>
<Form.Item label="Allowed Domains">
<Select
mode="tags"
value={values.auth_oidc_domains}
onChange={value => onChange({ auth_oidc_domains: value })}
/>
{!isEmpty(values.auth_oidc_domains) && (
<Alert
message={
<p>
Any user registered with a <strong>{join(values.auth_oidc_domains, ", ")} </strong>
<span> </span>domain 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>
);
}

OIDCLoginSettings.propTypes = SettingsEditorPropTypes;

OIDCLoginSettings.defaultProps = SettingsEditorDefaultProps;
4 changes: 3 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 OIDCLoginSettings from "./OIDCLoginSettings";
import SAMLSettings from "./SAMLSettings";

export default function AuthSettings(props) {
Expand All @@ -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.oidcLoginEnabled && !clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !allSettings.auth_saml_enabled;
if (allAuthMethodsDisabled) {
changes = { ...changes, auth_password_login_enabled: true };
}
Expand All @@ -31,6 +32,7 @@ export default function AuthSettings(props) {
<hr />
<PasswordLoginSettings {...props} onChange={handleChange} />
<GoogleLoginSettings {...props} onChange={handleChange} />
<OIDCLoginSettings {...props} onChange={handleChange} />
<SAMLSettings {...props} onChange={handleChange} />
</DynamicComponent>
);
Expand Down
15 changes: 8 additions & 7 deletions redash/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,8 @@ def logout_and_redirect_to_index():

def init_app(app):
from redash.authentication import ldap_auth, remote_user_auth, saml_auth
from redash.authentication.google_oauth import (
create_google_oauth_blueprint,
)
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
Expand All @@ -257,12 +256,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)
Expand All @@ -276,15 +277,15 @@ def create_and_login_user(org, name, email, picture=None):
if user_object.is_invitation_pending:
user_object.is_invitation_pending = False
models.db.session.commit()
if user_object.name != name:
if name and user_object.name != name:
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
user_object.name = name
models.db.session.commit()
except NoResultFound:
logger.debug("Creating user object (%r)", name)
user_object = models.User(
org=org,
name=name,
name=name if name else email,
email=email,
is_invitation_pending=False,
_profile_image_url=picture,
Expand Down
142 changes: 142 additions & 0 deletions redash/authentication/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import logging

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

logger = logging.getLogger(__name__)


def verify_account(org, email):
if org.is_public:
return True

domain = email.split("@")[-1]
logger.info(f"org domains: {org.oidc_domains}")

if domain in org.oidc_domains:
return True

if org.has_user(email) == 1:
return True

return False


def ensure_required_scope(scope):
"""
Ensures that the required scopes 'openid', 'email', and 'profile' are present in the scope string.
"""
scope_set = set(scope.split()) if scope else set()
required_scopes = {"openid", "email", "profile"}
scope_set.update(required_scopes)
return " ".join(scope_set)


def get_name_from_user_info(user_info):
name = user_info.get("name")
if not name:
given_name = user_info.get("given_name", "")
family_name = user_info.get("family_name", "")
name = f"{given_name} {family_name}".strip()
if not name:
name = user_info.get("preferred_username", "")
if not name:
name = user_info.get("nickname", "")
return name


def create_oidc_blueprint(app):
if not settings.OIDC_ENABLED:
return None

oauth = OAuth(app)

blueprint = Blueprint("oidc", __name__)

oauth = OAuth(app)
scope = ensure_required_scope(settings.OIDC_SCOPE)
oauth.register(
name="oidc",
server_metadata_url=settings.OIDC_ISSUER_URL,
client_kwargs={
"scope": scope,
},
)

@blueprint.route("/<org_slug>/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")

token = oauth.oidc.authorize_access_token()
user_info = oauth.oidc.parse_id_token(token)
if user_info:
session["user"] = user_info
Comment on lines +96 to +98
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would user_info -> userinfo in every single place/file because the endpoint is /userinfo haha (non-issue really)

else:
logger.warning("Unable to get userinfo from returned token")
flash("Validation error. Please retry.")
return redirect(url_for("redash.login"))

access_token = token["access_token"]

if access_token is None:
logger.warning("Access token missing in the callback request.")
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_account(org, user_info["email"]):
logger.warning(
"User tried to login with unauthorized domain name: %s (org: %s)",
user_info["email"],
org,
)
flash("Your account ({}) isn't allowed.".format(user_info["email"]))
return redirect(url_for("redash.login", org_slug=org.slug))

# see if email is verified
email_verified = user_info.get("email_verified", False)
if not email_verified:
flash("Email not verified.")
return redirect(url_for("redash.login"))

user_name = get_name_from_user_info(user_info)

user = create_and_login_user(org, user_name, user_info["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
17 changes: 17 additions & 0 deletions redash/handlers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -205,14 +218,17 @@ 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",
org_slug=org_slug,
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,
Expand Down Expand Up @@ -279,6 +295,7 @@ def client_config():
"dashboardRefreshIntervals": settings.DASHBOARD_REFRESH_INTERVALS,
"queryRefreshIntervals": settings.QUERY_REFRESH_INTERVALS,
"googleLoginEnabled": settings.GOOGLE_OAUTH_ENABLED,
"oidcLoginEnabled": settings.OIDC_ENABLED,
"ldapLoginEnabled": settings.LDAP_LOGIN_ENABLED,
"pageSize": settings.PAGE_SIZE,
"pageSizeOptions": settings.PAGE_SIZE_OPTIONS,
Expand Down
4 changes: 4 additions & 0 deletions redash/handlers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_oidc_domains"] = org.oidc_domains

return settings

Expand All @@ -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_oidc_domains":
previous_values[k] = self.current_org.oidc_domains
self.current_org.settings[Organization.SETTING_OIDC_DOMAINS] = v
else:
previous_values[k] = self.current_org.get_setting(k, raise_on_missing=False)
self.current_org.set_setting(k, v)
Expand Down
5 changes: 5 additions & 0 deletions redash/models/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
class Organization(TimestampMixin, db.Model):
SETTING_GOOGLE_APPS_DOMAINS = "google_apps_domains"
SETTING_IS_PUBLIC = "is_public"
SETTING_OIDC_DOMAINS = "oidc_domains"

id = primary_key("Organization")
name = Column(db.String(255))
Expand Down Expand Up @@ -43,6 +44,10 @@ def default_group(self):
def google_apps_domains(self):
return self.settings.get(self.SETTING_GOOGLE_APPS_DOMAINS, [])

@property
def oidc_domains(self):
return self.settings.get(self.SETTING_OIDC_DOMAINS, [])

@property
def is_public(self):
return self.settings.get(self.SETTING_IS_PUBLIC, False)
Expand Down
6 changes: 6 additions & 0 deletions redash/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@
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)
OIDC_SCOPE = os.environ.get("REDASH_OIDC_SCOPE", "openid email profile")

# 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
Expand Down
9 changes: 8 additions & 1 deletion redash/templates/invite.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<div class="fixed-width-page">
<div class="bg-white tiled">
<div class="m-b-25">
{% 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.
Expand All @@ -28,6 +28,13 @@
</a>
{% endif %}

{% if show_oidc_login %}
<a href="{{ oidc_auth_url }}" class="login-button btn btn-default btn-block">
<img src="/static/images/openid.svg">
Login with OIDC
</a>
{% endif %}

{% if show_saml_login %}
<a href="{{ url_for('saml_auth.sp_initiated', org_slug=org_slug) }}" class="login-button btn btn-default btn-block">SAML Login</a>
{% endif %}
Expand Down
Loading
Loading