Skip to content

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

Merged
merged 165 commits into from
Apr 11, 2018
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
165 commits
Select commit Hold shift + click to select a range
f88c8c3
OAuth2.0 initial commit.
VJalili Aug 7, 2017
b593368
Added a sample config file and its related parser and setup.
VJalili Aug 8, 2017
6aa59d1
Added UserOAuth2 class to model, and defined a table in mappings.
VJalili Aug 8, 2017
fbb28b9
Formatting ... added an empty line between two classes.
VJalili Aug 8, 2017
a9ea8d5
(1) added provider to the OAuth2.0 model, (2) changed endpoints,
VJalili Aug 8, 2017
a66bf82
Add migrate script for the OAuth2 table.
VJalili Aug 8, 2017
432b551
Add sample OAuth2.0 configuration file, and updated its parser.
VJalili Aug 9, 2017
3a90758
Change endpoint: `OAuth2Authenticate`-> `OAuth2Authentication`
VJalili Aug 9, 2017
57f5a5f
Update OAuth2.0 controller.
VJalili Aug 9, 2017
a8705f9
Migration: Removed `nullable` restriction from 3 OAuth2.0 columns.
VJalili Aug 11, 2017
5f55f56
Change `user` to `user_id`, & set some args optional on UserAuth2 init
VJalili Aug 11, 2017
41cc0dc
Remove nullable restriction from some fields of galaxy_user_oauth2 table
VJalili Aug 11, 2017
d355108
Controller now attempts re-authentication upon False reply from callback
VJalili Aug 11, 2017
2e4e28e
Add an interface for IdPs, & separated Google-specific flow from pare…
VJalili Aug 11, 2017
c027e31
Change OAuth2.0 endpoints to lower-case (was camel)
VJalili Aug 14, 2017
2326add
Add a `TODO` comment.
VJalili Aug 14, 2017
7ba12a1
Fix a bug; callback does not need to pass the provider name
VJalili Aug 14, 2017
7c0cc5e
Fix bugs; no need to `commit`, access_token accessor change, and add …
VJalili Aug 14, 2017
9dc0403
rename access_token to access_token_info
VJalili Aug 14, 2017
734a005
make sample OAuth2.0 endpoint lowercase, and updated a comment.
VJalili Aug 14, 2017
64d1cd8
Remove a `TODO` on Google callback check.
VJalili Aug 14, 2017
1481603
Updated a log message.
VJalili Aug 14, 2017
7ae287d
Added package requirements
VJalili Aug 18, 2017
f622206
Removed white spaces between parenthesis and arguments.
VJalili Aug 18, 2017
bd2c30b
Rebased and merged conflicts.
VJalili Aug 7, 2017
e0199f2
Merge remote-tracking branch 'origin/oauth2-after-rebase' into oauth2…
VJalili Aug 21, 2017
0e55897
Changed migrated code sequence number.
VJalili Aug 22, 2017
3714a92
Removed white space between parenthesis and arguments.
VJalili Aug 22, 2017
875a79a
Few more white space removal.
VJalili Aug 22, 2017
9087deb
Some formatting updates, and one additional endpoint removal
VJalili Aug 22, 2017
fe41dcb
An alpha version of authentication based on PSA.
VJalili Nov 29, 2017
df2cf94
Removed unused PSA storage file.
VJalili Nov 30, 2017
6f50966
Replaced custom BaseStrategy with PSA Core BaseStrategy.
VJalili Nov 30, 2017
18c0130
First efforts in replacing the PSA user with the Galaxy user.
VJalili Dec 2, 2017
80718cd
Deleted PSAUsers class and mapping.
VJalili Dec 5, 2017
f2150af
Updates to Galaxy-user and PSA social incorporation.
VJalili Dec 5, 2017
4829f2d
Renamed `galaxy_user_oauth2` table to 'oidc_rp'
VJalili Dec 7, 2017
55c0b4b
extended `social_auth_usersocialauth` table.
VJalili Dec 7, 2017
8148375
Renamed user authnz table and model.
VJalili Dec 8, 2017
9205cf2
Added two methods to return id and access tokens, & updated a comment.
VJalili Dec 8, 2017
5d8b8e1
Implemented disconnect endpoint, & updated some comments.
VJalili Dec 10, 2017
e6bb39d
Updated the disconnect process, & added some comments.
VJalili Dec 10, 2017
6734638
Updated flush function.
VJalili Dec 11, 2017
bef0ad9
Enabled authenticating an anonymous user.
VJalili Dec 11, 2017
071e174
Updated the temporary user password to a random string.
VJalili Dec 11, 2017
9c3ec35
Fixed a bug: cannot disconnect anonymous users.
VJalili Dec 12, 2017
ed0d2d4
Resolved a bug: cannot disconnect if not already authenticated.
VJalili Dec 12, 2017
3352c3e
Simplified UserAuthnzToken class by flattening unnecessary indirections.
VJalili Dec 12, 2017
6238849
Explained the key-value pairs of a static property.
VJalili Dec 12, 2017
88dc7e3
Removed some comments and implemented Nonce class.
VJalili Dec 12, 2017
7c1a846
Simplified Nonce class.
VJalili Dec 12, 2017
b8704f8
Refactored PSA Nonce table, & removed some unused imports.
VJalili Dec 12, 2017
cd6b934
Added missing `.table` in mapping.py for the PSA tables.
VJalili Dec 12, 2017
04cb179
Implemented SocialAuthAssociation class.
VJalili Dec 13, 2017
54f3033
Added some missing mappers of the PSA-related tables.
VJalili Dec 13, 2017
1a10718
Implemented PSA partial class.
VJalili Dec 14, 2017
bd46767
Implemented PSA Code class.
VJalili Dec 14, 2017
6f27d09
Some clean-ups in psa_authnz
VJalili Dec 14, 2017
d39c9c3
refactored PSA-related models and tables.
VJalili Dec 14, 2017
3cb9fd0
Removed a solved todo comment.
VJalili Dec 14, 2017
98e8408
Removed unused parameter provider from PSAAuthnz initialization.
VJalili Dec 14, 2017
61bbdde
Made some IdP-specific settings dynamic.
VJalili Dec 14, 2017
e290fea
Redirect user to main page after a successful authentication.
VJalili Dec 14, 2017
8eba74c
Added on-the-fly-config function.
VJalili Dec 14, 2017
98d5910
Login an anonymous user after a successful authn with an IdP.
VJalili Dec 15, 2017
2c77032
Commented a configuration key.
VJalili Dec 15, 2017
5f7206a
disabled consent prompt for Google authn.
VJalili Dec 15, 2017
ae0e173
Made `prompt` a variable defined in and read from configuration.
VJalili Dec 15, 2017
35482fd
Added PSA-level configuration (i.e., applied to all backends).
VJalili Dec 15, 2017
55362e4
Added `oidc_rp_config.xml.sample` file.
VJalili Dec 15, 2017
b3c1f3c
Capture errors on authnz callback.
VJalili Dec 15, 2017
dec858f
Added disconnect redirect URL to disconnect function signature.
VJalili Dec 15, 2017
55cb550
Updated OAuth2.0 configuration sample file.
VJalili Dec 15, 2017
54374ae
Some refactoring.
VJalili Dec 15, 2017
9151c0d
Added a description for the attribute-value pairs of OIDC config file.
VJalili Dec 15, 2017
3104ae1
updated redirect uri, now it is read from config.
VJalili Dec 15, 2017
bdb2c3e
Switched strategy and backend to local variables.
VJalili Dec 15, 2017
ba5312f
Replaced multiple local backend name variables, with a static dict.
VJalili Dec 15, 2017
40609a8
Removed the temporary global variable _trans.
VJalili Dec 15, 2017
0d0493e
Removed the temporary global variable _user.
VJalili Dec 15, 2017
4674670
Replaced local url variable with a config key.
VJalili Dec 15, 2017
2e0e049
Re-arch: parse all config at init time, and create backends on the fly.
VJalili Dec 16, 2017
8d6669f
Removed unused configuration in user login function, and saved the info
VJalili Dec 16, 2017
d5a295b
Fixed a typo.
VJalili Dec 16, 2017
60d2794
Refactored backend and strategy variables scope in callback to be local.
VJalili Dec 16, 2017
ed77f12
Extended the signature of login_user function.
VJalili Dec 16, 2017
7249a1c
Updated get_current_user function to rely only on Galaxy Trans.
VJalili Dec 16, 2017
a1b9842
Made dynamic the backend name part of a state token config key.
VJalili Dec 17, 2017
794746e
Removed an unused trans assignment.
VJalili Dec 17, 2017
e168eaf
A line break.
VJalili Dec 17, 2017
009248b
Replaced a temporary redirect URI assignment with a config kvp.
VJalili Dec 17, 2017
d2ad569
Refactored the scope of strategy & backend variables of disconnect func.
VJalili Dec 17, 2017
0623793
Removed an unused trans variable assignment in disconnect function.
VJalili Dec 17, 2017
0663e57
Now authnz callback receives login_redirect_url from controller.
VJalili Dec 17, 2017
e9ba0b9
Refactored disconnect redirect url.
VJalili Dec 17, 2017
aa07653
Some updates to config parameters.
VJalili Dec 17, 2017
ba05d28
Changed the scope of config variable.
VJalili Dec 17, 2017
33f2a8f
Fixed a bug with disconnect redirect url.
VJalili Dec 17, 2017
ab130f7
Updated the build_absolute_uri function of the Strategy.
VJalili Dec 17, 2017
cb4645f
Some cleanup.
VJalili Dec 18, 2017
cd9783e
Removed the unused function `create_user`.
VJalili Dec 18, 2017
b086802
Now get IdP names from buildapp/router; previously it was hard-code.
VJalili Dec 18, 2017
10e8053
Added some authnz front-end components.
VJalili Dec 18, 2017
c05c3c1
Merge remote-tracking branch 'remotes/upstream/dev' into psaAlpha
VJalili Dec 18, 2017
0b12196
Incremented migration scripts number.
VJalili Dec 18, 2017
ba18313
Fixed some bugs: some imports were removed as a result of last merge.
VJalili Dec 18, 2017
74b27d2
Extended login, callback, and disconnect functions to return success,…
VJalili Dec 18, 2017
294bdf4
Updated a dictionary init/update to dict literal values.
VJalili Dec 19, 2017
b4bdfe5
Refactored internal functions of Authn to adhere with private methods…
VJalili Dec 19, 2017
969a2e4
Updated disconnect function to return `success`, `message` & `response`.
VJalili Dec 19, 2017
6935adc
Removed the non-PSA-based authnz code.
VJalili Dec 19, 2017
7552952
Moved AuthnzManager to another module than init to avoid cyclic imports.
VJalili Dec 19, 2017
0b3b756
Removed temporary class User, and temporary models module.
VJalili Dec 19, 2017
22b4859
Fixed a typo.
VJalili Dec 19, 2017
3ae1552
Added the "Login with Google" button.
VJalili Dec 19, 2017
a17d8dd
Fixed a bug redirecting after successfully handling a call-back.
VJalili Dec 20, 2017
8385954
Capture callback errors, and inform the user and log the error.
VJalili Dec 20, 2017
2165447
Updated callback error handling.
VJalili Dec 20, 2017
be1acdc
Fixed a bug capturing some errors raised when handling authnz callbacks.
VJalili Dec 20, 2017
0c85d90
Some refactoring and updates to authnz error/exception handling.
VJalili Dec 20, 2017
bc854b0
Add dependencies required for a PSA-based Authnz.
VJalili Jan 15, 2018
596ef27
Merge remote-tracking branch 'upstream/dev' into oauth2-after-rebase
VJalili Jan 15, 2018
1e8e60a
Merge remote-tracking branch 'upstream/dev' into psaAlpha
VJalili Jan 15, 2018
3afc67d
Merge branch 'oauth2-after-rebase' into psaAlpha
VJalili Jan 15, 2018
cd92485
Merge pull request #4 from VJalili/psaAlpha
VJalili Jan 15, 2018
1502e0d
Remove OAuth2.0 migration script, & update OIDC migration script number.
VJalili Jan 15, 2018
d85c354
Remove old OAuth2.0 configuration file.
VJalili Jan 15, 2018
f06d793
Add missing oidc_backends_config.xml.sample.
VJalili Jan 15, 2018
b88339e
obfuscate client id and secret in oidc backend config sample.
VJalili Jan 15, 2018
2a6f6e1
Add a sample of redirect URL to oidc_backends_config.xml.sample.
VJalili Jan 15, 2018
0f4ec2f
Fix a line indentation issue.
VJalili Jan 15, 2018
cab0b48
Set optional parameter username as last argument of `User` constructor.
VJalili Jan 16, 2018
8980e82
Check for missing `enable_oidc` in login mako.
VJalili Jan 16, 2018
26c91f2
Remove unused OAuth2 controller.
VJalili Jan 16, 2018
218b88e
Remove the unused google oidc implementation.
VJalili Jan 16, 2018
4e7b39e
Some sorts on imports and adding new lines.
VJalili Jan 16, 2018
b951bb2
Some ordering to imports; remove `socket` & `codecs` imports.
VJalili Jan 16, 2018
3c4ceb0
Replaced `unicode` with `str`.
VJalili Jan 16, 2018
a992d1f
Update a comment.
VJalili Jan 16, 2018
d1ac819
cross-python 2/3 assertion if object is of string type.
VJalili Jan 16, 2018
c7ab4e2
Fix a bug with disconnect.
VJalili Jan 16, 2018
362cfca
Remove additional new line.
VJalili Jan 16, 2018
68acf98
Fixed a bug re-associating a disconnected OIDC identity with a user.
VJalili Jan 18, 2018
2f479e1
Merge remote-tracking branch 'upstream/dev' into oauth2-after-rebase
VJalili Jan 18, 2018
181d86f
Add OIDC configuration to the new galaxy.yml.sample file.
VJalili Jan 18, 2018
5c13e50
Set PSA to persist ID and refresh tokens.
VJalili Feb 21, 2018
ba3eaec
Update PSA setting to persist `id_token`.
VJalili Feb 21, 2018
6ea87b5
Merge branch 'dev' into oauth2-after-rebase
VJalili Feb 21, 2018
654206e
Add OIDC and OAuth2.0 pinned requirements to `requirements.txt`.
VJalili Mar 20, 2018
6dcdcc4
When OIDC is not enabled and user manually reaches its endpoint,
VJalili Mar 20, 2018
730b931
OIDC config in galaxy.yml.sample is now set via config_schema.
VJalili Mar 20, 2018
ea0ebd8
Replace static redirect and username with proper variables.
VJalili Mar 21, 2018
f634c40
Merge remote-tracking branch 'upstream/dev' into oauth2-after-rebase
VJalili Mar 21, 2018
320cf47
Encrypt random password of a user who is logged-in using OIDC.
VJalili Mar 29, 2018
6b998e6
Add a missing blank line at the end of OIDC config samples.
VJalili Mar 29, 2018
60e936c
Add a comment to OIDC config explaining the unit of settings.
VJalili Mar 29, 2018
5ffaf9f
use `random.sample` to generate random password for a user.
VJalili Apr 6, 2018
259daf1
Merge remote-tracking branch 'upstream/dev' into oauth2-after-rebase
VJalili Apr 6, 2018
86e13a5
Add OIDC requirements to pipfiles/default/pinned-requirements.txt
VJalili Apr 6, 2018
1263161
Remove changes to pinned-requirements.txt
VJalili Apr 9, 2018
7f1e07b
Add PSA requirement to pipfile and run `make update-dependencies`.
VJalili Apr 9, 2018
cd7e0b1
Merge remote-tracking branch 'upstream/dev' into oauth2-after-rebase
VJalili Apr 9, 2018
1ed2b8f
replaced pinned-requirements file with s symlink.
VJalili Apr 9, 2018
00d48f0
Add the missing pyjwkest requirement to pipfile.
VJalili Apr 9, 2018
d01be5d
Create `set_random_password` function in galaxy user.
VJalili Apr 9, 2018
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
5 changes: 5 additions & 0 deletions config/galaxy.ini.sample
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,11 @@ use_interactive = True
#openid_config_file = config/openid_conf.xml
#openid_consumer_cache_path = database/openid_consumer_cache

