From 1c0b0f2fdd85585a544259fcdf0c9aa9038557e7 Mon Sep 17 00:00:00 2001 From: Colton Leekley-Winslow Date: Sat, 2 Apr 2016 17:56:28 -0500 Subject: [PATCH 1/6] Add ability to inject extra claims and xsrfToken JWT generation functions were moved into the JWTFactory class. The JWTRequestAuthenticator class was added,and was made the parent class of ApiRequestAuthenticator, OAuthRequestAuthenticator, and OAuthClientCredentialsRequestAuthenticator. These three authenticator classes now allow specifying arbitrary key-value pairs to add in the JWT claims. The also now have support for auto-generating a uuid4 to be used as a CSRF token in the JWT, as described in the "Cookies" section of https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage/ --- stormpath/api_auth.py | 138 ++++++++++++++++++++++++----------- tests/mocks/test_api_auth.py | 33 +++++++++ 2 files changed, 130 insertions(+), 41 deletions(-) diff --git a/stormpath/api_auth.py b/stormpath/api_auth.py index dec309e..971952e 100644 --- a/stormpath/api_auth.py +++ b/stormpath/api_auth.py @@ -5,6 +5,7 @@ import datetime import json from six import string_types +import uuid try: from urlparse import urlparse, parse_qs @@ -94,10 +95,11 @@ def __init__(self, app, token): # get raw data without validation try: - data = jwt.decode(self.token, verify=False, algorithms=['HS256']) - self.client_id = data.get('sub', '') + claims = jwt.decode(self.token, verify=False, algorithms=['HS256']) + self.claims = claims + self.client_id = claims.get('sub', '') try: - self.account = self.app.accounts.get(data.get('sub', '')) + self.account = self.app.accounts.get(claims.get('sub', '')) # We're accessing account.username here to force # evaluation of this Account -- this allows us to check @@ -109,8 +111,8 @@ def __init__(self, app, token): if self.account: self.for_api_key = False self.api_key = self.app.api_keys.get_key(self.client_id) - self.exp = data.get('exp', 0) - self.scopes = data.get('scope', '') if data.get('scope') else '' + self.exp = claims.get('exp', 0) + self.scopes = claims.get('scope', '') if claims.get('scope') else '' self.scopes = self.scopes.split(' ') except jwt.DecodeError: pass @@ -294,47 +296,86 @@ def validate_bearer_token(self, token, scopes, request): return True return False +class JWTFactory(object): + def __init__(self, token_data=None, add_xsrf_token=False): + self.token_data = token_data + self.add_xsrf_token = add_xsrf_token + + def get_bearer_token(self, *args, **kwargs): + kwargs["token_generator"]=self.generate_signed_token + return self._get_bearer_token(*args, **kwargs) + + def generate_signed_token(self, request): + return self._generate_signed_token( + request, + token_data=self.token_data, + add_xsrf_token=self.token_data + ) + + @classmethod + def _generate_signed_token(cls, request, token_data=None, add_xsrf_token=False): + client_id = request.client.client_id + request.app.api_keys.get_key(client_id) + + # the SP ApiKey is already validated in SPOauth2RequestValidator.validate_client_id + # but to prevent time based attacks oauthlib always goes through the entire + # flow even though the entire request will be deemed invalid + # in the end. + secret = request.app._client.auth.secret + + now = datetime.datetime.utcnow() + + claims = { + 'iss': request.app.href, + 'sub': client_id, + 'iat': now, + 'exp': now + datetime.timedelta(seconds=request.expires_in) + } + + if hasattr(request, 'scope'): + claims['scope'] = request.scope -def _generate_signed_token(request): - client_id = request.client.client_id - request.app.api_keys.get_key(client_id) + if add_xsrf_token: + claims["xsrfToken"] = str(uuid.uuid4().hex) - # the SP ApiKey is already validated in SPOauth2RequestValidator.validate_client_id - # but to prevent time based attacks oauthlib always goes through the entire - # flow even though the entire request will be deemed invalid - # in the end. - secret = request.app._client.auth.secret + if token_data: + # Don't allow token_data to overwrite built-in claims + token_data.update(claims) + claims = token_data - now = datetime.datetime.utcnow() + token = jwt.encode(claims, secret, 'HS256') + token = to_unicode(token, "UTF-8") - data = { - 'iss': request.app.href, - 'sub': client_id, - 'iat': now, - 'exp': now + datetime.timedelta(seconds=request.expires_in) - } + return token - if hasattr(request, 'scope'): - data['scope'] = request.scope - token = jwt.encode(data, secret, 'HS256') - token = to_unicode(token, "UTF-8") + @classmethod + def _get_bearer_token(cls, app, allowed_scopes, http_method, uri, body, headers, ttl=DEFAULT_TTL, + token_generator=None): - return token + validator = SPOauth2RequestValidator(app=app, allowed_scopes=allowed_scopes, ttl=ttl) + if token_generator is None: + token_generator = cls._generate_signed_token -def _get_bearer_token(app, allowed_scopes, http_method, uri, body, headers, ttl=DEFAULT_TTL): - validator = SPOauth2RequestValidator(app=app, allowed_scopes=allowed_scopes, ttl=ttl) - server = Oauth2BackendApplicationServer(validator, - token_generator=_generate_signed_token) + server = Oauth2BackendApplicationServer(validator, + token_generator=token_generator) - headers, body, status = server.create_token_response( - uri, http_method, body, headers, {}) + headers, body, status = server.create_token_response( + uri, http_method, body, headers, {}) + + if status == 200: + token_response = json.loads(body) + return token_response.get('access_token') + return None - if status == 200: - token_response = json.loads(body) - return token_response.get('access_token') - return None +########################################################## +# Deprecated +########################################################## +########################################################## +_generate_signed_token = JWTFactory._generate_signed_token +_get_bearer_token = JWTFactory._get_bearer_token +########################################################## class Authenticator(object): @@ -343,6 +384,16 @@ class Authenticator(object): :param app: An application to which this Authenticator authenticates. + :param token_data dict: A dictionary of extra data to be + added to JWT's if your authenticator generates them. + This will be merged into the JWT claims. Any reserved + keys like 'exp' or 'iat' will not be overwritten. + + :param add_xsrf_token bool: If True, an JWT's generated + by this authenticator will include a 'xsrfToken' key. + For more information, see + https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage/ + """ def __init__(self, app): self.app = app @@ -409,8 +460,13 @@ def authenticate(self, headers, http_method='', uri='', body=None, account=result.account, api_key=result.api_key, access_token=access_token) +class JWTRequestAuthenticator(RequestAuthenticator): + def __init__(self, app, token_data=None, add_xsrf_token=False): + super(JWTRequestAuthenticator, self).__init__(app) + self.jwt_factory = JWTFactory(token_data=token_data, add_xsrf_token=add_xsrf_token) + -class ApiRequestAuthenticator(RequestAuthenticator): +class ApiRequestAuthenticator(JWTRequestAuthenticator): """This class should authenticate both HTTP Basic Auth and OAuth2 requests. However, if you need more specific or customized OAuth2 request processing, you will likely want to use the @@ -429,7 +485,7 @@ def _get_scheme_and_token(self, headers, http_method, uri, body, scopes, jwt_token = None if auth_scheme == 'Basic': if body.get('grant_type') or url_qs.get('grant_type'): - jwt_token =_get_bearer_token( + jwt_token =self.jwt_factory.get_bearer_token( self.app, scopes, http_method, uri, body, headers, ttl) if auth_scheme == 'Bearer': jwt_token = auth_header.split(' ')[1] @@ -455,7 +511,7 @@ def _get_scheme_and_token(self, headers, http_method, uri, body, scopes, return None, None -class OAuthRequestAuthenticator(RequestAuthenticator): +class OAuthRequestAuthenticator(JWTRequestAuthenticator): """This class should authenticate OAuth2 requests. It will eventually support authenticating all 4 OAuth2 grant types. Specifically, right now, this class will authenticate OAuth2 @@ -476,7 +532,7 @@ def _get_scheme_and_token(self, headers, http_method, uri, body, scopes, if auth_scheme == 'Basic': if not 'grant_type' in body and not 'grant_type' in url_qs: return None, None - jwt_token =_get_bearer_token( + jwt_token =self.jwt_factory.get_bearer_token( self.app, scopes, http_method, uri, body, headers, ttl) if auth_scheme == 'Bearer': jwt_token = auth_header.split(' ')[1] @@ -510,7 +566,7 @@ def _get_scheme_and_token(self, headers, http_method, uri, body, scopes, return None, None -class OAuthClientCredentialsRequestAuthenticator(RequestAuthenticator): +class OAuthClientCredentialsRequestAuthenticator(JWTRequestAuthenticator): """This class should authenticate OAuth2 client credentials grant type requests only. It will handle authenticating a request based on API key credentials. @@ -523,7 +579,7 @@ def _get_scheme_and_token(self, headers, http_method, uri, body, scopes, if self._get_auth_scheme_from_header(auth_header) == auth_scheme: if body.get('grant_type') or url_qs.get('grant_type'): - return auth_scheme, _get_bearer_token( + return auth_scheme, self.jwt_factory.get_bearer_token( self.app, scopes, http_method, uri, body, headers, ttl) return None, None diff --git a/tests/mocks/test_api_auth.py b/tests/mocks/test_api_auth.py index 53599e0..cb7bc5f 100644 --- a/tests/mocks/test_api_auth.py +++ b/tests/mocks/test_api_auth.py @@ -106,6 +106,39 @@ def test_basic_api_auth_with_generating_bearer_token(self): self.assertIsNotNone(result.token) self.assertEquals(result.token.scopes, ['test1']) + def test_basic_api_auth_with_generating_bearer_token_extra_data_xsrf(self): + app = MagicMock() + app._client.auth.secret = 'fakeApiKeyProperties.secret' + app.href = 'HREF' + api_keys = MagicMock() + api_keys.get_key = lambda k, s=None: MagicMock( + id=FAKE_CLIENT_ID, secret=FAKE_CLIENT_SECRET, status=StatusMixin.STATUS_ENABLED) + app.api_keys = api_keys + + basic_auth = base64.b64encode("{}:{}".format(FAKE_CLIENT_ID, FAKE_CLIENT_SECRET).encode('utf-8')) + + uri = 'https://example.com/get' + http_method = 'GET' + body = {'grant_type': 'client_credentials', 'scope': 'test1'} + headers = { + 'Authorization': b'Basic ' + basic_auth + } + + allowed_scopes = ['test1'] + + authenticator = ApiRequestAuthenticator(app, token_data={"foo":"bar", "exp":"dumb"}) + result = authenticator.authenticate( + headers=headers, http_method=http_method, uri=uri, body=body, + scopes=allowed_scopes) + self.assertIsNotNone(result) + self.assertIsNotNone(result.api_key) + self.assertIsNotNone(result.token) + self.assertEquals(result.token.claims['foo'], 'bar') + self.assertNotEquals(result.token.claims['exp'], 'dumb') + self.assertEquals(result.token.scopes, ['test1']) + # this will raise a keyerror or valueerror if csrf uuid was not added + uuid.UUID(result.token.claims["xsrfToken"]) + def test_basic_api_auth_with_invalid_scope_no_token_get_generated(self): app = MagicMock() app._client.auth.secret = 'fakeApiKeyProperties.secret' From ca948b2e2e0f241870662c08b77a6e9f1ceb61bd Mon Sep 17 00:00:00 2001 From: Colton Leekley-Winslow Date: Sat, 2 Apr 2016 18:26:54 -0500 Subject: [PATCH 2/6] Fix add_xsrf_token and add a test --- stormpath/api_auth.py | 2 +- tests/mocks/test_api_auth.py | 37 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/stormpath/api_auth.py b/stormpath/api_auth.py index 971952e..c626d9b 100644 --- a/stormpath/api_auth.py +++ b/stormpath/api_auth.py @@ -309,7 +309,7 @@ def generate_signed_token(self, request): return self._generate_signed_token( request, token_data=self.token_data, - add_xsrf_token=self.token_data + add_xsrf_token=self.add_xsrf_token ) @classmethod diff --git a/tests/mocks/test_api_auth.py b/tests/mocks/test_api_auth.py index cb7bc5f..e45a2e0 100644 --- a/tests/mocks/test_api_auth.py +++ b/tests/mocks/test_api_auth.py @@ -106,7 +106,7 @@ def test_basic_api_auth_with_generating_bearer_token(self): self.assertIsNotNone(result.token) self.assertEquals(result.token.scopes, ['test1']) - def test_basic_api_auth_with_generating_bearer_token_extra_data_xsrf(self): + def test_basic_api_auth_with_generating_bearer_token_extra_data(self): app = MagicMock() app._client.auth.secret = 'fakeApiKeyProperties.secret' app.href = 'HREF' @@ -136,8 +136,39 @@ def test_basic_api_auth_with_generating_bearer_token_extra_data_xsrf(self): self.assertEquals(result.token.claims['foo'], 'bar') self.assertNotEquals(result.token.claims['exp'], 'dumb') self.assertEquals(result.token.scopes, ['test1']) - # this will raise a keyerror or valueerror if csrf uuid was not added - uuid.UUID(result.token.claims["xsrfToken"]) + # check that xsrfToken was not added + self.assertIsNone(result.token.claims.get("xsrfToken")) + + def test_basic_api_auth_with_generating_bearer_token_xsrf(self): + app = MagicMock() + app._client.auth.secret = 'fakeApiKeyProperties.secret' + app.href = 'HREF' + api_keys = MagicMock() + api_keys.get_key = lambda k, s=None: MagicMock( + id=FAKE_CLIENT_ID, secret=FAKE_CLIENT_SECRET, status=StatusMixin.STATUS_ENABLED) + app.api_keys = api_keys + + basic_auth = base64.b64encode("{}:{}".format(FAKE_CLIENT_ID, FAKE_CLIENT_SECRET).encode('utf-8')) + + uri = 'https://example.com/get' + http_method = 'GET' + body = {'grant_type': 'client_credentials', 'scope': 'test1'} + headers = { + 'Authorization': b'Basic ' + basic_auth + } + + allowed_scopes = ['test1'] + + authenticator = ApiRequestAuthenticator(app, add_xsrf_token=True) + result = authenticator.authenticate( + headers=headers, http_method=http_method, uri=uri, body=body, + scopes=allowed_scopes) + self.assertIsNotNone(result) + self.assertIsNotNone(result.api_key) + self.assertIsNotNone(result.token) + + # check that xsrfToken was added + uuid.UUID(result.token.claims.get("xsrfToken")) def test_basic_api_auth_with_invalid_scope_no_token_get_generated(self): app = MagicMock() From 484caf841a5273687eff254d246768257e3237ac Mon Sep 17 00:00:00 2001 From: Colton Leekley-Winslow Date: Sun, 3 Apr 2016 14:47:30 -0500 Subject: [PATCH 3/6] allow specifying app and ttl to JWTFactory --- stormpath/api_auth.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/stormpath/api_auth.py b/stormpath/api_auth.py index c626d9b..0d35f4d 100644 --- a/stormpath/api_auth.py +++ b/stormpath/api_auth.py @@ -297,7 +297,7 @@ def validate_bearer_token(self, token, scopes, request): return False class JWTFactory(object): - def __init__(self, token_data=None, add_xsrf_token=False): + def __init__(self, app=None, token_data=None, ttl=None, add_xsrf_token=False): self.token_data = token_data self.add_xsrf_token = add_xsrf_token @@ -313,23 +313,31 @@ def generate_signed_token(self, request): ) @classmethod - def _generate_signed_token(cls, request, token_data=None, add_xsrf_token=False): + def _generate_signed_token(cls, request, app=None, token_data=None, ttl=None, add_xsrf_token=False): client_id = request.client.client_id - request.app.api_keys.get_key(client_id) + if app is None: + app = request.app + + app.api_keys.get_key(client_id) # the SP ApiKey is already validated in SPOauth2RequestValidator.validate_client_id # but to prevent time based attacks oauthlib always goes through the entire # flow even though the entire request will be deemed invalid # in the end. - secret = request.app._client.auth.secret + secret = app._client.auth.secret now = datetime.datetime.utcnow() + if ttl is None: + ttl = request.expires_in + + exp = now + datetime.timedelta(seconds=request.expires_in) + claims = { 'iss': request.app.href, 'sub': client_id, 'iat': now, - 'exp': now + datetime.timedelta(seconds=request.expires_in) + 'exp': exp } if hasattr(request, 'scope'): From 5048db3c40c40ffd3f91edb92e29d83d5164e7a4 Mon Sep 17 00:00:00 2001 From: Colton Leekley-Winslow Date: Wed, 6 Apr 2016 10:59:33 -0500 Subject: [PATCH 4/6] allow specification of additional params to JWTFactory --- stormpath/api_auth.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/stormpath/api_auth.py b/stormpath/api_auth.py index 0d35f4d..db9a952 100644 --- a/stormpath/api_auth.py +++ b/stormpath/api_auth.py @@ -297,41 +297,50 @@ def validate_bearer_token(self, token, scopes, request): return False class JWTFactory(object): - def __init__(self, app=None, token_data=None, ttl=None, add_xsrf_token=False): + def __init__(self, app, token_data=None, ttl=None, add_xsrf_token=False, encode_params=None): + self.app = app self.token_data = token_data + self.ttl = ttl self.add_xsrf_token = add_xsrf_token + self.encode_params = encode_params def get_bearer_token(self, *args, **kwargs): - kwargs["token_generator"]=self.generate_signed_token + kwargs.setdefault("token_generator", self.generate_signed_token) return self._get_bearer_token(*args, **kwargs) - def generate_signed_token(self, request): + def generate_signed_token(self, request, **kwargs): + kwargs.setdefault("app", self.app) + kwargs.setdefault("token_data", self.token_data) + kwargs.setdefault("ttl", self.ttl) + kwargs.setdefault("add_xsrf_token", self.add_xsrf_token) + kwargs.setdefault("encode_params", self.encode_params) return self._generate_signed_token( request, - token_data=self.token_data, - add_xsrf_token=self.add_xsrf_token + **kwargs ) @classmethod - def _generate_signed_token(cls, request, app=None, token_data=None, ttl=None, add_xsrf_token=False): + def _generate_signed_token(cls, request, app=None, token_data=None, + ttl=None, add_xsrf_token=False, + encode_params=None, secret=None): client_id = request.client.client_id if app is None: app = request.app app.api_keys.get_key(client_id) - # the SP ApiKey is already validated in SPOauth2RequestValidator.validate_client_id # but to prevent time based attacks oauthlib always goes through the entire # flow even though the entire request will be deemed invalid # in the end. - secret = app._client.auth.secret + if secret is None: + secret = app._client.auth.secret now = datetime.datetime.utcnow() if ttl is None: ttl = request.expires_in - exp = now + datetime.timedelta(seconds=request.expires_in) + exp = now + datetime.timedelta(seconds=ttl) claims = { 'iss': request.app.href, @@ -350,8 +359,9 @@ def _generate_signed_token(cls, request, app=None, token_data=None, ttl=None, ad # Don't allow token_data to overwrite built-in claims token_data.update(claims) claims = token_data - - token = jwt.encode(claims, secret, 'HS256') + if encode_params is None: + encode_params = {"algorithm":"HS256"} + token = jwt.encode(claims, secret, **encode_params) token = to_unicode(token, "UTF-8") return token From b6b22a74d8519d4ab9c30d6c33da774a5740be08 Mon Sep 17 00:00:00 2001 From: Colton Leekley-Winslow Date: Wed, 6 Apr 2016 11:06:37 -0500 Subject: [PATCH 5/6] fix confusing app argument --- stormpath/api_auth.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/stormpath/api_auth.py b/stormpath/api_auth.py index db9a952..876f27b 100644 --- a/stormpath/api_auth.py +++ b/stormpath/api_auth.py @@ -297,8 +297,8 @@ def validate_bearer_token(self, token, scopes, request): return False class JWTFactory(object): - def __init__(self, app, token_data=None, ttl=None, add_xsrf_token=False, encode_params=None): - self.app = app + def __init__(self, stormpath_app, token_data=None, ttl=None, add_xsrf_token=False, encode_params=None): + self.stormpath_app = stormpath_app self.token_data = token_data self.ttl = ttl self.add_xsrf_token = add_xsrf_token @@ -309,7 +309,7 @@ def get_bearer_token(self, *args, **kwargs): return self._get_bearer_token(*args, **kwargs) def generate_signed_token(self, request, **kwargs): - kwargs.setdefault("app", self.app) + kwargs.setdefault("app", self.stormpath_app) kwargs.setdefault("token_data", self.token_data) kwargs.setdefault("ttl", self.ttl) kwargs.setdefault("add_xsrf_token", self.add_xsrf_token) @@ -479,9 +479,9 @@ def authenticate(self, headers, http_method='', uri='', body=None, access_token=access_token) class JWTRequestAuthenticator(RequestAuthenticator): - def __init__(self, app, token_data=None, add_xsrf_token=False): - super(JWTRequestAuthenticator, self).__init__(app) - self.jwt_factory = JWTFactory(token_data=token_data, add_xsrf_token=add_xsrf_token) + def __init__(self, stormpath_app, token_data=None, add_xsrf_token=False): + super(JWTRequestAuthenticator, self).__init__(stormpath_app) + self.jwt_factory = JWTFactory(stormpath_app, token_data=token_data, add_xsrf_token=add_xsrf_token) class ApiRequestAuthenticator(JWTRequestAuthenticator): From dc15e0d40ece6aaaa9701e3062f327fe6efe0f60 Mon Sep 17 00:00:00 2001 From: Colton Leekley-Winslow Date: Thu, 7 Apr 2016 12:01:38 -0500 Subject: [PATCH 6/6] fix trailing whitespaces --- stormpath/api_auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stormpath/api_auth.py b/stormpath/api_auth.py index 876f27b..d7b1218 100644 --- a/stormpath/api_auth.py +++ b/stormpath/api_auth.py @@ -315,13 +315,13 @@ def generate_signed_token(self, request, **kwargs): kwargs.setdefault("add_xsrf_token", self.add_xsrf_token) kwargs.setdefault("encode_params", self.encode_params) return self._generate_signed_token( - request, + request, **kwargs ) @classmethod - def _generate_signed_token(cls, request, app=None, token_data=None, - ttl=None, add_xsrf_token=False, + def _generate_signed_token(cls, request, app=None, token_data=None, + ttl=None, add_xsrf_token=False, encode_params=None, secret=None): client_id = request.client.client_id if app is None: @@ -409,7 +409,7 @@ class Authenticator(object): :param add_xsrf_token bool: If True, an JWT's generated by this authenticator will include a 'xsrfToken' key. - For more information, see + For more information, see https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage/ """