diff --git a/api/auth/authproviders.py b/api/auth/authproviders.py index 61a4e1789fb9712d689e092f063a5df036b6d50d..e96f258dcf61eb8438f4432af168de6927271bf6 100644 --- a/api/auth/authproviders.py +++ b/api/auth/authproviders.py @@ -4,6 +4,8 @@ import json 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 @@ class GoogleOAuthProvider(AuthProvider): # 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,58 @@ class WechatOAuthProvider(AuthProvider): 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): + 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') + + 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 + # see also: xml parsing in https://github.com/python-cas/python-cas + if tree[0].tag.endswith('authenticationSuccess'): + + 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('Ticket verification unsuccessful.') + + return username + + + class APIKeyAuthProvider(AuthProvider): """ Uses an API key for authentication. @@ -339,5 +394,6 @@ AuthProviders = { 'google' : GoogleOAuthProvider, 'ldap' : JWTAuthProvider, 'wechat' : WechatOAuthProvider, - 'api-key' : APIKeyAuthProvider + 'api-key' : APIKeyAuthProvider, + 'cas' : CASAuthProvider } diff --git a/api/config.py b/api/config.py index a04e08ee4ad0c4c2913d59989e82511a304cf945..7bcfc5b6bd178e95adecb1c3fb8f9bfe961bb925 100644 --- a/api/config.py +++ b/api/config.py @@ -38,7 +38,8 @@ DEFAULT_CONFIG = { '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, diff --git a/api/web/base.py b/api/web/base.py index f2fbb144cbd2f71c7e11d29bfc2c95de5b427168..00b282130d9d626f50e63f958cf8a3436201ee46 100644 --- a/api/web/base.py +++ b/api/web/base.py @@ -127,6 +127,27 @@ class RequestHandler(webapp2.RequestHandler): 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_item('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']: diff --git a/sample.config b/sample.config index 7ff5f1f693f0b1857303215ea7d20bece8dbf7c6..66bd574cc90a52f5df966043c45ceb313ea12bcb 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="" diff --git a/test/unit_tests/python/test_auth.py b/test/unit_tests/python/test_auth.py index b4081b21891d6d6a505ba4a101f244e6a93c5560..e2a09e9f6f2c1363fb0870c4ce6908a85d961741 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_state='?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 = """ + <cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'> + <cas:authenticationFailure> + </cas:authenticationFailure> + </cas:serviceResponse> + """ + + # 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:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'> + <cas:authenticationSuccess> + <cas:bad_key>cas</cas:bad_key> + </cas:authenticationSuccess> + </cas:serviceResponse> + """ + + # 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:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'> + <cas:authenticationSuccess> + <cas:user>cas</cas:user> + </cas:authenticationSuccess> + </cas:serviceResponse> + """ + + # 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'