Skip to content

Commit

Permalink
Merge branch 'oauth2-after-rebase' into psaAlpha
Browse files Browse the repository at this point in the history
  • Loading branch information
VJalili committed Jan 15, 2018
2 parents 1e8e60a + 596ef27 commit 3afc67d
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 1 deletion.
8 changes: 8 additions & 0 deletions config/oauth2_config.xml.sample
@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<OAuth2.0>
<provider name="Google">
<!-- Keep the client_secret file on a path inaccessible to public. -->
<client_secret_file>./oauth2/client_secret.json</client_secret_file>
<redirect_uri>https://usegalaxy.org/oauth2callback/google</redirect_uri>
</provider>
</OAuth2.0>
2 changes: 2 additions & 0 deletions lib/galaxy/app.py
Expand Up @@ -173,9 +173,11 @@ def __init__(self, **kwargs):
)
self.heartbeat.daemon = True
self.application_stack.register_postfork_function(self.heartbeat.start)

if self.config.enable_oidc:
from galaxy.authnz import managers
self.authnz_manager = managers.AuthnzManager(self, self.config.oidc_config, self.config.oidc_backends_config)

self.sentry_client = None
if self.config.sentry_dsn:

Expand Down
136 changes: 136 additions & 0 deletions lib/galaxy/authnz/oidc_idp_google.py
@@ -0,0 +1,136 @@
"""
Contains implementations for authentication and authorization against Google identity provider.
This package follows "authorization code flow" authentication protocol to authenticate
Galaxy users against third-party identity providers.
"""

import logging
import httplib2
import hashlib
import json
from xml.etree.ElementTree import ParseError
from datetime import datetime
from datetime import timedelta
from ..authnz import IdentityProvider
from oauth2client import client, GOOGLE_TOKEN_URI, GOOGLE_REVOKE_URI


log = logging.getLogger(__name__)


class OIDCIdPGoogle(IdentityProvider):
def __init__(self, config):
client_secret_file = config.find('client_secret_file')
if client_secret_file is None:
log.error("Did not find `client_secret_file` key in the configuration; skipping the node '{}'."
.format(config.get('name')))
raise ParseError
redirect_uri = config.find('redirect_uri')
if redirect_uri is None:
log.error("Did not find `redirect_uri` key in the configuration; skipping the node '{}'."
.format(config.get('name')))
raise ParseError
self.provider = 'Google'
self.client_secret_file = client_secret_file.text
self.redirect_uri = redirect_uri.text

def _redirect_uri(self, trans):
# Prepare authentication flow.
flow = client.flow_from_clientsecrets(
self.client_secret_file,
scope=['openid', 'email', 'profile'],
redirect_uri=self.redirect_uri)
flow.params['access_type'] = 'offline' # This asks google to send back a `refresh token`.
flow.params['prompt'] = 'consent'

# Include the following parameter only if we need 'incremental authorization',
# however, current application scenario does not seem to require it.
# flow.params['include_granted_scopes'] = "true"

# A anti-forgery state token. This token will be sent back from Google upon user authentication.
state_token = hashlib.sha256(str(trans.user.username)).hexdigest()
flow.params['state'] = state_token
user_oauth2 = trans.app.model.UserOAuth2(trans.user.id, self.provider, state_token)
trans.sa_session.add(user_oauth2)
trans.sa_session.flush()
return flow.step1_get_authorize_url()

def _refresh_access_token(self, trans, authn_record): # TODO: handle `Bad Request` error
with open(self.client_secret_file) as file:
client_secret = json.load(file)['web']
credentials = client.OAuth2Credentials(
None, client_secret['client_id'], client_secret['client_secret'],
authn_record.refresh_token, None, GOOGLE_TOKEN_URI, None, revoke_uri=GOOGLE_REVOKE_URI)
credentials.refresh(httplib2.Http())
access_token = credentials.get_access_token()
authn_record.id_token = credentials.id_token_jwt
authn_record.access_token = access_token['access_token']
authn_record.expiration_date = datetime.now() + timedelta(seconds=access_token['expires_in'])
trans.sa_session.commit()
trans.sa_session.flush()

def authenticate(self, trans):
query_result = trans.sa_session.query(
trans.app.model.UserOAuth2).filter(
trans.app.model.UserOAuth2.table.c.user_id == trans.user.id).filter(
trans.app.model.UserOAuth2.table.c.provider == self.provider)
if query_result.count() == 1:
authn_record = query_result.first()
if authn_record.expiration_date is not None \
and authn_record.expiration_date < datetime.now() + timedelta(minutes=15):
self._refresh_access_token(trans, authn_record)
elif query_result.count() > 1:
log.critical(
"Found `{}` records for user `{}` authentication against `{}` identity provider; at most one "
"record should exist. Now deleting all the `{}` records and prompt re-authentication.".format(
query_result.count(), trans.user.username, self.provider, query_result.count()))
for record in query_result:
trans.sa_session.delete(record)
trans.sa_session.flush()
return self._redirect_uri(trans)

