Skip to content

Commit

Permalink
have option to setup valid domains to login from
Browse files Browse the repository at this point in the history
- removed redudent userinfo fetch instead parsed id_token for userinfo
- added domains verification for OIDC in UI
- incorporated review comments
- tested with AWS cognito
  • Loading branch information
palash247 committed Feb 18, 2025
1 parent ef4c6c7 commit 18294ee
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 61 deletions.
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
9 changes: 4 additions & 5 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)
Expand Down Expand Up @@ -279,15 +278,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
114 changes: 74 additions & 40 deletions redash/authentication/oidc.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,74 @@
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 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("preffered_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)

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)
scope = ensure_required_scope(settings.OIDC_SCOPE)
oauth.register(
name="oidc",
server_metadata_url=settings.OIDC_ISSUER_URL,
client_kwargs={
"scope": "openid email profile",
"scope": scope,
},
)

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("/<org_slug>/oidc", endpoint="authorize_org")
def org_login(org_slug):
session["org_slug"] = current_org.slug
Expand All @@ -72,20 +90,19 @@ def login():
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.")
token = oauth.oidc.authorize_access_token()
user_info = oauth.oidc.parse_id_token(token)
if user_info:
session["user"] = user_info
else:
logger.warning("Unable to get userinfo from returned token")
flash("Validation error. Please retry.")
return redirect(url_for("redash.login"))

profile = get_user_profile(access_token)
if profile is None:
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"))

Expand All @@ -94,7 +111,24 @@ def authorized():
else:
org = current_org

user = create_and_login_user(org, profile["name"], profile["email"])
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()

Expand Down
10 changes: 4 additions & 6 deletions redash/handlers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@

from redash import __version__, limiter, models, settings
from redash.authentication import current_org, get_login_url, get_next_path
from redash.authentication.account import (
send_password_reset_email,
send_user_disabled_email,
send_verify_email,
validate_token,
)
from redash.authentication.account import (send_password_reset_email,
send_user_disabled_email,
send_verify_email, validate_token)
from redash.handlers import routes
from redash.handlers.base import json_response, org_scoped_rule
from redash.version_check import get_latest_version
Expand Down Expand Up @@ -295,6 +292,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
14 changes: 5 additions & 9 deletions redash/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,10 @@
from flask_talisman import talisman
from funcy import distinct, remove

from redash.settings.helpers import (
add_decode_responses_to_redis_url,
array_from_string,
cast_int_or_default,
fix_assets_path,
int_or_none,
parse_boolean,
set_from_string,
)
from redash.settings.helpers import (add_decode_responses_to_redis_url,
array_from_string, cast_int_or_default,
fix_assets_path, int_or_none,
parse_boolean, set_from_string)
from redash.settings.organization import DATE_FORMAT, TIME_FORMAT # noqa

# _REDIS_URL is the unchanged REDIS_URL we get from env vars, to be used later with RQ
Expand Down Expand Up @@ -143,6 +138,7 @@
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
Expand Down

0 comments on commit 18294ee

Please sign in to comment.