# Sets OAuth2.0 the path to client secret file. Note that this file should be
# on a path inaccessible to public.
#enable_oauth2 = True
#oauth2_config_file = config/oauth2_config.xml

# XML config file that allows the use of different authentication providers
# (e.g. LDAP) instead or in addition to local authentication (.sample is used
# if default does not exist).
Expand Down
8 changes: 8 additions & 0 deletions config/oauth2_config.xml.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0"?>
Copy link
Member

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?

Copy link
Member

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.

Copy link
Member

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.

<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>
Copy link
Member

Choose a reason for hiding this comment

The 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 bind-dn/bind-password from ldap's configuration. https://github.com/galaxyproject/galaxy/blob/dev/config/auth_conf.xml.sample#L94

<redirect_uri>https://usegalaxy.org/oauth2callback/google</redirect_uri>
Copy link
Member

Choose a reason for hiding this comment

The 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 web.url_for e.g. https://github.com/galaxyproject/galaxy/blob/dev/lib/galaxy/tools/error_reports/plugins/sentry.py#L77 to construct a URL to a route. Ideally the URL could be constructed completely from the provider name (assuming provider name == python file name).

</provider>
</OAuth2.0>
3 changes: 3 additions & 0 deletions lib/galaxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ def __init__(self, **kwargs):
)
self.heartbeat.daemon = True
self.application_stack.register_postfork_function(self.heartbeat.start)
if self.config.enable_oauth2:
from galaxy import authnz
self.authnz_manager = authnz.AuthnzManager(self.config.oauth2_config)
self.sentry_client = None
if self.config.sentry_dsn:

Expand Down
138 changes: 138 additions & 0 deletions lib/galaxy/authnz/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""
Copy link
Member

Choose a reason for hiding this comment

The 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 galaxy/lib/galaxy/auth/providers/, or even a oauth2 subdirectory under providers.

Copy link
Member Author

Choose a reason for hiding this comment

The 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):
Copy link
Member

Choose a reason for hiding this comment

The 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:
Copy link
Member

Choose a reason for hiding this comment

The 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))
136 changes: 136 additions & 0 deletions lib/galaxy/authnz/oidc_idp_google.py
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
3 changes: 3 additions & 0 deletions lib/galaxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ def __init__(self, **kwargs):
self.tool_data_path = resolve_path(kwargs.get("tool_data_path", "tool-data"), os.getcwd())
self.builds_file_path = resolve_path(kwargs.get("builds_file_path", os.path.join(self.tool_data_path, 'shared', 'ucsc', 'builds.txt')), self.root)
self.len_file_path = resolve_path(kwargs.get("len_file_path", os.path.join(self.tool_data_path, 'shared', 'ucsc', 'chrom')), self.root)
# Galaxy OAuth2.0 settings.
self.enable_oauth2 = kwargs.get("enable_oauth2", False)
self.oauth2_config = kwargs.get("oauth2_config_file", None)
# The value of migrated_tools_config is the file reserved for containing only those tools that have been eliminated from the distribution
# and moved to the tool shed.
self.integrated_tool_panel_config = resolve_path(kwargs.get('integrated_tool_panel_config', 'integrated_tool_panel.xml'), self.root)
Expand Down
6 changes: 5 additions & 1 deletion lib/galaxy/dependencies/pinned-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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