def callback(self, state_token, authz_code, trans):
query_result = trans.sa_session.query(trans.app.model.UserOAuth2).filter(
trans.app.model.UserOAuth2.table.c.provider == self.provider).filter(
trans.app.model.UserOAuth2.table.c.state_token == state_token)
if query_result.count() > 1:
log.critical(
"Found `{}` records for user `{}` authentication against `{}` identity provider; at most one "
"record should exist. Now deleting all the `{}` records and prompt re-authentication.".format(
query_result.count(), trans.user.username, self.provider, query_result.count()))
for record in query_result:
trans.sa_session.delete(record)
trans.sa_session.flush()
return False # results in re-authentication.
if query_result.count() == 0:
log.critical("Found `0` records for user `{}` authentication against `{}` identity provider; "
"an improperly initiated authentication flow. Now prompting re-authentication."
.format(trans.user.username, self.provider))
return False # results in re-authentication.
# A callback should follow a request from Galaxy; and if a request was (successfully) made by Galaxy,
# the a record in the `galaxy_user_oauth2` table with valid `user_id`, `provider`, and `state_token`
# should exist (see _redirect_uri function). Since such record does not exist, Galaxy should not
# trust the token, and does not attempt to associate with a user. Alternatively, we could linearly scan
# the users table and find a username which creates a `state_token` matching the received `state_token`,
# but it is safer to retry authentication instead.
# Prepare authentication flow.
flow = client.flow_from_clientsecrets(
self.client_secret_file,
scope=['openid', 'email', 'profile'],
redirect_uri=self.redirect_uri)
# Exchanges an authorization code for OAuth2Credentials.
# The credentials object holds refresh and access tokens
# that authorize access to a single user's data.
credentials = flow.step2_exchange(authz_code)
access_token_info = credentials.get_access_token()
user_oauth_record = query_result.first()
user_oauth_record.id_token = credentials.id_token_jwt
user_oauth_record.refresh_token = credentials.refresh_token
user_oauth_record.expiration_date = datetime.now() + timedelta(seconds=access_token_info.expires_in)
user_oauth_record.access_token = access_token_info.access_token
trans.sa_session.flush()
log.debug("User `{}` authentication against `Google` identity provider is successfully saved."
.format(trans.user.username))
return True
2 changes: 1 addition & 1 deletion lib/galaxy/dependencies/pinned-requirements.txt
Expand Up @@ -90,7 +90,7 @@ ecdsa==0.13
# GenomeSpace dependencies
python-genomespaceclient==0.1.8

# OpendID Connect
# OpendID Connect & OAuth2.0
oauthlib==1.0.3
pyjwkest==1.4.0
PyJWT==1.4.0
Expand Down
45 changes: 45 additions & 0 deletions lib/galaxy/model/migrate/versions/0136_add_oauth2_table.py
@@ -0,0 +1,45 @@
"""
Migration script to add a new table named `galaxy_user_oauth2` for authentication and authorization.
"""
from __future__ import print_function

import logging

from sqlalchemy import Column, DateTime, ForeignKey, Integer, MetaData, Table, TEXT

log = logging.getLogger(__name__)
metadata = MetaData()

UserOAuth2Table = Table(
"galaxy_user_oauth2", metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("galaxy_user.id"), nullable=False, index=True),
Column("provider", TEXT, nullable=False),
Column("state_token", TEXT, nullable=False, index=True),
Column("id_token", TEXT),
Column("refresh_token", TEXT),
Column("expiration_date", DateTime),
Column("access_token", TEXT))


def upgrade(migrate_engine):
print(__doc__)
metadata.bind = migrate_engine
metadata.reflect()

# Create UserOAuth2Table
try:
UserOAuth2Table.create()
except Exception as e:
log.exception("Creating UserOAuth2 table failed: %s" % str(e))


def downgrade(migrate_engine):
metadata.bind = migrate_engine
metadata.reflect()

# Drop UserOAuth2Table
try:
UserOAuth2Table.drop()
except Exception as e:
log.exception("Dropping UserOAuth2 table failed: %s" % str(e))
26 changes: 26 additions & 0 deletions lib/galaxy/webapps/galaxy/controllers/oauth2.py
@@ -0,0 +1,26 @@
"""
OAuth 2.0 and OpenID Connect Authentication and Authorization Controller.
"""

from __future__ import absolute_import
import logging
log = logging.getLogger(__name__)
from galaxy import web
from galaxy.web.base.controller import BaseUIController


class OAuth2(BaseUIController):

@web.expose
@web.require_login("authenticate against Google identity provider")
def google_authn(self, trans, **kwargs):
if trans.user is None:
# Only logged in users are allowed here.
return
return trans.response.send_redirect(web.url_for(trans.app.authnz_manager.authenticate("Google", trans)))

@web.expose
def google_callback(self, trans, **kwargs):
if trans.app.authnz_manager.callback("Google", kwargs['state'], kwargs['code'], trans) is False:
# TODO: inform the user why he/she is being re-authenticated.
self.google_authn(trans)

0 comments on commit 3afc67d

Please sign in to comment.