Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'oauth2-after-rebase' into psaAlpha
- Loading branch information
Showing
6 changed files
with
218 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
45 changes: 45 additions & 0 deletions
45
lib/galaxy/model/migrate/versions/0136_add_oauth2_table.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |