diff --git a/.travis.yml b/.travis.yml index 37d6d5f..9ae677c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: - '3.6' +dist: xenial stages: - test diff --git a/CHANGES.md b/CHANGES.md index c358b2c..6a2c92c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ Changelog ========= +0.0.12 (Apr 23th 2019) +------------------ +- Support select jwt sign key by kid + 0.0.11 (Apr 17th 2018) ------------------ - Change dependency version diff --git a/README.md b/README.md index 8067bb8..29c1d16 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,18 @@ AUTH_USER_MODEL = 'ridi_django_oauth2.RidiUser' # RIDI Setting -RIDI_OAUTH2_JWT_SECRET = 'this-is-jwt-secret' +RIDI_OAUTH2_JWT_SECRETS = [ + { + 'kid': '0', + 'secret': 'this-is-hs256-key', + 'alg': 'HS256', + }, + { + 'kid': '1', + 'secret': 'this-is-rs256-public-key', + 'alg': 'RS256', + }, +] RIDI_OAUTH2_CLIENT_ID = 'this-is-client-id' RIDI_OAUTH2_CLIENT_SECRET = 'this-is-client-secret' @@ -41,7 +52,6 @@ REST_FRAMEWORK = { 'ridi_django_oauth2.rest_framework.authentication.OAuth2Authentication', ) } - ``` diff --git a/ridi_django_oauth2/config.py b/ridi_django_oauth2/config.py index 103c8c4..64fdde3 100644 --- a/ridi_django_oauth2/config.py +++ b/ridi_django_oauth2/config.py @@ -1,11 +1,12 @@ +from typing import Dict + from django.conf import settings from ridi_oauth2.introspector.dtos import JwtInfo class _Settings: - JWT_SECRET_NAME = 'RIDI_OAUTH2_JWT_SECRET' - JWT_ALGORITHM_NAME = 'RIDI_OAUTH2_JWT_ALGORITHM' + JWT_SECRETS = 'RIDI_OAUTH2_JWT_SECRETS' COOKIE_DOMAIN = 'RIDI_OAUTH2_COOKIE_DOMAIN' ACCESS_TOKEN_COOKIE_KEY = 'RIDI_OAUTH2_ACCESS_TOKEN_COOKIE_KEY' @@ -13,18 +14,17 @@ class _Settings: class _Default: - JWT_ALGORITHM = 'HS256' - COOKIE_DOMAIN = 'ridibooks.com' ACCESS_TOKEN_COOKIE_KEY = "ridi-at" REFRESH_TOKEN_COOKIE_KEY = "ridi-rt" # JwtInfo -_RIDI_OAUTH2_JWT_SECRET = getattr(settings, _Settings.JWT_SECRET_NAME) -_RIDI_OAUTH2_JWT_ALGORITHM = getattr(settings, _Settings.JWT_ALGORITHM_NAME, _Default.JWT_ALGORITHM) - -_JWT_INFO = JwtInfo(secret=_RIDI_OAUTH2_JWT_SECRET, algorithm=_RIDI_OAUTH2_JWT_ALGORITHM) +_RIDI_OAUTH2_JWT_SECRETS = getattr(settings, _Settings.JWT_SECRETS) +_JWT_INFOS = dict([ + (_RIDI_OAUTH2_JWT_SECRET['kid'], JwtInfo(_RIDI_OAUTH2_JWT_SECRET['secret'], _RIDI_OAUTH2_JWT_SECRET['alg'])) + for _RIDI_OAUTH2_JWT_SECRET in _RIDI_OAUTH2_JWT_SECRETS +]) # Cookie _RIDI_COOKIE_DOMAIN = getattr(settings, _Settings.COOKIE_DOMAIN, _Default.COOKIE_DOMAIN) @@ -34,8 +34,8 @@ class _Default: class RidiOAuth2Config: @staticmethod - def get_jwt_info() -> JwtInfo: - return _JWT_INFO + def get_jwt_infos() -> Dict[str, JwtInfo]: + return _JWT_INFOS @staticmethod def get_cookie_domain() -> str: diff --git a/ridi_django_oauth2/utils/token.py b/ridi_django_oauth2/utils/token.py index d280f42..7a668fe 100644 --- a/ridi_django_oauth2/utils/token.py +++ b/ridi_django_oauth2/utils/token.py @@ -17,8 +17,10 @@ def get_token_from_cookie(request: HttpRequest) -> TokenData: def get_token_info(token: str) -> typing.Optional[AccessTokenInfo]: + jwt_infos = RidiOAuth2Config.get_jwt_infos() try: - token_info = JwtIntrospectHelper.introspect(jwt_info=RidiOAuth2Config.get_jwt_info(), access_token=token) + token_info = JwtIntrospectHelper.introspect(jwt_infos, token) + except (KeyError, ExpireTokenException, InvalidJwtSignatureException): token_info = None diff --git a/ridi_oauth2/introspector/helpers.py b/ridi_oauth2/introspector/helpers.py index 09448d4..fe7f36d 100644 --- a/ridi_oauth2/introspector/helpers.py +++ b/ridi_oauth2/introspector/helpers.py @@ -1,3 +1,7 @@ +from typing import Dict + +import jwt + from ridi_oauth2.introspector.dtos import AccessTokenInfo, JwtInfo from ridi_oauth2.introspector.exceptions import InvalidJwtSignatureException from ridi_oauth2.introspector.jwt_introspector import JwtIntrospector @@ -5,10 +9,21 @@ class JwtIntrospectHelper: @staticmethod - def introspect(jwt_info: JwtInfo, access_token: str) -> AccessTokenInfo: + def introspect(jwt_infos: Dict[str, JwtInfo], access_token: str) -> AccessTokenInfo: + unverified_header = jwt.get_unverified_header(access_token) + kid = unverified_header.get('kid') + if not kid: + raise InvalidJwtSignatureException + + jwt_info = jwt_infos.get(kid) + if not jwt_info: + raise InvalidJwtSignatureException + introspector = JwtIntrospector(jwt_info=jwt_info, access_token=access_token) result = introspector.introspect() + try: return AccessTokenInfo.from_dict(result) + except KeyError: raise InvalidJwtSignatureException diff --git a/runcommand.py b/runcommand.py index 8377947..0fe73e1 100644 --- a/runcommand.py +++ b/runcommand.py @@ -27,7 +27,13 @@ 'MIDDLEWARE_CLASSES': ( 'ridi_django_oauth2.middlewares.AuthenticationMiddleware', ), - 'RIDI_OAUTH2_JWT_SECRET': 'dummy_jwt_secret', + 'RIDI_OAUTH2_JWT_SECRETS': [ + { + 'kid': '0', + 'secret': 'dummy_jwt_secret', + 'alg': 'HS256' + }, + ], 'RIDI_ridi_oauth2_ID': 'dummy_client_id', 'RIDI_ridi_oauth2_SECRET': 'dummy_client_secret', diff --git a/runtests.py b/runtests.py index 45a2292..990448b 100644 --- a/runtests.py +++ b/runtests.py @@ -27,7 +27,13 @@ 'MIDDLEWARE_CLASSES': ( 'ridi_django_oauth2.middlewares.AuthenticationMiddleware', ), - 'RIDI_OAUTH2_JWT_SECRET': 'dummy_jwt_secret', + 'RIDI_OAUTH2_JWT_SECRETS': [ + { + 'kid': '0', + 'secret': 'dummy_jwt_secret', + 'alg': 'HS256' + }, + ], 'RIDI_ridi_oauth2_ID': 'dummy_client_id', 'RIDI_ridi_oauth2_SECRET': 'dummy_client_secret', diff --git a/setup.py b/setup.py index 6a7dfc9..701909b 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -version = '0.0.11' +version = '0.0.12' with open('requirements/base.txt') as f: install_requires = [line for line in f if line and not line.startswith('-')] diff --git a/tests/tests_ridi_django_oauth2/test_authentication_middleware.py b/tests/tests_ridi_django_oauth2/test_authentication_middleware.py index 0d6b4a8..836c5a9 100644 --- a/tests/tests_ridi_django_oauth2/test_authentication_middleware.py +++ b/tests/tests_ridi_django_oauth2/test_authentication_middleware.py @@ -14,6 +14,9 @@ class AuthenticationMiddlewareTestCase(TestCase): def setUp(self): self.middleware = AuthenticationMiddleware() + self.headers = { + 'kid': '0', + } self.valid_token = jwt.encode(payload={ 'sub': 'testuser', @@ -21,13 +24,13 @@ def setUp(self): 'exp': int(time.time()) + 60 * 60, 'client_id': 'asfeih29snv8as213i', 'scope': 'all' - }, key='dummy_jwt_secret').decode() + }, key='dummy_jwt_secret', headers=self.headers).decode() self.loose_token = jwt.encode(payload={ 'sub': 'testuser', 'u_idx': 123123, 'exp': int(time.time()) + 60 * 60, - }, key='dummy_jwt_secret').decode() + }, key='dummy_jwt_secret', headers=self.headers).decode() self.expire_token = jwt.encode(payload={ 'sub': 'testuser', @@ -35,7 +38,7 @@ def setUp(self): 'exp': int(time.time()) - 60 * 60, 'client_id': 'asfeih29snv8as213i', 'scope': 'all' - }, key='dummy_jwt_secret').decode() + }, key='dummy_jwt_secret', headers=self.headers).decode() def test_login_and_not_expire(self): request = Mock() diff --git a/tests/tests_ridi_django_oauth2/test_decorator.py b/tests/tests_ridi_django_oauth2/test_decorator.py index 1d44156..b8bd57a 100644 --- a/tests/tests_ridi_django_oauth2/test_decorator.py +++ b/tests/tests_ridi_django_oauth2/test_decorator.py @@ -26,6 +26,10 @@ def setUp(self): 'scope': 'all' } + self.headers = { + 'kid': '0', + } + self.dummy_view = login_required()(MagicMock(return_value=HttpResponse(content='success'))) self.custom_dummy_view = login_required(response_handler=response_handler)(MagicMock(return_value=HttpResponse(content='success'))) @@ -51,7 +55,9 @@ def test_not_login_with_custom_response(self): def test_not_exists_token_info(self): request = Mock() request.COOKIES = { - RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode(payload=self.jwt_payload, key='dummy_jwt_secret').decode(), + RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( + self.jwt_payload, 'dummy_jwt_secret', headers=self.headers + ).decode(), } self.middleware.process_request(request) @@ -66,7 +72,9 @@ def test_not_exists_token_info(self): def test_login(self): request = Mock() request.COOKIES = { - RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode(payload=self.jwt_payload, key='dummy_jwt_secret').decode(), + RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( + self.jwt_payload, 'dummy_jwt_secret', headers=self.headers + ).decode(), } self.middleware.process_request(request) @@ -95,6 +103,10 @@ def setUp(self): 'scope': 'user_info' } + self.headers = { + 'kid': '0', + } + self.dummy_view1 = scope_required(required_scopes=['user_info'])(MagicMock(return_value=HttpResponse(content='success1'))) self.dummy_view2 = scope_required(required_scopes=[('user_info', 'purchase')])( MagicMock(return_value=HttpResponse(content='success2')) @@ -106,7 +118,9 @@ def setUp(self): def test_all_scope(self): request = Mock() request.COOKIES = { - RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode(payload=self.jwt_payload, key='dummy_jwt_secret').decode(), + RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( + self.jwt_payload, 'dummy_jwt_secret', headers=self.headers + ).decode(), } self.middleware.process_request(request) @@ -126,7 +140,9 @@ def test_all_scope(self): def test_restriction_scope(self): request = Mock() request.COOKIES = { - RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode(payload=self.jwt_loose_payload, key='dummy_jwt_secret').decode(), + RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( + self.jwt_loose_payload, 'dummy_jwt_secret', headers=self.headers + ).decode(), } self.middleware.process_request(request) @@ -145,7 +161,9 @@ def test_restriction_scope(self): def test_restriction_scope_with_custom_response(self): request = Mock() request.COOKIES = { - RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode(payload=self.jwt_loose_payload, key='dummy_jwt_secret').decode(), + RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( + self.jwt_loose_payload, 'dummy_jwt_secret', headers=self.headers + ).decode(), } self.middleware.process_request(request) diff --git a/tests/tests_ridi_django_oauth2/test_token_util.py b/tests/tests_ridi_django_oauth2/test_token_util.py index 66b3d1f..b06a407 100644 --- a/tests/tests_ridi_django_oauth2/test_token_util.py +++ b/tests/tests_ridi_django_oauth2/test_token_util.py @@ -29,7 +29,10 @@ def test_token_info(self): 'client_id': 'asfeih29snv8as213i', 'scope': 'all' } - valid_token = jwt.encode(payload=payload, key='dummy_jwt_secret').decode() + headers = { + 'kid': '0', + } + valid_token = jwt.encode(payload=payload, key='dummy_jwt_secret', headers=headers).decode() token_info = get_token_info(token=valid_token) diff --git a/tests/tests_ridi_oauth2/test_introspector.py b/tests/tests_ridi_oauth2/test_introspector.py index 86dbd2b..24a3575 100644 --- a/tests/tests_ridi_oauth2/test_introspector.py +++ b/tests/tests_ridi_oauth2/test_introspector.py @@ -14,18 +14,21 @@ class JwtIntrospectorTestCase(unittest.TestCase): def setUp(self): self.secret = generate_random_str(chars=string.ascii_letters + string.digits + string.punctuation) self.alg = 'HS256' + self.headers = { + 'kid': '0', + } self.claim = { "sub": "testuser", "exp": int(time.time()) + 60 * 60, } - self.token = jwt.encode(self.claim, self.secret, algorithm=self.alg) + self.token = jwt.encode(self.claim, self.secret, self.alg, self.headers) self.invalid_claim = { "sub": "testuser", "exp": int(time.time()) - 60 * 60, } - self.invalid_token = jwt.encode(self.invalid_claim, self.secret, algorithm=self.alg) + self.invalid_token = jwt.encode(self.invalid_claim, self.secret, self.alg, self.headers) def test_introspect(self): jwt_info = JwtInfo(secret=self.secret, algorithm=self.alg)