From 43b7fcb1943d868101f3686788ba46e035e84591 Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Mon, 11 Sep 2017 16:35:32 -0500 Subject: [PATCH 1/8] Add CAS authentication --- api/auth/authproviders.py | 56 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/api/auth/authproviders.py b/api/auth/authproviders.py index 61a4e1789..25b064d74 100644 --- a/api/auth/authproviders.py +++ b/api/auth/authproviders.py @@ -4,6 +4,8 @@ import urllib import urlparse +from xml.etree import ElementTree + from . import APIAuthProviderException, APIUnknownUserException, APIRefreshTokenException from .. import config, util from ..dao import dbutil @@ -189,6 +191,7 @@ def set_user_avatar(self, uid, identity): # If the user has no avatar set, mark their provider_avatar as their chosen avatar. config.db.users.update_one({'_id': uid, 'avatar': {'$exists': False}}, {'$set':{'avatar': provider_avatar, 'modified': timestamp}}) + class WechatOAuthProvider(AuthProvider): def __init__(self): @@ -278,6 +281,56 @@ def set_user_avatar(self, uid, identity): # pragma: no cover pass +class CASAuthProvider(AuthProvider): + + def __init__(self): + super(CASAuthProvider, self).__init__('cas') + + def validate_code(self, code, **kwargs): + uid = self.validate_user(code) + return { + 'access_token': code, + 'uid': uid, + 'auth_type': self.auth_type, + 'expires': datetime.datetime.utcnow() + datetime.timedelta(days=14) + } + + def validate_user(self, token): + config.log.warning('the config is {}\n\n'.format(self.config)) + r = requests.get(self.config['verify_endpoint'], params={'ticket': token, 'service': self.config['service_url']}) + if not r.ok: + raise APIAuthProviderException('User token not valid') + + username = self._parse_xml_response(r.content) + uid = username+'@'+self.config['namespace'] + + self.ensure_user_exists(uid) + self.set_user_gravatar(uid, uid) + + return uid + + def _parse_xml_response(self, response): + + # parse xml + tree = ElementTree.fromstring(response) + + # check to see if xml response labeled request as success + if tree[0].tag.endswith('authenticationSuccess'): + + # get username from response + namespace = tree.tag[0:tree.tag.index('}')+1] + username = tree[0].find('.//' + namespace + 'user').text + + else: + raise APIAuthProviderException('Auth provider ticket verification unsuccessful.') + + if not username: + raise APIAuthProviderException('Auth provider did not provide username') + + return username + + + class APIKeyAuthProvider(AuthProvider): """ Uses an API key for authentication. @@ -339,5 +392,6 @@ def validate_user_api_key(key): 'google' : GoogleOAuthProvider, 'ldap' : JWTAuthProvider, 'wechat' : WechatOAuthProvider, - 'api-key' : APIKeyAuthProvider + 'api-key' : APIKeyAuthProvider, + 'cas' : CASAuthProvider } From ed3436069574d53f3242a9da5abafa6808272105 Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Mon, 11 Sep 2017 18:02:45 -0500 Subject: [PATCH 2/8] Add tests --- api/auth/authproviders.py | 17 +++--- test/unit_tests/python/test_auth.py | 89 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/api/auth/authproviders.py b/api/auth/authproviders.py index 25b064d74..44b6a96e1 100644 --- a/api/auth/authproviders.py +++ b/api/auth/authproviders.py @@ -296,7 +296,6 @@ def validate_code(self, code, **kwargs): } def validate_user(self, token): - config.log.warning('the config is {}\n\n'.format(self.config)) r = requests.get(self.config['verify_endpoint'], params={'ticket': token, 'service': self.config['service_url']}) if not r.ok: raise APIAuthProviderException('User token not valid') @@ -315,17 +314,19 @@ def _parse_xml_response(self, response): tree = ElementTree.fromstring(response) # check to see if xml response labeled request as success + # see also: xml parsing in https://github.com/python-cas/python-cas if tree[0].tag.endswith('authenticationSuccess'): - # get username from response - namespace = tree.tag[0:tree.tag.index('}')+1] - username = tree[0].find('.//' + namespace + 'user').text + try: + # get username from response + namespace = tree.tag[0:tree.tag.index('}')+1] + username = tree[0].find('.//' + namespace + 'user').text + except Exception as e: # pylint: disable=broad-except + config.log.warning(e) + raise APIAuthProviderException('Unable to parse response from CAS provider.') else: - raise APIAuthProviderException('Auth provider ticket verification unsuccessful.') - - if not username: - raise APIAuthProviderException('Auth provider did not provide username') + raise APIAuthProviderException('Ticket verification unsuccessful.') return username diff --git a/test/unit_tests/python/test_auth.py b/test/unit_tests/python/test_auth.py index b4081b218..053677572 100644 --- a/test/unit_tests/python/test_auth.py +++ b/test/unit_tests/python/test_auth.py @@ -61,6 +61,95 @@ def test_jwt_auth(config, as_drone, as_public, api_db): api_db.users.delete_one({'_id': uid}) +def test_cas_auth(config, as_drone, as_public, api_db): + # try to login w/ unconfigured auth provider + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.status_code == 400 + + # inject cas auth config + config['auth']['cas'] = dict( + service_url='http://local.test?state=cas', + auth_endpoint='http://cas.test/cas/login', + verify_endpoint='http://cas.test/cas/serviceValidate', + namespace='cas.test', + display_string='CAS Auth') + + username = 'cas' + uid = username+'@'+config.auth.cas.namespace + + with requests_mock.Mocker() as m: + # try to log in w/ cas and invalid token (=code) + m.get(config.auth.cas.verify_endpoint, status_code=400) + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.status_code == 401 + + xml_response_unsuccessful = """ + + + + + """ + + # try to log in w/ cas - pretend provider doesn't return with success + m.get(config.auth.cas.verify_endpoint, content=xml_response_unsuccessful) + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.status_code == 401 + + xml_response_malformed = """ + + + cas + + + """ + + # try to log in w/ cas - pretend provider doesn't return valid username response + m.get(config.auth.cas.verify_endpoint, content=xml_response_malformed) + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.status_code == 401 + + xml_response_successful = """ + + + cas + + + """ + + # try to log in w/ cas - user not in db (yet) + m.get(config.auth.cas.verify_endpoint, content=xml_response_successful) + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.status_code == 402 + + # try to log in w/ cas - user added but disabled + assert as_drone.post('/users', json={ + '_id': uid, 'disabled': True, 'firstname': 'test', 'lastname': 'test'}).ok + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.status_code == 402 + + # log in w/ cas (also mock gravatar 404) + m.head(re.compile('https://gravatar.com/avatar'), status_code=404) + as_drone.put('/users/' + uid, json={'disabled': False}) + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.ok + assert 'gravatar' not in api_db.users.find_one({'_id': uid})['avatars'] + token = r.json['token'] + + # access api w/ valid token + r = as_public.get('', headers={'Authorization': token}) + assert r.ok + + # log in w/ cas (now w/ existing gravatar) + m.head(re.compile('https://gravatar.com/avatar')) + r = as_public.post('/login', json={'auth_type': 'cas', 'code': 'test'}) + assert r.ok + assert 'gravatar' in api_db.users.find_one({'_id': uid})['avatars'] + + # clean up + api_db.authtokens.delete_one({'_id': token}) + api_db.users.delete_one({'_id': uid}) + + def test_google_auth(config, as_drone, as_public, api_db): # inject google auth client_secret into config config['auth']['google']['client_secret'] = 'test' From 7807351f56132898f476367df14d61497d980cd8 Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Tue, 12 Sep 2017 15:44:59 -0500 Subject: [PATCH 3/8] Add inactivity timeout --- api/web/base.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/api/web/base.py b/api/web/base.py index f2fbb144c..c8f2fde22 100644 --- a/api/web/base.py +++ b/api/web/base.py @@ -127,6 +127,27 @@ def authenticate_user_token(self, session_token): if cached_token: self.request.logger.debug('looked up cached token in %dms', ((datetime.datetime.utcnow() - timestamp).total_seconds() * 1000.)) + # Check if site has inactivity timeout + try: + inactivity_timeout = config.get('site', 'inactivity_timeout') + except KeyError: + inactivity_timeout = None + + if inactivity_timeout: + last_seen = cached_token.get('last_seen') + + # If now - last_seen is greater than inactivity timeout, clear out session + if last_seen and (timestamp - last_seen).total_seconds() > inactivity_timeout: + + # Token expired and no refresh token, remove and deny request + config.db.authtokens.delete_one({'_id': cached_token['_id']}) + config.db.refreshtokens.delete({'uid': cached_token['uid'], 'auth_type': cached_token['auth_type']}) + self.abort(401, 'Inactivity timeout') + + # set last_seen to now + config.db.authtokens.update_one({'_id': cached_token['_id']}, {'$set': {'last_seen': timestamp}}) + + # Check if token is expired if cached_token.get('expires') and timestamp > cached_token['expires']: From 69bafd70471f9c014bb86a1ce665c7c6d87b0990 Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Tue, 12 Sep 2017 15:58:56 -0500 Subject: [PATCH 4/8] Fix typo --- api/web/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/web/base.py b/api/web/base.py index c8f2fde22..00b282130 100644 --- a/api/web/base.py +++ b/api/web/base.py @@ -129,7 +129,7 @@ def authenticate_user_token(self, session_token): # Check if site has inactivity timeout try: - inactivity_timeout = config.get('site', 'inactivity_timeout') + inactivity_timeout = config.get_item('site', 'inactivity_timeout') except KeyError: inactivity_timeout = None From 1c8827c055ae906879cd2ef7ee94cad86a50b7b3 Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Thu, 14 Sep 2017 15:07:20 -0500 Subject: [PATCH 5/8] Add default inactivity timeout --- api/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index a04e08ee4..7bcfc5b6b 100644 --- a/api/config.py +++ b/api/config.py @@ -38,7 +38,8 @@ 'redirect_url': 'https://localhost', 'central_url': 'https://sdmc.scitran.io/api', 'registered': False, - 'ssl_cert': None + 'ssl_cert': None, + 'inactivity_timeout': None }, 'queue': { 'max_retries': 3, From 655066bb24c29d1d21949a6e425e34bad99d78bd Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Wed, 27 Sep 2017 11:18:03 -0500 Subject: [PATCH 6/8] Change full url to service url state --- api/auth/authproviders.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/auth/authproviders.py b/api/auth/authproviders.py index 44b6a96e1..e96f258dc 100644 --- a/api/auth/authproviders.py +++ b/api/auth/authproviders.py @@ -296,7 +296,8 @@ def validate_code(self, code, **kwargs): } def validate_user(self, token): - r = requests.get(self.config['verify_endpoint'], params={'ticket': token, 'service': self.config['service_url']}) + service_url = config.get_item('site', 'redirect_url') + self.config['service_url_state'] + r = requests.get(self.config['verify_endpoint'], params={'ticket': token, 'service': service_url}) if not r.ok: raise APIAuthProviderException('User token not valid') From 450b39f2ce60913c48cdbb71a5c2b92c9a012440 Mon Sep 17 00:00:00 2001 From: Megan Henning Date: Wed, 27 Sep 2017 11:19:59 -0500 Subject: [PATCH 7/8] Change service url state in tests --- test/unit_tests/python/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit_tests/python/test_auth.py b/test/unit_tests/python/test_auth.py index 053677572..e2a09e9f6 100644 --- a/test/unit_tests/python/test_auth.py +++ b/test/unit_tests/python/test_auth.py @@ -68,7 +68,7 @@ def test_cas_auth(config, as_drone, as_public, api_db): # inject cas auth config config['auth']['cas'] = dict( - service_url='http://local.test?state=cas', + service_url_state='?state=cas', auth_endpoint='http://cas.test/cas/login', verify_endpoint='http://cas.test/cas/serviceValidate', namespace='cas.test', From 28541a4faba9fcc7c6de323cabe284e5c1130c78 Mon Sep 17 00:00:00 2001 From: Ryan Sanford Date: Fri, 29 Sep 2017 10:37:39 -0500 Subject: [PATCH 8/8] Add inactivity timeout to sample config file --- sample.config | 1 + 1 file changed, 1 insertion(+) diff --git a/sample.config b/sample.config index 7ff5f1f69..66bd574cc 100644 --- a/sample.config +++ b/sample.config @@ -15,6 +15,7 @@ #SCITRAN_CORE_DRONE_SECRET="" #SCITRAN_SITE_ID="" +#SCITRAN_SITE_INACTIVITY_TIMEOUT=3600 #SCITRAN_SITE_NAME="" #SCITRAN_SITE_URL="" #SCITRAN_SITE_API_URL=""