11 changes: 11 additions & 0 deletions lib/galaxy/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4984,6 +4984,17 @@ def __init__(self, user=None, session=None, openid=None):
self.openid = openid


class UserOAuth2(object):
def __init__(self, user_id, provider, state_token, id_token=None, refresh_token=None, expiration_date=None, access_token=None):
self.user_id = user_id
self.provider = provider
self.state_token = state_token
self.id_token = id_token
self.refresh_token = refresh_token
self.expiration_date = expiration_date
self.access_token = access_token


class Page(object, Dictifiable):
dict_element_visible_keys = ['id', 'title', 'latest_revision_id', 'slug', 'published', 'importable', 'deleted']

Expand Down
16 changes: 16 additions & 0 deletions lib/galaxy/model/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@
Column("openid", TEXT, index=True, unique=True),
Column("provider", TrimmedString(255)))

model.UserOAuth2.table = 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", String, nullable=False),
Column("state_token", String, nullable=False, index=True),
Column("id_token", String),
Column("refresh_token", String),
Column("expiration_date", DateTime),
Column("access_token", String))

model.PasswordResetToken.table = Table(
"password_reset_token", metadata,
Column("token", String(32), primary_key=True, unique=True, index=True),
Expand Down Expand Up @@ -1577,6 +1588,11 @@ def simple_mapping(model, **kwds):
order_by=desc(model.UserOpenID.table.c.update_time))
))

mapper(model.UserOAuth2, model.UserOAuth2.table, properties=dict(
user=relation(model.User,
primaryjoin=(model.UserOAuth2.table.c.user_id == model.User.table.c.id))
))

mapper(model.ValidationError, model.ValidationError.table)

simple_mapping(model.HistoryDatasetAssociation,
Expand Down
Loading