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

Restyle Feature: GitHub OAuth support #4700

Closed
wants to merge 7 commits into from
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/github_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 35 additions & 1 deletion client/app/pages/settings/OrganizationSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ class OrganizationSettings extends React.Component {
}

disablePasswordLoginToggle = () =>
!(clientConfig.googleLoginEnabled || clientConfig.ldapLoginEnabled || this.state.formValues.auth_saml_enabled);
!(
clientConfig.googleLoginEnabled ||
clientConfig.ldapLoginEnabled ||
clientConfig.githubLoginEnabled ||
this.state.formValues.auth_saml_enabled
);

handleSubmit = e => {
e.preventDefault();
Expand Down Expand Up @@ -103,6 +108,34 @@ class OrganizationSettings extends React.Component {
);
}

renderGithubLoginOptions() {
const { formValues } = this.state;
return (
<React.Fragment>
<h4>Github Login</h4>
<Form.Item label="Allowed GitHub Apps Domains">
<Select
mode="tags"
value={formValues.auth_github_apps_domains}
onChange={value => this.handleChange("auth_github_apps_domains", value)}
/>
{!isEmpty(formValues.auth_github_apps_domains) && (
<Alert
message={
<p>
Any user registered with a <strong>{join(formValues.auth_github_apps_domains, ", ")}</strong> GitHub
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>
</React.Fragment>
);
}

renderSAMLOptions() {
const { formValues } = this.state;
return (
Expand Down Expand Up @@ -244,6 +277,7 @@ class OrganizationSettings extends React.Component {
</Checkbox>
</Form.Item>
{clientConfig.googleLoginEnabled && this.renderGoogleLoginOptions()}
{clientConfig.githubLoginEnabled && this.renderGithubLoginOptions()}
{this.renderSAMLOptions()}
</React.Fragment>
);
Expand Down
2 changes: 2 additions & 0 deletions redash/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def logout_and_redirect_to_index():
def init_app(app):
from redash.authentication import (
google_oauth,
github_oauth,
saml_auth,
remote_user_auth,
ldap_auth,
Expand All @@ -252,6 +253,7 @@ def init_app(app):
login_manager.anonymous_user = models.AnonymousUser

app.register_blueprint(google_oauth.blueprint)
app.register_blueprint(github_oauth.blueprint)
app.register_blueprint(saml_auth.blueprint)
app.register_blueprint(remote_user_auth.blueprint)
app.register_blueprint(ldap_auth.blueprint)
Expand Down
132 changes: 132 additions & 0 deletions redash/authentication/github_oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import logging
import requests
from flask import redirect, url_for, Blueprint, flash, request, session
from flask_oauthlib.client import OAuth

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

logger = logging.getLogger('github_oauth')

oauth = OAuth()
blueprint = Blueprint('github_oauth', __name__)


def github_remote_app():
if 'github' not in oauth.remote_apps:
oauth.remote_app('github',
base_url='https://api.github.com/',
authorize_url='https://github.com/login/oauth/authorize',
request_token_url=None,
request_token_params={'scope': 'user:email'},
access_token_url='https://github.com/login/oauth/access_token',
access_token_method='POST',
consumer_key=settings.GITHUB_CLIENT_ID,
consumer_secret=settings.GITHUB_CLIENT_SECRET)

return oauth.github


def get_user_profile(access_token):
headers = {'Authorization': 'token {}'.format(access_token)}
response = requests.get('https://api.github.com/user', headers=headers)

if response.status_code == 401:
logger.warning("Failed getting user profile (response code 401).")
return None

return response.json()


def get_user_emails(access_token):
headers = {'Authorization': 'token {}'.format(access_token)}
response = requests.get('https://api.github.com/user/emails', headers=headers)

if response.status_code == 401:
logger.warning("Failed getting user profile (response code 401).")
return None

return response.json()


def verify_profile(org, profile, profile_emails):
if org.is_public:
return True

emails = [obj['email'] for obj in profile_emails]
for email in emails:
domain = email.split('@')[-1]
if domain in org.github_apps_domains:
profile['email'] = email
return True

if org.has_user(email) == 1:
profile['email'] = email
return True

return False


@blueprint.route('/<org_slug>/oauth/github', 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/github', endpoint="authorize")
def login():
callback = 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", callback)
logger.debug("Next is: %s", next_path)
return github_remote_app().authorize(callback=callback, state=next_path)


@blueprint.route('/oauth/github_callback', endpoint="callback")
def authorized():
resp = github_remote_app().authorized_response()
if 'error' in resp:
logger.warning("Incorrect GitHub client configurations: %s", resp['error'])
return redirect(resp['error_uri'])

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'))

emails = get_user_emails(access_token)
if emails 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, emails):
logger.warning("User tried to login with unauthorized domain name: %s (org: %s)", profile['email'], org)
flash("Your GitHub Apps account ({}) isn't allowed.".format(profile['email']))
return redirect(url_for('redash.login', org_slug=org.slug))

picture_url = "%s" % profile['avatar_url']
user = create_and_login_user(org, profile['name'], profile['email'], picture_url)
if user is None:
return logout_and_redirect_to_index()

unsafe_next_path = request.args.get('state') or url_for("redash.index", org_slug=org.slug)
next_path = get_next_path(unsafe_next_path)

return redirect(next_path)
15 changes: 15 additions & 0 deletions redash/handlers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ def get_google_auth_url(next_path):
return google_auth_url


def get_github_auth_url(next_path):
if settings.MULTI_ORG:
github_auth_url = url_for('github_oauth.authorize_org', next=next_path, org_slug=current_org.slug)
else:
github_auth_url = url_for('github_oauth.authorize', next=next_path)
return github_auth_url


def render_token_login_page(template, org_slug, token, invite):
try:
user_id = validate_token(token)
Expand Down Expand Up @@ -93,12 +101,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))
github_auth_url = get_github_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_github_openid=settings.GITHUB_OAUTH_ENABLED,
github_auth_url=github_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 @@ -215,6 +226,7 @@ def login(org_slug=None):
flash("Wrong email or password.")

google_auth_url = get_google_auth_url(next_path)
github_auth_url = get_github_auth_url(next_path)

return render_template(
"login.html",
Expand All @@ -223,6 +235,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_github_openid=settings.GITHUB_OAUTH_ENABLED,
github_auth_url=github_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 @@ -292,6 +306,7 @@ def client_config():
"dashboardRefreshIntervals": settings.DASHBOARD_REFRESH_INTERVALS,
"queryRefreshIntervals": settings.QUERY_REFRESH_INTERVALS,
"googleLoginEnabled": settings.GOOGLE_OAUTH_ENABLED,
'githubLoginEnabled': settings.GITHUB_OAUTH_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_github_apps_domains'] = org.github_apps_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_github_apps_domains':
previous_values[k] = self.current_org.github_apps_domains
self.current_org.settings[Organization.SETTING_GITHUB_APPS_DOMAINS] = v
else:
previous_values[k] = self.current_org.get_setting(
k, raise_on_missing=False
Expand Down
5 changes: 5 additions & 0 deletions redash/models/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
@generic_repr("id", "name", "slug")
class Organization(TimestampMixin, db.Model):
SETTING_GOOGLE_APPS_DOMAINS = "google_apps_domains"
SETTING_GITHUB_APPS_DOMAINS = 'github_apps_domains'
SETTING_IS_PUBLIC = "is_public"

id = Column(db.Integer, primary_key=True)
Expand Down Expand Up @@ -44,6 +45,10 @@ def default_group(self):
def google_apps_domains(self):
return self.settings.get(self.SETTING_GOOGLE_APPS_DOMAINS, [])

@property
def github_apps_domains(self):
return self.settings.get(self.SETTING_GITHUB_APPS_DOMAINS, [])

@property
def is_public(self):
return self.settings.get(self.SETTING_IS_PUBLIC, False)
Expand Down
4 changes: 4 additions & 0 deletions redash/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@
GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)

GITHUB_CLIENT_ID = os.environ.get("REDASH_GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.environ.get("REDASH_GITHUB_CLIENT_SECRET", "")
GITHUB_OAUTH_ENABLED = bool(GITHUB_CLIENT_ID and GITHUB_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
Expand Down
7 changes: 7 additions & 0 deletions redash/templates/invite.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
</a>
{% endif %}

{% if show_github_openid %}
<a href="{{ github_auth_url }}" class="login-button btn btn-default btn-block">
<img src="/static/images/github_logo.svg">
Login with GitHub
</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
7 changes: 7 additions & 0 deletions redash/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
</a>
{% endif %}

{% if show_github_openid %}
<a href="{{ github_auth_url }}" class="login-button btn btn-default btn-block">
<img src="/static/images/github_logo.svg">
Login with GitHub
</a>
{% endif %}

{% if show_saml_login %}
<a href="{{ url_for('saml_auth.sp_initiated', org_slug=org_slug, next=next) }}" class="login-button btn btn-default btn-block">SAML Login</a>
{% endif %}
Expand Down
2 changes: 2 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
os.environ["REDASH_GOOGLE_CLIENT_ID"] = "dummy"
os.environ["REDASH_GOOGLE_CLIENT_SECRET"] = "dummy"
os.environ["REDASH_MULTI_ORG"] = "true"
os.environ['REDASH_GITHUB_CLIENT_ID'] = "dummy"
os.environ['REDASH_GITHUB_CLIENT_SECRET'] = "dummy"

# Make sure rate limit is enabled
os.environ["REDASH_RATELIMIT_ENABLED"] = "true"
Expand Down
20 changes: 20 additions & 0 deletions tests/handlers/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,23 @@ 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_updates_github_apps_domains(self):
admin = self.factory.create_admin()
domains = ["example.com"]
rv = self.make_request(
"post",
"/api/settings/organization",
data={"auth_github_apps_domains": domains},
user=admin,
)
updated_org = Organization.get_by_slug(self.factory.org.slug)
self.assertEqual(updated_org.github_apps_domains, domains)

def test_get_returns_github_appas_domains(self):
admin = self.factory.create_admin()
domains = ["example.com"]
admin.org.settings[Organization.SETTING_GITHUB_APPS_DOMAINS] = domains

rv = self.make_request("get", "/api/settings/organization", user=admin)
self.assertEqual(rv.json["settings"]["auth_github_apps_domains"], domains)
Loading