-
Notifications
You must be signed in to change notification settings - Fork 1.1k
User authentication using OpenID Connect protocol #4474
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
Changes from 31 commits
f88c8c3
b593368
6aa59d1
fbb28b9
a9ea8d5
a66bf82
432b551
3a90758
57f5a5f
a8705f9
5f55f56
41cc0dc
d355108
2e4e28e
c027e31
2326add
7ba12a1
7c0cc5e
9dc0403
734a005
64d1cd8
1481603
7ae287d
f622206
bd2c30b
e0199f2
0e55897
3714a92
875a79a
9087deb
fe41dcb
df2cf94
6f50966
18c0130
80718cd
f2150af
4829f2d
55c0b4b
8148375
9205cf2
5d8b8e1
e6bb39d
6734638
bef0ad9
071e174
9c3ec35
ed0d2d4
3352c3e
6238849
88dc7e3
7c1a846
b8704f8
cd6b934
04cb179
54f3033
1a10718
bd46767
6f27d09
d39c9c3
3cb9fd0
98e8408
61bbdde
e290fea
8eba74c
98d5910
2c77032
5f7206a
ae0e173
35482fd
55362e4
b3c1f3c
dec858f
55cb550
54374ae
9151c0d
3104ae1
bdb2c3e
ba5312f
40609a8
0d0493e
4674670
2e0e049
8d6669f
d5a295b
60d2794
ed77f12
7249a1c
a1b9842
794746e
e168eaf
009248b
d2ad569
0623793
0663e57
e9ba0b9
aa07653
ba05d28
33f2a8f
ab130f7
cb4645f
cd9783e
b086802
10e8053
c05c3c1
0b12196
ba18313
74b27d2
294bdf4
b4bdfe5
969a2e4
6935adc
7552952
0b3b756
22b4859
3ae1552
a17d8dd
8385954
2165447
be1acdc
0c85d90
bc854b0
596ef27
1e8e60a
3afc67d
cd92485
1502e0d
d85c354
f06d793
b88339e
2a6f6e1
0f4ec2f
cab0b48
8980e82
26c91f2
218b88e
4e7b39e
b951bb2
3c4ceb0
a992d1f
d1ac819
c7ab4e2
362cfca
68acf98
2f479e1
181d86f
5c13e50
ba3eaec
6ea87b5
654206e
6dcdcc4
730b931
ea0ebd8
f634c40
320cf47
6b998e6
60e936c
5ffaf9f
259daf1
86e13a5
1263161
7f1e07b
cd7e0b1
1ed2b8f
00d48f0
d01be5d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. secrets contained in these config files are (unless something is very wrong) inaccessible to the public. I would rather enter my client ID / client secret in this XML file rather than in a separate json file. Compare with |
||
<redirect_uri>https://usegalaxy.org/oauth2callback/google</redirect_uri> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This URL could be constructed, and not admin-supplied. You can use something like |
||
</provider> | ||
</OAuth2.0> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was there a reason to create this folder as separate from other auth stuff? Otherwise I think it would make more sense to have with the rest of the pluggable auth, under There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Though their logic might sound similar to some degree, but they have their own flow, constructors, managers, and backends (providers) which do not necessarily overlap with each other. Hence IMHO merging these two might be counter-intuitive. |
||
Contains implementations for authentication and authorization against third-party | ||
OAuth2.0 authorization servers and OpenID Connect Identity providers. | ||
|
||
This package follows "authorization code flow" authentication protocol to authenticate | ||
Galaxy users against third-party identity providers. | ||
|
||
Additionally, this package implements functionalist's to request temporary access | ||
credentials for cloud-based resource providers (e.g., Amazon AWS, Microsoft Azure). | ||
""" | ||
|
||
import logging | ||
import xml.etree.ElementTree as ET | ||
from xml.etree.ElementTree import ParseError | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
class IdentityProvider(object): | ||
""" | ||
OpenID Connect Identity Provider abstract interface. | ||
""" | ||
|
||
def __init__(self, config): | ||
""" | ||
Initialize the identity provider using the provided configuration, | ||
and raise a ParseError (or any more related specific exception) in | ||
case the configuration is malformed. | ||
|
||
:type config: xml.etree.ElementTree.Element | ||
:param config: Is the configuration element of the provider | ||
from the configuration file (e.g., OAuth2_config.xml). | ||
This element contains the all the provider-specific | ||
configuration elements. | ||
""" | ||
raise NotImplementedError() | ||
|
||
def authenticate(self, trans): | ||
"""Runs for authentication process. Checks the database if a | ||
valid identity exists in the database; if yes, then the user | ||
is authenticated, if not, it generates a provider-specific | ||
authentication flow and returns redirect URI to the controller. | ||
|
||
:type trans: GalaxyWebTransaction | ||
:param trans: Galaxy web transaction. | ||
|
||
:return: a redirect URI to the provider's authentication | ||
endpoint. | ||
""" | ||
raise NotImplementedError() | ||
|
||
def callback(self, state_token, authz_code, trans): | ||
""" | ||
Handles authentication call-backs from identity providers. | ||
This process maps `state-token` to a user | ||
:type state_token: string | ||
:param state_token: is an anti-forgery token which identifies | ||
a Galaxy user to whom the given authorization code belongs to. | ||
:type authz_code: string | ||
:param authz_code: a very short-lived, single-use token to | ||
request a refresh token. | ||
:type trans: GalaxyWebTransaction | ||
:param trans: Galaxy web transaction. | ||
:return boolean: | ||
True: if callback is handled successfully. | ||
False: if processing callback fails, then Galaxy attempts re-authentication. | ||
""" | ||
raise NotImplementedError() | ||
|
||
|
||
class AuthnzManager(object): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should only be AuthN. |
||
|
||
def __init__(self, config): | ||
""" | ||
:type config: string | ||
:param config: sets the path for OAuth2.0 configuration | ||
file (e.g., OAuth2_config.xml). | ||
""" | ||
self._parse_config(config) | ||
|
||
def _parse_config(self, config): | ||
self.providers = {} | ||
try: | ||
tree = ET.parse(config) | ||
root = tree.getroot() | ||
if root.tag != 'OAuth2.0': | ||
raise ParseError("The root element in OAuth2.0 config xml file is expected to be `OAuth2.0`, " | ||
"found `{}` instead -- unable to continue.".format(root.tag)) | ||
for child in root: | ||
if child.tag != 'provider': | ||
log.error("Expect a node with `provider` tag, found a node with `{}` tag instead; " | ||
"skipping the node.".format(child.tag)) | ||
continue | ||
if 'name' not in child.attrib: | ||
log.error("Could not find a node attribute 'name'; skipping the node '{}'.".format(child.tag)) | ||
continue | ||
provider = child.get('name') | ||
try: | ||
if provider == 'Google': | ||
from .oidc_idp_google import OIDCIdPGoogle | ||
self.providers[provider] = OIDCIdPGoogle(child) | ||
except ParseError: | ||
log.error("Could not initialize `{}` identity provider; skipping this node.".format(provider)) | ||
continue | ||
if len(self.providers) == 0: | ||
raise ParseError("No valid provider configuration parsed.") | ||
except ImportError: | ||
raise | ||
except ParseError as e: | ||
raise ParseError("Invalid configuration at `{}`: {} -- unable to continue.".format(config, e.message)) | ||
except Exception: | ||
raise ParseError("Malformed OAuth2.0 Configuration XML -- unable to continue.") | ||
|
||
def authenticate(self, provider, trans): | ||
""" | ||
:type provider: string | ||
:param provider: set the name of the identity provider to be | ||
used for authentication flow. | ||
:type trans: GalaxyWebTransaction | ||
:param trans: Galaxy web transaction. | ||
:return: an identity provider specific authentication redirect URI. | ||
""" | ||
if provider in self.providers: | ||
try: | ||
return self.providers[provider].authenticate(trans) | ||
except: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. bare exception |
||
raise | ||
else: | ||
log.error("The provider '{}' is not a recognized and expected provider.".format(provider)) | ||
|
||
def callback(self, provider, state_token, authz_code, trans): | ||
if provider in self.providers: | ||
try: | ||
return self.providers[provider].callback(state_token, authz_code, trans) | ||
except: | ||
raise | ||
else: | ||
raise NameError("The provider '{}' is not a recognized and expected provider.".format(provider)) |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -80,4 +80,8 @@ pysam==0.8.4+gx5 | |
chronos-python==0.38.0 | ||
|
||
# GenomeSpace dependencies | ||
python-genomespaceclient==0.1.8 | ||
python-genomespaceclient==0.1.8 | ||
|
||
# OpendID Connect & OAuth2.0 | ||
httplib2==0.10.3 | ||
oauth2client==4.1.2 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What was the rationale for choosing oauth2client over something like PSA? http://python-social-auth.readthedocs.io/en/latest/intro.html PSA gives a huge number of backends implemented for free, that might be a more attractive option than tying us to a custom google oauth2 implementation. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What was the rationale for choosing oauth2client over something like PSA? http://python-social-auth.readthedocs.io/en/latest/intro.html |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this be merged in with
config/auth_conf.xml
with<type>oauth2</type>
or something similar?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@erasche Not sure
config/auth_conf.xml.sample
is a good fit, because it may contain multiple auth mechanisms which are tried in sequence (unless<continue-on-failure>False</continue-on-failure>
is specified for a matched authenticator) using the provided username and password.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nsoranzo good point, I had forgotten that those were tried serially with credentials. Just didn't want to manage an extra file if it was a possibility.