diff --git a/CHANGES.md b/CHANGES.md index 56410c8..2c00fa6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ Changelog ========= +1.0.0 (Oct 10st 2019) +------------------ +- Change main logic to get public key from OAuth2 server + - to get public key, it sends request to OAuth2 server, and memorize that key until key expires. + - when getting public key, it use [JWKS](https://tools.ietf.org/html/rfc7517) type + - it makes able to adapt OAuth2 server's key changing dynamically and using multi key. + 0.0.15 (Aug 1st 2019) ------------------ - Add cryptography in package diff --git a/Pipfile b/Pipfile index 12e5998..6fdbf42 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ name = "pypi" "PyJWT" = "==1.6.1" "requests" = "==2.20.0" "cryptography" = "==2.3" +"pycrypto" = "==2.6.1" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 0584f39..19899da 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e0b03167fc3395701ad89a427edb75dc3f377fa5ba2857216cab00ce57d64bf6" + "sha256": "e630150e90468d0872d89840699081fb09c49b51acadc4ee1fa361129265a0e1" }, "pipfile-spec": 6, "requires": { @@ -18,17 +18,17 @@ "default": { "asn1crypto": { "hashes": [ - "sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", - "sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49" + "sha256:0b199f211ae690df3db4fd6c1c4ff976497fb1da689193e368eedbadc53d9292", + "sha256:bca90060bd995c3f62c4433168eab407e44bdbdb567b3f3a396a676c1a4c4a3f" ], - "version": "==0.24.0" + "version": "==1.0.1" }, "certifi": { "hashes": [ - "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", - "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" ], - "version": "==2019.6.16" + "version": "==2019.9.11" }, "cffi": { "hashes": [ @@ -124,6 +124,13 @@ ], "version": "==2.19" }, + "pycrypto": { + "hashes": [ + "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c" + ], + "index": "pypi", + "version": "==2.6.1" + }, "pyjwt": { "hashes": [ "sha256:bca523ef95586d3a8a5be2da766fe6f82754acba27689c984e28e77a12174593", @@ -134,10 +141,10 @@ }, "pytz": { "hashes": [ - "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", - "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" ], - "version": "==2019.1" + "version": "==2019.3" }, "requests": { "hashes": [ @@ -172,17 +179,17 @@ "develop": { "astroid": { "hashes": [ - "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", - "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" + "sha256:98c665ad84d10b18318c5ab7c3d203fe11714cbad2a4aef4f44651f415392754", + "sha256:b7546ffdedbf7abcfbff93cd1de9e9980b1ef744852689decc5aeada324238c6" ], - "version": "==2.2.5" + "version": "==2.3.1" }, "certifi": { "hashes": [ - "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", - "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" ], - "version": "==2019.6.16" + "version": "==2019.9.11" }, "chardet": { "hashes": [ @@ -222,26 +229,26 @@ }, "lazy-object-proxy": { "hashes": [ - "sha256:159a745e61422217881c4de71f9eafd9d703b93af95618635849fe469a283661", - "sha256:23f63c0821cc96a23332e45dfaa83266feff8adc72b9bcaef86c202af765244f", - "sha256:3b11be575475db2e8a6e11215f5aa95b9ec14de658628776e10d96fa0b4dac13", - "sha256:3f447aff8bc61ca8b42b73304f6a44fa0d915487de144652816f950a3f1ab821", - "sha256:4ba73f6089cd9b9478bc0a4fa807b47dbdb8fad1d8f31a0f0a5dbf26a4527a71", - "sha256:4f53eadd9932055eac465bd3ca1bd610e4d7141e1278012bd1f28646aebc1d0e", - "sha256:64483bd7154580158ea90de5b8e5e6fc29a16a9b4db24f10193f0c1ae3f9d1ea", - "sha256:6f72d42b0d04bfee2397aa1862262654b56922c20a9bb66bb76b6f0e5e4f9229", - "sha256:7c7f1ec07b227bdc561299fa2328e85000f90179a2f44ea30579d38e037cb3d4", - "sha256:7c8b1ba1e15c10b13cad4171cfa77f5bb5ec2580abc5a353907780805ebe158e", - "sha256:8559b94b823f85342e10d3d9ca4ba5478168e1ac5658a8a2f18c991ba9c52c20", - "sha256:a262c7dfb046f00e12a2bdd1bafaed2408114a89ac414b0af8755c696eb3fc16", - "sha256:acce4e3267610c4fdb6632b3886fe3f2f7dd641158a843cf6b6a68e4ce81477b", - "sha256:be089bb6b83fac7f29d357b2dc4cf2b8eb8d98fe9d9ff89f9ea6012970a853c7", - "sha256:bfab710d859c779f273cc48fb86af38d6e9210f38287df0069a63e40b45a2f5c", - "sha256:c10d29019927301d524a22ced72706380de7cfc50f767217485a912b4c8bd82a", - "sha256:dd6e2b598849b3d7aee2295ac765a578879830fb8966f70be8cd472e6069932e", - "sha256:e408f1eacc0a68fed0c08da45f31d0ebb38079f043328dce69ff133b95c29dc1" - ], - "version": "==1.4.1" + "sha256:02b260c8deb80db09325b99edf62ae344ce9bc64d68b7a634410b8e9a568edbf", + "sha256:18f9c401083a4ba6e162355873f906315332ea7035803d0fd8166051e3d402e3", + "sha256:1f2c6209a8917c525c1e2b55a716135ca4658a3042b5122d4e3413a4030c26ce", + "sha256:2f06d97f0ca0f414f6b707c974aaf8829c2292c1c497642f63824119d770226f", + "sha256:616c94f8176808f4018b39f9638080ed86f96b55370b5a9463b2ee5c926f6c5f", + "sha256:63b91e30ef47ef68a30f0c3c278fbfe9822319c15f34b7538a829515b84ca2a0", + "sha256:77b454f03860b844f758c5d5c6e5f18d27de899a3db367f4af06bec2e6013a8e", + "sha256:83fe27ba321e4cfac466178606147d3c0aa18e8087507caec78ed5a966a64905", + "sha256:84742532d39f72df959d237912344d8a1764c2d03fe58beba96a87bfa11a76d8", + "sha256:874ebf3caaf55a020aeb08acead813baf5a305927a71ce88c9377970fe7ad3c2", + "sha256:9f5caf2c7436d44f3cec97c2fa7791f8a675170badbfa86e1992ca1b84c37009", + "sha256:a0c8758d01fcdfe7ae8e4b4017b13552efa7f1197dd7358dc9da0576f9d0328a", + "sha256:a4def978d9d28cda2d960c279318d46b327632686d82b4917516c36d4c274512", + "sha256:ad4f4be843dace866af5fc142509e9b9817ca0c59342fdb176ab6ad552c927f5", + "sha256:ae33dd198f772f714420c5ab698ff05ff900150486c648d29951e9c70694338e", + "sha256:b4a2b782b8a8c5522ad35c93e04d60e2ba7f7dcb9271ec8e8c3e08239be6c7b4", + "sha256:c462eb33f6abca3b34cdedbe84d761f31a60b814e173b98ede3c81bb48967c4f", + "sha256:fd135b8d35dfdcdb984828c84d695937e58cc5f49e1c854eb311c4d6aa03f4f1" + ], + "version": "==1.4.2" }, "mccabe": { "hashes": [ @@ -266,11 +273,11 @@ }, "pylint": { "hashes": [ - "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", - "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" + "sha256:7edbae11476c2182708063ac387a8f97c760d9cfe36a5ede0ca996f90cf346c8", + "sha256:844ce067788028c1a35086a5c66bc5e599ddd851841c41d6ee1623b36774d9f2" ], "index": "pypi", - "version": "==2.3.1" + "version": "==2.4.2" }, "requests": { "hashes": [ @@ -313,7 +320,7 @@ "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" ], - "markers": "implementation_name == 'cpython'", + "markers": "implementation_name == 'cpython' and python_version < '3.8'", "version": "==1.4.0" }, "urllib3": { diff --git a/lib/decorators/memorize.py b/lib/decorators/memorize.py new file mode 100644 index 0000000..1e3d2ca --- /dev/null +++ b/lib/decorators/memorize.py @@ -0,0 +1,28 @@ +import time +from typing import Callable + +DEFAULT_MEMORIZE_TIMEOUT = 60 + + +def memorize(timeout: int = DEFAULT_MEMORIZE_TIMEOUT): + def _wrapper(func: Callable): + _cache = {} + _timeouts = {} + + def _decorator(*args, clear: bool = False, **kwargs): + if clear: + return func(*args, **kwargs) + + key = func.__name__ + str(args) + str(kwargs) + if key not in _cache: + _timeouts[key] = time.time() + _cache[key] = func(*args, **kwargs) + + delta = time.time() - _timeouts[key] + if delta > timeout: + _timeouts[key] = time.time() + _cache[key] = func(*args, **kwargs) + + return _cache[key] + return _decorator + return _wrapper diff --git a/lib/decorators/retry.py b/lib/decorators/retry.py new file mode 100644 index 0000000..e078e65 --- /dev/null +++ b/lib/decorators/retry.py @@ -0,0 +1,49 @@ +import traceback +from functools import wraps +from typing import Callable, Tuple, Type + +_DEFAULT_RETRY_COUNT = 5 + + +class RetryFailException(Exception): + pass + + +def retry( + retry_count: int = _DEFAULT_RETRY_COUNT, + retriable_exceptions: Tuple[Type[BaseException]] = (BaseException,), + print_stacktrace: bool = False, +): + def _decorator(func: Callable): + def _wrapper(*args, **kwargs): + stacktraces = [] + + for _ in range(0, retry_count): + try: + value = func(*args, **kwargs) + except Exception as e: + if is_retriable_exception(e, retriable_exceptions): + stacktraces.append(traceback.format_exc()) + continue + + raise e + else: + return value + + if print_stacktrace: + print('[RetryFail][StackTrace] %s', stacktraces) + + raise RetryFailException + + return wraps(func)(_wrapper) + + return _decorator + + +def is_retriable_exception(exception: Type[BaseException], retriable_exceptions: Tuple[Type[BaseException]]) -> bool: + is_class = isinstance(exception, type) + + if is_class: + return issubclass(exception, retriable_exceptions) + + return isinstance(exception, retriable_exceptions) diff --git a/lib/utils/bytes.py b/lib/utils/bytes.py new file mode 100644 index 0000000..c378ca2 --- /dev/null +++ b/lib/utils/bytes.py @@ -0,0 +1,5 @@ +def bytes_to_int(by): + result = 0 + for b in by: + result = result * 256 + int(b) + return result diff --git a/ridi_django_oauth2/config.py b/ridi_django_oauth2/config.py index 64fdde3..67c1c63 100644 --- a/ridi_django_oauth2/config.py +++ b/ridi_django_oauth2/config.py @@ -1,13 +1,8 @@ -from typing import Dict - from django.conf import settings -from ridi_oauth2.introspector.dtos import JwtInfo - - -class _Settings: - JWT_SECRETS = 'RIDI_OAUTH2_JWT_SECRETS' +class _SettingKeyName: + KEY_URL = 'RIDI_OAUTH2_KEY_URL' COOKIE_DOMAIN = 'RIDI_OAUTH2_COOKIE_DOMAIN' ACCESS_TOKEN_COOKIE_KEY = 'RIDI_OAUTH2_ACCESS_TOKEN_COOKIE_KEY' REFRESH_TOKEN_COOKIE_KEY = 'RIDI_OAUTH2_REFRESH_TOKEN_COOKIE_KEY' @@ -19,23 +14,18 @@ class _Default: REFRESH_TOKEN_COOKIE_KEY = "ridi-rt" -# JwtInfo -_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) -_RIDI_ACCESS_TOKEN_COOKIE_KEY = getattr(settings, _Settings.ACCESS_TOKEN_COOKIE_KEY, _Default.ACCESS_TOKEN_COOKIE_KEY) -_RIDI_REFRESH_TOKEN_COOKIE_KEY = getattr(settings, _Settings.REFRESH_TOKEN_COOKIE_KEY, _Default.REFRESH_TOKEN_COOKIE_KEY) +_RIDI_COOKIE_DOMAIN = getattr(settings, _SettingKeyName.COOKIE_DOMAIN, _Default.COOKIE_DOMAIN) +_RIDI_ACCESS_TOKEN_COOKIE_KEY = getattr(settings, _SettingKeyName.ACCESS_TOKEN_COOKIE_KEY, _Default.ACCESS_TOKEN_COOKIE_KEY) +_RIDI_REFRESH_TOKEN_COOKIE_KEY = getattr(settings, _SettingKeyName.REFRESH_TOKEN_COOKIE_KEY, _Default.REFRESH_TOKEN_COOKIE_KEY) + +_RIDI_OAUTH2_KEY_URL = getattr(settings, _SettingKeyName.KEY_URL) class RidiOAuth2Config: @staticmethod - def get_jwt_infos() -> Dict[str, JwtInfo]: - return _JWT_INFOS + def get_key_url() -> str: + return _RIDI_OAUTH2_KEY_URL @staticmethod def get_cookie_domain() -> str: diff --git a/ridi_django_oauth2/middlewares.py b/ridi_django_oauth2/middlewares.py index 29d7e85..d3f9592 100644 --- a/ridi_django_oauth2/middlewares.py +++ b/ridi_django_oauth2/middlewares.py @@ -2,7 +2,9 @@ from django.contrib.auth.models import AnonymousUser from django.utils.deprecation import MiddlewareMixin +from ridi_django_oauth2.response import HttpUnauthorizedResponse from ridi_django_oauth2.utils.token import get_token_from_cookie, get_token_info +from ridi_oauth2.introspector.exceptions import PublicKeyException class AuthenticationMiddleware(MiddlewareMixin): @@ -12,7 +14,10 @@ def process_request(self, request): token = get_token_from_cookie(request=request) token_info = None if token.access_token: - token_info = get_token_info(token.access_token.token) + try: + token_info = get_token_info(token.access_token.token) + except PublicKeyException: + return HttpUnauthorizedResponse() if token_info is not None: user, _ = get_user_model().objects.get_or_create(u_idx=token_info.u_idx) diff --git a/ridi_django_oauth2/utils/token.py b/ridi_django_oauth2/utils/token.py index 040afec..f698748 100644 --- a/ridi_django_oauth2/utils/token.py +++ b/ridi_django_oauth2/utils/token.py @@ -17,13 +17,12 @@ def get_token_from_cookie(request: HttpRequest) -> TokenData: def get_token_info(token: str) -> typing.Optional[AccessTokenInfo]: - jwt_infos = RidiOAuth2Config.get_jwt_infos() + token_info = None try: - token_info = JwtIntrospectHelper.introspect(jwt_infos, token) + token_info = JwtIntrospectHelper.introspect(token) except (KeyError, ExpireTokenException, InvalidJwtSignatureException, InvalidToken): - token_info = None - + pass return token_info diff --git a/ridi_oauth2/client/dtos.py b/ridi_oauth2/client/dtos.py index c6ef094..d101580 100644 --- a/ridi_oauth2/client/dtos.py +++ b/ridi_oauth2/client/dtos.py @@ -1,8 +1,8 @@ -import typing +from typing import Dict class ClientInfo: - def __init__(self, client_id: str, client_secret: str, scope: str=None, redirect_uri: str=None): + def __init__(self, client_id: str, client_secret: str, scope: str = None, redirect_uri: str = None): self._client_id = client_id self._client_secret = client_secret self._scope = scope @@ -26,7 +26,7 @@ def redirect_uri(self) -> str: class AuthorizationServerInfo: - def __init__(self, authorization_url: str=None, token_url: str=None): + def __init__(self, authorization_url: str = None, token_url: str = None): self._authorization_url = authorization_url self._token_url = token_url @@ -40,7 +40,7 @@ def token_url(self) -> str: class Token: - def __init__(self, token: str, expires_in: int=None): + def __init__(self, token: str, expires_in: int = None): self._token = token self._expires_in = expires_in @@ -54,7 +54,7 @@ def expires_in(self) -> int: class TokenData: - def __init__(self, access_token: Token, token_type: str=None, scope: str=None, refresh_token: Token=None): + def __init__(self, access_token: Token, token_type: str = None, scope: str = None, refresh_token: Token = None): self._access_token = access_token self._token_type = token_type self._scope = scope @@ -78,7 +78,7 @@ def refresh_token(self) -> Token: return self._refresh_token @staticmethod - def from_dict(dictionary: typing.Dict): + def from_dict(dictionary: Dict): access_token = None if dictionary.get('access_token', None): access_token = Token(token=dictionary['access_token'], expires_in=dictionary.get('expires_in', None)) diff --git a/ridi_oauth2/client/exceptions.py b/ridi_oauth2/client/exceptions.py index 6c07079..d3d9aa5 100644 --- a/ridi_oauth2/client/exceptions.py +++ b/ridi_oauth2/client/exceptions.py @@ -1,5 +1,3 @@ - - class InvalidResponseException(Exception): pass @@ -9,7 +7,7 @@ class NotSupportedGrantException(Exception): class OAuthFailureException(Exception): - def __init__(self, error_code: str, *args, description: str=None, error_uri: str=None): + def __init__(self, error_code: str, *args, description: str = None, error_uri: str = None): self._error_code = error_code self._description = description self._error_uri = error_uri diff --git a/ridi_oauth2/introspector/constants.py b/ridi_oauth2/introspector/constants.py new file mode 100644 index 0000000..e3a4eba --- /dev/null +++ b/ridi_oauth2/introspector/constants.py @@ -0,0 +1,11 @@ +JWK_EXPIRES_MIN = 30 + + +class JWKKeyType: + RSA = 'RSA' + EC = 'EC' + OCT = 'oct' + + +class JWKUse: + SIG = 'sig' diff --git a/ridi_oauth2/introspector/dtos.py b/ridi_oauth2/introspector/dtos.py index 1172625..d5a67f4 100644 --- a/ridi_oauth2/introspector/dtos.py +++ b/ridi_oauth2/introspector/dtos.py @@ -1,19 +1,11 @@ import typing -from datetime import datetime +from base64 import urlsafe_b64decode +from datetime import datetime, timedelta +from Crypto.PublicKey import RSA -class JwtInfo: - def __init__(self, secret: str, algorithm: str): - self._secret = secret - self._algorithm = algorithm - - @property - def secret(self) -> str: - return self._secret - - @property - def algorithm(self) -> str: - return self._algorithm +from lib.utils.bytes import bytes_to_int +from ridi_oauth2.introspector.constants import JWK_EXPIRES_MIN class AccessTokenInfo: @@ -55,3 +47,40 @@ def from_dict(dictionary: typing.Dict): subject=dictionary['sub'], u_idx=dictionary['u_idx'], expire=dictionary['exp'], client_id=dictionary['client_id'], scope=dictionary['scope'], ) + + +class JWKDto: + def __init__(self, json): + self._json = json + self.expires = datetime.now() + timedelta(minutes=JWK_EXPIRES_MIN) + decoded_n = bytes_to_int(urlsafe_b64decode(self.n)) + decoded_e = bytes_to_int(urlsafe_b64decode(self.e)) + self.public_key = RSA.construct((decoded_n, decoded_e)).exportKey().decode() + + @property + def alg(self) -> str: + return self._json.get('alg') + + @property + def kty(self) -> str: + return self._json.get('kty') + + @property + def use(self) -> str: + return self._json.get('use') + + @property + def e(self) -> str: + return self._json.get('e') + + @property + def n(self) -> str: + return self._json.get('n') + + @property + def kid(self) -> str: + return self._json.get('kid') + + @property + def is_expired(self) -> bool: + return self.expires < datetime.now() diff --git a/ridi_oauth2/introspector/exceptions.py b/ridi_oauth2/introspector/exceptions.py index e60eb8c..0a02611 100644 --- a/ridi_oauth2/introspector/exceptions.py +++ b/ridi_oauth2/introspector/exceptions.py @@ -8,3 +8,31 @@ class ExpireTokenException(Exception): class InvalidToken(Exception): pass + + +class PublicKeyException(Exception): + pass + + +class InvalidPublicKey(PublicKeyException): + pass + + +class FailToLoadPublicKeyException(PublicKeyException): + pass + + +class NotExistedKey(PublicKeyException): + pass + + +class PublicRequestException(PublicKeyException): + pass + + +class AccountServerException(PublicRequestException): + pass + + +class ClientRequestException(PublicRequestException): + pass diff --git a/ridi_oauth2/introspector/helpers.py b/ridi_oauth2/introspector/helpers.py index cad351f..15e755b 100644 --- a/ridi_oauth2/introspector/helpers.py +++ b/ridi_oauth2/introspector/helpers.py @@ -1,33 +1,31 @@ -from typing import Dict - import jwt +from jwt import InvalidTokenError +from jwt.exceptions import InvalidKeyError -from ridi_oauth2.introspector.dtos import AccessTokenInfo, JwtInfo +from ridi_oauth2.introspector.dtos import AccessTokenInfo from ridi_oauth2.introspector.exceptions import InvalidJwtSignatureException, InvalidToken -from ridi_oauth2.introspector.jwt_introspector import JwtIntrospector +from ridi_oauth2.introspector.key_handler import KeyHandler class JwtIntrospectHelper: @staticmethod - def introspect(jwt_infos: Dict[str, JwtInfo], access_token: str) -> AccessTokenInfo: + def introspect(access_token: str) -> AccessTokenInfo: try: unverified_header = jwt.get_unverified_header(access_token) + unverified_payload = jwt.decode(access_token, verify=False) except jwt.InvalidTokenError: raise InvalidToken - kid = unverified_header.get('kid') - if not kid: - raise InvalidJwtSignatureException + kid = unverified_header.get('kid', None) + client_id = unverified_payload.get('client_id', None) - jwt_info = jwt_infos.get(kid) - if not jwt_info: + if not kid or not client_id: raise InvalidJwtSignatureException - introspector = JwtIntrospector(jwt_info=jwt_info, access_token=access_token) - result = introspector.introspect() + public_key = KeyHandler.get_public_key_by_kid(client_id, kid) try: - return AccessTokenInfo.from_dict(result) - - except KeyError: + payload = jwt.decode(jwt=access_token, key=public_key, algorithms=unverified_header.get('alg')) + return AccessTokenInfo.from_dict(payload) + except (InvalidTokenError, InvalidKeyError): raise InvalidJwtSignatureException diff --git a/ridi_oauth2/introspector/jwt_introspector.py b/ridi_oauth2/introspector/jwt_introspector.py deleted file mode 100644 index 53e56ea..0000000 --- a/ridi_oauth2/introspector/jwt_introspector.py +++ /dev/null @@ -1,47 +0,0 @@ - -import typing - -import jwt - -from ridi_oauth2.common.constants import TokenType -from ridi_oauth2.introspector.dtos import JwtInfo -from ridi_oauth2.introspector.exceptions import ExpireTokenException, InvalidJwtSignatureException -from .base import BaseIntrospector - - -class JwtIntrospector(BaseIntrospector): - _DEFAULT_SCOPE_DELIMITER = ' ' - - def __init__(self, jwt_info: JwtInfo, access_token: str): - self._jwt_info = jwt_info - super().__init__(access_token=access_token, token_type_hint=TokenType.BEARER) - - def introspect(self) -> typing.Dict: - try: - payload = jwt.decode(jwt=self.access_token, key=self._jwt_info.secret, algorithms=[self._jwt_info.algorithm]) - except jwt.exceptions.ExpiredSignatureError: - raise ExpireTokenException - except jwt.exceptions.DecodeError: - raise InvalidJwtSignatureException - except jwt.InvalidAlgorithmError: - raise InvalidJwtSignatureException - - payload = self._active_response(payload=payload) - payload = self._split_scopes(payload=payload) - return payload - - @staticmethod - def _active_response(payload: typing.Dict) -> typing.Dict: - payload.update({'active': True}) - return payload - - @classmethod - def _split_scopes(cls, payload: typing.Dict) -> typing.Dict: - if payload.get('scope', None) is None: - return payload - - if isinstance(payload['scope'], list): - return payload - - payload['scope'] = payload['scope'].split(cls._DEFAULT_SCOPE_DELIMITER) - return payload diff --git a/ridi_oauth2/introspector/key_handler.py b/ridi_oauth2/introspector/key_handler.py new file mode 100644 index 0000000..2dcfd0e --- /dev/null +++ b/ridi_oauth2/introspector/key_handler.py @@ -0,0 +1,74 @@ +from typing import Dict, List + +import requests +from requests import RequestException, Response + +from lib.decorators.retry import RetryFailException, retry +from ridi_django_oauth2.config import RidiOAuth2Config +from ridi_oauth2.introspector.constants import JWKKeyType, JWKUse +from ridi_oauth2.introspector.dtos import JWKDto +from ridi_oauth2.introspector.exceptions import AccountServerException, ClientRequestException, FailToLoadPublicKeyException, \ + InvalidPublicKey, NotExistedKey + + +class KeyHandler: + _public_key_dtos = {} + + @classmethod + def _get_memorized_key_dto(cls, client_id: str, kid: str) -> JWKDto: + return cls._public_key_dtos.get(client_id, {}).get(kid, None) + + @classmethod + def get_public_key_by_kid(cls, client_id: str, kid: str): + public_key_dto = cls._get_memorized_key_dto(client_id, kid) + + if not public_key_dto or public_key_dto.is_expired: + public_key_dto = cls._reset_key_dtos(client_id, kid) + + cls._assert_valid_key(public_key_dto) + + return public_key_dto.public_key + + @staticmethod + def _assert_valid_key(key: JWKDto): + if not key: + raise NotExistedKey + if key.kty != JWKKeyType.RSA or key.use != JWKUse.SIG: + raise InvalidPublicKey + + @classmethod + def _reset_key_dtos(cls, client_id: str, kid: str) -> JWKDto: + try: + keys = cls._get_valid_public_keys_by_client_id(client_id) + + except RetryFailException: + raise FailToLoadPublicKeyException + + cls._memorize_key_dtos(client_id, keys) + + return cls._get_memorized_key_dto(client_id, kid) + + @classmethod + def _memorize_key_dtos(cls, client_id: str, keys: List[JWKDto]): + key_dtos = cls._public_key_dtos.get(client_id, {}) + for key in keys: + key_dtos[key.kid] = key + cls._public_key_dtos[client_id] = key_dtos + + @staticmethod + def _process_response(response: Response) -> Dict: + if response.status_code >= 500: + raise AccountServerException + elif response.status_code >= 400: + raise ClientRequestException + return response.json() + + @classmethod + @retry(retry_count=3, retriable_exceptions=(RequestException, AccountServerException,)) + def _get_valid_public_keys_by_client_id(cls, client_id: str) -> List[JWKDto]: + response = requests.request( + method='GET', + url=RidiOAuth2Config.get_key_url(), + params={'client_id': client_id}, + ) + return [JWKDto(key) for key in cls._process_response(response=response).get('keys')] diff --git a/runtests.py b/runtests.py index 990448b..0728ffa 100644 --- a/runtests.py +++ b/runtests.py @@ -6,7 +6,6 @@ sys.path.append(os.path.abspath('./src')) - SETTINGS_DICT = { 'DEBUG': True, 'USE_TZ': True, @@ -27,18 +26,12 @@ 'MIDDLEWARE_CLASSES': ( 'ridi_django_oauth2.middlewares.AuthenticationMiddleware', ), - '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', 'RIDI_OAUTH2_AUTHORIZATION_URL': 'http://localhost/oauth2/authorize/', 'RIDI_OAUTH2_TOKEN_URL': 'http://localhost/oauth2/token/', + 'RIDI_OAUTH2_KEY_URL': 'https://account.dev.ridi.io/oauth2/keys/public', } diff --git a/setup.py b/setup.py index c2133b4..09577f1 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -version = '0.0.15' +version = '1.0.0' # When the project is installed by pip, this is the specification that is used to install its dependencies. install_requires = [ diff --git a/tests/tests_ridi_django_oauth2/test_authentication_middleware.py b/tests/tests_ridi_django_oauth2/test_authentication_middleware.py index 836c5a9..f049fbe 100644 --- a/tests/tests_ridi_django_oauth2/test_authentication_middleware.py +++ b/tests/tests_ridi_django_oauth2/test_authentication_middleware.py @@ -1,7 +1,9 @@ +import json import time from unittest.mock import Mock import jwt +import requests_mock from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.test import TestCase @@ -15,8 +17,9 @@ class AuthenticationMiddlewareTestCase(TestCase): def setUp(self): self.middleware = AuthenticationMiddleware() self.headers = { - 'kid': '0', + 'kid': 'RS999', } + self.private_key = '-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEA1rL5PCEv2PaAASaGldzfnlo0MiMCglC+eFxYHgUfa6a7qJhj\no0QX8LeAelBlQpMCAMVGX33jUJ2FCCP/QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n\n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg/FuplBFT82e14UVmZx4kP+HwDjaSp\nvYHoTr3b5j20Ebx7aIy/SVrWeY0wxeAdFf+EOuEBQ+QIIe5Npd49gzq4CGHeNJlP\nQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh\n5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQIDAQABAoIBAGxaiORqz04NIY7z\nFYs+nHC7j4oaFyMTgv0Vhbco2LGoxR6SQf7c18Q5qBKSznfp32HqLdj1nKpLxR7V\no/WSqqTPPLMU/oxI1TnFH8YUT918FbcbRNcSVsQirDWkpskpjoeMrBcGvE10u+aD\nJQnufKChDPhBuBZ3/Xf4415Lcw7xAsooHWxN5dBlq80ITyO3Rpwp7VayNRg3fzqy\ndZgsZoutyJmKd5dyKpckCpnGXOI1uvs/bnL2GLWWZvAS7/PDm+8pv3iVzmLB4J4J\njogLtJteHczxvVpIEotTAI7hLyb89k1NRncGE7zxKwtP0sIp1qg7AZGlVE8YImSg\nBhA4uU0CgYEA4uLFIx3td6jWstFok6/J36VHM8K/o8BqWgHqlypfwBAGMTuXwNHY\nMwFQNyTsT0BHoFN8e60s5wE1PxlL+hrjepXWxyCyWoHJqHpi3wqdDTMLkwniQnYr\n4c9cKcOAbkbVo+zsycuXiEQvXzLiltYII4P3KyBH4T+kQJM+4j8drocCgYEA8j/e\nXEJZaAAN95wUhfw1yfuJhXvZ5sB//nJlxCEnY3Kr5/o36eRTDGYai9qEkZEi2Ntz\nlB57mZQdI+VSRU5OADmQUYTVY5f+KQenzA0HnEFT/8aBdEvvGAWQDnULS4UPLqZS\nDUqV4L/CuBXhlHE7vz7nbNfQGzu+WG6EdJBb/xcCgYBwXjOYotfbbal3wrLyghuP\nQkIzZn6XUVLa5RwUZg4qB0Wp2IPeIY/cIwhhZ04KKiHPS8nZTvlwJ28Bozu30N1c\n9xz6Xj03ChSf9o1FPfJueRuAZWLD29b77UEOBh9zfm2M1GipwMV53ZtAoOkMH1DE\nljUyDLjM3EIzIToBv5SpvQKBgFSniRcIgKHdUwQyYOGpj0p0QkyJSU5f+tp6M6Hk\nTBVunzBDuoJbrcHpdGFnDWipJVpO5gbe2CaFIeHHY4agpJVjiFFUcBWLqd/AsxyV\neRFbqvT484gmePkWCI9ky3uqlfGhYY8Pf2y41lzqGJh9MXnVi533lNvPducER/lL\n8TolAoGANsr7iMbS5+iM6PvbSnkoNecVB2uScUO8K0ZIMNc2NDq1C+7tOGCQSi93\nIk7xlStrOD7vhmSSZuvUMZ23F7R9b8RXq0XIfokKYkPiCUjdrgC9PP54CDwUbglS\nlKP4SrbeuaTbTuMuuN6es7h1kkcz5qORtm1hWXLFTmloqYe4r5Q=\n-----END RSA PRIVATE KEY-----' self.valid_token = jwt.encode(payload={ 'sub': 'testuser', @@ -24,13 +27,13 @@ def setUp(self): 'exp': int(time.time()) + 60 * 60, 'client_id': 'asfeih29snv8as213i', 'scope': 'all' - }, key='dummy_jwt_secret', headers=self.headers).decode() + }, key=self.private_key, algorithm='RS256', 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', headers=self.headers).decode() + }, key=self.private_key, algorithm='RS256', headers=self.headers).decode() self.expire_token = jwt.encode(payload={ 'sub': 'testuser', @@ -38,15 +41,26 @@ def setUp(self): 'exp': int(time.time()) - 60 * 60, 'client_id': 'asfeih29snv8as213i', 'scope': 'all' - }, key='dummy_jwt_secret', headers=self.headers).decode() + }, key=self.private_key, algorithm='RS256', headers=self.headers).decode() def test_login_and_not_expire(self): request = Mock() request.COOKIES = { RidiOAuth2Config.get_access_token_cookie_key(): self.valid_token, } - - response = self.middleware.process_request(request=request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + response = self.middleware.process_request(request=request) self.assertIsNone(response) self.assertTrue(request.user.is_authenticated) @@ -58,7 +72,19 @@ def test_not_login(self): request.COOKIES = { } - response = self.middleware.process_request(request=request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + response = self.middleware.process_request(request=request) self.assertIsNone(response) self.assertFalse(request.user.is_authenticated) @@ -70,7 +96,19 @@ def test_login_and_expire(self): RidiOAuth2Config.get_access_token_cookie_key(): self.expire_token, } - response = self.middleware.process_request(request=request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + response = self.middleware.process_request(request=request) self.assertIsNone(response, HttpUnauthorizedResponse) self.assertIsInstance(request.user, AnonymousUser) @@ -82,7 +120,19 @@ def test_login_and_loose_token(self): RidiOAuth2Config.get_access_token_cookie_key(): self.loose_token, } - response = self.middleware.process_request(request=request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + response = self.middleware.process_request(request=request) self.assertIsNone(response, HttpUnauthorizedResponse) self.assertIsInstance(request.user, AnonymousUser) diff --git a/tests/tests_ridi_django_oauth2/test_decorator.py b/tests/tests_ridi_django_oauth2/test_decorator.py index b8bd57a..5063095 100644 --- a/tests/tests_ridi_django_oauth2/test_decorator.py +++ b/tests/tests_ridi_django_oauth2/test_decorator.py @@ -1,7 +1,9 @@ +import json import time from unittest.mock import MagicMock, Mock import jwt +import requests_mock from django.http import HttpResponse, HttpResponseForbidden from django.test import TestCase @@ -18,6 +20,8 @@ def response_handler(request, *args, **kwargs) -> HttpResponse: class LoginRequireTestCase(TestCase): def setUp(self): self.middleware = AuthenticationMiddleware() + self.private_key = '-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEA1rL5PCEv2PaAASaGldzfnlo0MiMCglC+eFxYHgUfa6a7qJhj\no0QX8LeAelBlQpMCAMVGX33jUJ2FCCP/QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n\n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg/FuplBFT82e14UVmZx4kP+HwDjaSp\nvYHoTr3b5j20Ebx7aIy/SVrWeY0wxeAdFf+EOuEBQ+QIIe5Npd49gzq4CGHeNJlP\nQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh\n5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQIDAQABAoIBAGxaiORqz04NIY7z\nFYs+nHC7j4oaFyMTgv0Vhbco2LGoxR6SQf7c18Q5qBKSznfp32HqLdj1nKpLxR7V\no/WSqqTPPLMU/oxI1TnFH8YUT918FbcbRNcSVsQirDWkpskpjoeMrBcGvE10u+aD\nJQnufKChDPhBuBZ3/Xf4415Lcw7xAsooHWxN5dBlq80ITyO3Rpwp7VayNRg3fzqy\ndZgsZoutyJmKd5dyKpckCpnGXOI1uvs/bnL2GLWWZvAS7/PDm+8pv3iVzmLB4J4J\njogLtJteHczxvVpIEotTAI7hLyb89k1NRncGE7zxKwtP0sIp1qg7AZGlVE8YImSg\nBhA4uU0CgYEA4uLFIx3td6jWstFok6/J36VHM8K/o8BqWgHqlypfwBAGMTuXwNHY\nMwFQNyTsT0BHoFN8e60s5wE1PxlL+hrjepXWxyCyWoHJqHpi3wqdDTMLkwniQnYr\n4c9cKcOAbkbVo+zsycuXiEQvXzLiltYII4P3KyBH4T+kQJM+4j8drocCgYEA8j/e\nXEJZaAAN95wUhfw1yfuJhXvZ5sB//nJlxCEnY3Kr5/o36eRTDGYai9qEkZEi2Ntz\nlB57mZQdI+VSRU5OADmQUYTVY5f+KQenzA0HnEFT/8aBdEvvGAWQDnULS4UPLqZS\nDUqV4L/CuBXhlHE7vz7nbNfQGzu+WG6EdJBb/xcCgYBwXjOYotfbbal3wrLyghuP\nQkIzZn6XUVLa5RwUZg4qB0Wp2IPeIY/cIwhhZ04KKiHPS8nZTvlwJ28Bozu30N1c\n9xz6Xj03ChSf9o1FPfJueRuAZWLD29b77UEOBh9zfm2M1GipwMV53ZtAoOkMH1DE\nljUyDLjM3EIzIToBv5SpvQKBgFSniRcIgKHdUwQyYOGpj0p0QkyJSU5f+tp6M6Hk\nTBVunzBDuoJbrcHpdGFnDWipJVpO5gbe2CaFIeHHY4agpJVjiFFUcBWLqd/AsxyV\neRFbqvT484gmePkWCI9ky3uqlfGhYY8Pf2y41lzqGJh9MXnVi533lNvPducER/lL\n8TolAoGANsr7iMbS5+iM6PvbSnkoNecVB2uScUO8K0ZIMNc2NDq1C+7tOGCQSi93\nIk7xlStrOD7vhmSSZuvUMZ23F7R9b8RXq0XIfokKYkPiCUjdrgC9PP54CDwUbglS\nlKP4SrbeuaTbTuMuuN6es7h1kkcz5qORtm1hWXLFTmloqYe4r5Q=\n-----END RSA PRIVATE KEY-----' + self.jwt_payload = { 'sub': 'testuser', 'u_idx': 123123, @@ -27,7 +31,7 @@ def setUp(self): } self.headers = { - 'kid': '0', + 'kid': 'RS999', } self.dummy_view = login_required()(MagicMock(return_value=HttpResponse(content='success'))) @@ -56,14 +60,25 @@ def test_not_exists_token_info(self): request = Mock() request.COOKIES = { RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( - self.jwt_payload, 'dummy_jwt_secret', headers=self.headers + self.jwt_payload, key=self.private_key, algorithm='RS256', headers=self.headers ).decode(), } - self.middleware.process_request(request) - - del request.user.token_info - - response1 = self.dummy_view(None, request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + self.middleware.process_request(request=request) + del request.user.token_info + + response1 = self.dummy_view(None, request) self.assertIsNone(getattr(request.user, 'token_info', None)) self.assertIsInstance(response1, HttpUnauthorizedResponse) @@ -73,11 +88,22 @@ def test_login(self): request = Mock() request.COOKIES = { RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( - self.jwt_payload, 'dummy_jwt_secret', headers=self.headers + self.jwt_payload, key=self.private_key, algorithm='RS256', headers=self.headers ).decode(), } - self.middleware.process_request(request) - + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + self.middleware.process_request(request=request) response = self.dummy_view(None, request) self.assertIsInstance(response, HttpResponse) self.assertEqual(response.status_code, 200) @@ -104,8 +130,9 @@ def setUp(self): } self.headers = { - 'kid': '0', + 'kid': 'RS999', } + self.private_key = '-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEA1rL5PCEv2PaAASaGldzfnlo0MiMCglC+eFxYHgUfa6a7qJhj\no0QX8LeAelBlQpMCAMVGX33jUJ2FCCP/QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n\n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg/FuplBFT82e14UVmZx4kP+HwDjaSp\nvYHoTr3b5j20Ebx7aIy/SVrWeY0wxeAdFf+EOuEBQ+QIIe5Npd49gzq4CGHeNJlP\nQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh\n5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQIDAQABAoIBAGxaiORqz04NIY7z\nFYs+nHC7j4oaFyMTgv0Vhbco2LGoxR6SQf7c18Q5qBKSznfp32HqLdj1nKpLxR7V\no/WSqqTPPLMU/oxI1TnFH8YUT918FbcbRNcSVsQirDWkpskpjoeMrBcGvE10u+aD\nJQnufKChDPhBuBZ3/Xf4415Lcw7xAsooHWxN5dBlq80ITyO3Rpwp7VayNRg3fzqy\ndZgsZoutyJmKd5dyKpckCpnGXOI1uvs/bnL2GLWWZvAS7/PDm+8pv3iVzmLB4J4J\njogLtJteHczxvVpIEotTAI7hLyb89k1NRncGE7zxKwtP0sIp1qg7AZGlVE8YImSg\nBhA4uU0CgYEA4uLFIx3td6jWstFok6/J36VHM8K/o8BqWgHqlypfwBAGMTuXwNHY\nMwFQNyTsT0BHoFN8e60s5wE1PxlL+hrjepXWxyCyWoHJqHpi3wqdDTMLkwniQnYr\n4c9cKcOAbkbVo+zsycuXiEQvXzLiltYII4P3KyBH4T+kQJM+4j8drocCgYEA8j/e\nXEJZaAAN95wUhfw1yfuJhXvZ5sB//nJlxCEnY3Kr5/o36eRTDGYai9qEkZEi2Ntz\nlB57mZQdI+VSRU5OADmQUYTVY5f+KQenzA0HnEFT/8aBdEvvGAWQDnULS4UPLqZS\nDUqV4L/CuBXhlHE7vz7nbNfQGzu+WG6EdJBb/xcCgYBwXjOYotfbbal3wrLyghuP\nQkIzZn6XUVLa5RwUZg4qB0Wp2IPeIY/cIwhhZ04KKiHPS8nZTvlwJ28Bozu30N1c\n9xz6Xj03ChSf9o1FPfJueRuAZWLD29b77UEOBh9zfm2M1GipwMV53ZtAoOkMH1DE\nljUyDLjM3EIzIToBv5SpvQKBgFSniRcIgKHdUwQyYOGpj0p0QkyJSU5f+tp6M6Hk\nTBVunzBDuoJbrcHpdGFnDWipJVpO5gbe2CaFIeHHY4agpJVjiFFUcBWLqd/AsxyV\neRFbqvT484gmePkWCI9ky3uqlfGhYY8Pf2y41lzqGJh9MXnVi533lNvPducER/lL\n8TolAoGANsr7iMbS5+iM6PvbSnkoNecVB2uScUO8K0ZIMNc2NDq1C+7tOGCQSi93\nIk7xlStrOD7vhmSSZuvUMZ23F7R9b8RXq0XIfokKYkPiCUjdrgC9PP54CDwUbglS\nlKP4SrbeuaTbTuMuuN6es7h1kkcz5qORtm1hWXLFTmloqYe4r5Q=\n-----END RSA PRIVATE KEY-----' 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')])( @@ -119,10 +146,22 @@ def test_all_scope(self): request = Mock() request.COOKIES = { RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( - self.jwt_payload, 'dummy_jwt_secret', headers=self.headers + self.jwt_payload, self.private_key, algorithm='RS256', headers=self.headers ).decode(), } - self.middleware.process_request(request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + self.middleware.process_request(request=request) response1 = self.dummy_view1(None, request) response2 = self.dummy_view2(None, request) @@ -141,10 +180,22 @@ def test_restriction_scope(self): request = Mock() request.COOKIES = { RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( - self.jwt_loose_payload, 'dummy_jwt_secret', headers=self.headers + self.jwt_loose_payload, self.private_key, algorithm='RS256', headers=self.headers ).decode(), } - self.middleware.process_request(request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + self.middleware.process_request(request=request) response1 = self.dummy_view1(None, request) response2 = self.dummy_view2(None, request) @@ -162,10 +213,22 @@ def test_restriction_scope_with_custom_response(self): request = Mock() request.COOKIES = { RidiOAuth2Config.get_access_token_cookie_key(): jwt.encode( - self.jwt_loose_payload, 'dummy_jwt_secret', headers=self.headers + self.jwt_loose_payload, self.private_key, algorithm='RS256', headers=self.headers ).decode(), } - self.middleware.process_request(request) + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + self.middleware.process_request(request=request) response = self.dummy_view_with_custom_response(None, request) diff --git a/tests/tests_ridi_django_oauth2/test_token_util.py b/tests/tests_ridi_django_oauth2/test_token_util.py index b06a407..edff6a9 100644 --- a/tests/tests_ridi_django_oauth2/test_token_util.py +++ b/tests/tests_ridi_django_oauth2/test_token_util.py @@ -1,7 +1,9 @@ +import json import time from unittest.mock import Mock import jwt +import requests_mock from django.test import TestCase from ridi_django_oauth2.config import RidiOAuth2Config @@ -15,7 +17,6 @@ def test_token_from_cookie(self): RidiOAuth2Config.get_access_token_cookie_key(): 'this-is-access-token', RidiOAuth2Config.get_refresh_token_cookie_key(): 'this-is-refresh-token' } - token = get_token_from_cookie(request=request) self.assertEqual(token.access_token.token, 'this-is-access-token') @@ -30,11 +31,25 @@ def test_token_info(self): 'scope': 'all' } headers = { - 'kid': '0', + 'kid': 'RS999', } - valid_token = jwt.encode(payload=payload, key='dummy_jwt_secret', headers=headers).decode() - - token_info = get_token_info(token=valid_token) + private_key = '-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEA1rL5PCEv2PaAASaGldzfnlo0MiMCglC+eFxYHgUfa6a7qJhj\no0QX8LeAelBlQpMCAMVGX33jUJ2FCCP/QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n\n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg/FuplBFT82e14UVmZx4kP+HwDjaSp\nvYHoTr3b5j20Ebx7aIy/SVrWeY0wxeAdFf+EOuEBQ+QIIe5Npd49gzq4CGHeNJlP\nQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh\n5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQIDAQABAoIBAGxaiORqz04NIY7z\nFYs+nHC7j4oaFyMTgv0Vhbco2LGoxR6SQf7c18Q5qBKSznfp32HqLdj1nKpLxR7V\no/WSqqTPPLMU/oxI1TnFH8YUT918FbcbRNcSVsQirDWkpskpjoeMrBcGvE10u+aD\nJQnufKChDPhBuBZ3/Xf4415Lcw7xAsooHWxN5dBlq80ITyO3Rpwp7VayNRg3fzqy\ndZgsZoutyJmKd5dyKpckCpnGXOI1uvs/bnL2GLWWZvAS7/PDm+8pv3iVzmLB4J4J\njogLtJteHczxvVpIEotTAI7hLyb89k1NRncGE7zxKwtP0sIp1qg7AZGlVE8YImSg\nBhA4uU0CgYEA4uLFIx3td6jWstFok6/J36VHM8K/o8BqWgHqlypfwBAGMTuXwNHY\nMwFQNyTsT0BHoFN8e60s5wE1PxlL+hrjepXWxyCyWoHJqHpi3wqdDTMLkwniQnYr\n4c9cKcOAbkbVo+zsycuXiEQvXzLiltYII4P3KyBH4T+kQJM+4j8drocCgYEA8j/e\nXEJZaAAN95wUhfw1yfuJhXvZ5sB//nJlxCEnY3Kr5/o36eRTDGYai9qEkZEi2Ntz\nlB57mZQdI+VSRU5OADmQUYTVY5f+KQenzA0HnEFT/8aBdEvvGAWQDnULS4UPLqZS\nDUqV4L/CuBXhlHE7vz7nbNfQGzu+WG6EdJBb/xcCgYBwXjOYotfbbal3wrLyghuP\nQkIzZn6XUVLa5RwUZg4qB0Wp2IPeIY/cIwhhZ04KKiHPS8nZTvlwJ28Bozu30N1c\n9xz6Xj03ChSf9o1FPfJueRuAZWLD29b77UEOBh9zfm2M1GipwMV53ZtAoOkMH1DE\nljUyDLjM3EIzIToBv5SpvQKBgFSniRcIgKHdUwQyYOGpj0p0QkyJSU5f+tp6M6Hk\nTBVunzBDuoJbrcHpdGFnDWipJVpO5gbe2CaFIeHHY4agpJVjiFFUcBWLqd/AsxyV\neRFbqvT484gmePkWCI9ky3uqlfGhYY8Pf2y41lzqGJh9MXnVi533lNvPducER/lL\n8TolAoGANsr7iMbS5+iM6PvbSnkoNecVB2uScUO8K0ZIMNc2NDq1C+7tOGCQSi93\nIk7xlStrOD7vhmSSZuvUMZ23F7R9b8RXq0XIfokKYkPiCUjdrgC9PP54CDwUbglS\nlKP4SrbeuaTbTuMuuN6es7h1kkcz5qORtm1hWXLFTmloqYe4r5Q=\n-----END RSA PRIVATE KEY-----' + + valid_token = jwt.encode(payload=payload, key=private_key, algorithm='RS256', headers=headers).decode() + + with requests_mock.Mocker() as m: + m.get(RidiOAuth2Config.get_key_url(), text=json.dumps({ + 'keys': [{ + 'kid': 'RS999', + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "1rL5PCEv2PaAASaGldzfnlo0MiMCglC-eFxYHgUfa6a7qJhjo0QX8LeAelBlQpMCAMVGX33jUJ2FCCP_QDk3NIu74AgP7F3Z7IdmVvOfkt2myF1n3ZDyCHKdyi7MnOBtHIQCqQRGZ4XH2Ss5bmg_FuplBFT82e14UVmZx4kP-HwDjaSpvYHoTr3b5j20Ebx7aIy_SVrWeY0wxeAdFf-EOuEBQ-QIIe5Npd49gzq4CGHeNJlPQjs0EjMZFtPutCrIRSoEaLwccKQEIHcMSbsBLCJIJ5OuTmtK2WaSh7VYCrJsCbPh5tYKF6akN7TSOtDwGQVKwJjjOsxkPdYXNoAnIQ==", + "e": "AQAB", + }] + })) + + token_info = get_token_info(token=valid_token) self.assertEqual(token_info.subject, payload['sub']) self.assertEqual(token_info.u_idx, payload['u_idx']) diff --git a/tests/tests_ridi_oauth2/test_dtos.py b/tests/tests_ridi_oauth2/test_dtos.py index b66c441..d59dda2 100644 --- a/tests/tests_ridi_oauth2/test_dtos.py +++ b/tests/tests_ridi_oauth2/test_dtos.py @@ -1,18 +1,6 @@ import unittest from ridi_oauth2.client.dtos import AuthorizationServerInfo, ClientInfo -from ridi_oauth2.introspector.dtos import JwtInfo - - -class JwtInfoTestCase(unittest.TestCase): - def test_jwt_info(self): - secret = 'asdfasdfasdf' - algorithm = 'HS256' - - jwt_info = JwtInfo(secret=secret, algorithm=algorithm) - - self.assertEqual(jwt_info.secret, secret) - self.assertEqual(jwt_info.algorithm, algorithm) class ClientInfoTestCase(unittest.TestCase): diff --git a/tests/tests_ridi_oauth2/test_introspector.py b/tests/tests_ridi_oauth2/test_introspector.py deleted file mode 100644 index 24a3575..0000000 --- a/tests/tests_ridi_oauth2/test_introspector.py +++ /dev/null @@ -1,62 +0,0 @@ -import string -import time -import unittest - -import jwt - -from ridi_oauth2.common.utils.string import generate_random_str -from ridi_oauth2.introspector.dtos import JwtInfo -from ridi_oauth2.introspector.exceptions import ExpireTokenException, InvalidJwtSignatureException -from ridi_oauth2.introspector.jwt_introspector import JwtIntrospector - - -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, 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, self.alg, self.headers) - - def test_introspect(self): - jwt_info = JwtInfo(secret=self.secret, algorithm=self.alg) - introspector = JwtIntrospector(jwt_info=jwt_info, access_token=self.token) - - result = introspector.introspect() - _active = result.pop('active') - - self.assertTrue(_active) - self.assertDictEqual(result, self.claim) - - def test_introspect_with_not_jwt_token(self): - jwt_info = JwtInfo(secret=self.secret, algorithm=self.alg) - introspector = JwtIntrospector(jwt_info=jwt_info, access_token="asdfasdfasdfasdf") - - with self.assertRaises(InvalidJwtSignatureException): - introspector.introspect() - - def test_introspect_with_another_secret(self): - jwt_info = JwtInfo(secret='asdfasdf', algorithm=self.alg) - introspector = JwtIntrospector(jwt_info=jwt_info, access_token=self.token) - - with self.assertRaises(InvalidJwtSignatureException): - introspector.introspect() - - def test_expire_token(self): - jwt_info = JwtInfo(secret=self.secret, algorithm=self.alg) - introspector = JwtIntrospector(jwt_info=jwt_info, access_token=self.invalid_token) - - with self.assertRaises(ExpireTokenException): - introspector.introspect()