From 1eff99c00c04785cc15329bd3a3eb8d26babd2c8 Mon Sep 17 00:00:00 2001 From: Simon Oliver Tveit Date: Thu, 17 Nov 2022 18:22:09 +0100 Subject: [PATCH 1/3] Add JWT Token authenticator --- src/argus/auth/authentication.py | 61 +++++++++++++++++++++++++++++++- src/argus/site/settings/base.py | 6 ++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/argus/auth/authentication.py b/src/argus/auth/authentication.py index f4502038e..5336865fe 100644 --- a/src/argus/auth/authentication.py +++ b/src/argus/auth/authentication.py @@ -1,10 +1,15 @@ from datetime import timedelta +from urllib.request import urlopen +import json +import jwt from django.conf import settings from django.utils import timezone -from rest_framework.authentication import TokenAuthentication +from rest_framework.authentication import TokenAuthentication, BaseAuthentication from rest_framework.exceptions import AuthenticationFailed +from .models import User + class ExpiringTokenAuthentication(TokenAuthentication): EXPIRATION_DURATION = timedelta(days=settings.AUTH_TOKEN_EXPIRES_AFTER_DAYS) @@ -17,3 +22,57 @@ def authenticate_credentials(self, key): raise AuthenticationFailed("Token has expired.") return user, token + + +class JWTAuthentication(BaseAuthentication): + def authenticate(self, request): + try: + raw_token = self.get_raw_jwt_token(request) + except ValueError: + return None + try: + validated_token = jwt.decode( + jwt=raw_token, + algorithms=["RS256", "RS384", "RS512"], + key=self.get_public_key(), + options={ + "require": [ + "exp", + "nbf", + "aud", + "iss", + "sub", + ] + }, + audience=settings.JWT_AUDIENCE, + issuer=settings.JWT_ISSUER, + ) + except jwt.exceptions.PyJWTError as e: + raise AuthenticationFailed(f"Error validating JWT token: {e}") + username = validated_token["sub"] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise AuthenticationFailed(f"No user found for username {username}") + + return user, validated_token + + def get_public_key(self): + response = urlopen(settings.JWK_ENDPOINT) + jwks = json.loads(response.read()) + jwk = jwks["keys"][0] + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) + return public_key + + def get_raw_jwt_token(self, request): + """Raises ValueError if a jwt token could not be found""" + auth_header = request.META.get("HTTP_AUTHORIZATION") + if not auth_header: + raise ValueError("No Authorization header found") + try: + scheme, token = auth_header.split() + except ValueError as e: + raise ValueError(f"Failed to parse Authorization header: {e}") + if scheme != settings.JWT_AUTH_SCHEME: + raise ValueError(f"Invalid Authorization scheme: {scheme}") + return token diff --git a/src/argus/site/settings/base.py b/src/argus/site/settings/base.py index 4160d25b4..fcd1b6a3d 100644 --- a/src/argus/site/settings/base.py +++ b/src/argus/site/settings/base.py @@ -187,6 +187,7 @@ "argus.auth.authentication.ExpiringTokenAuthentication", # For BrowsableAPIRenderer "rest_framework.authentication.SessionAuthentication", + "argus.auth.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ( @@ -301,3 +302,8 @@ # # SOCIAL_AUTH_DATAPORTEN_FEIDE_KEY = SOCIAL_AUTH_DATAPORTEN_KEY # SOCIAL_AUTH_DATAPORTEN_FEIDE_SECRET = SOCIAL_AUTH_DATAPORTEN_SECRET + +JWK_ENDPOINT = get_str_env("JWK_ENDPOINT") +JWT_ISSUER = get_str_env("JWT_ISSUER") +JWT_AUDIENCE = get_str_env("JWT_AUDIENCE") +JWT_AUTH_SCHEME = get_str_env("JWT_AUTH_SCHEME") From 36b6df69cd7c829bd6f267490529a9b232dca080 Mon Sep 17 00:00:00 2001 From: Simon Oliver Tveit Date: Fri, 18 Nov 2022 18:43:20 +0100 Subject: [PATCH 2/3] blob --- src/argus/auth/authentication.py | 72 +++++++++++++++++--------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/src/argus/auth/authentication.py b/src/argus/auth/authentication.py index 5336865fe..545658474 100644 --- a/src/argus/auth/authentication.py +++ b/src/argus/auth/authentication.py @@ -25,46 +25,26 @@ def authenticate_credentials(self, key): class JWTAuthentication(BaseAuthentication): + REQUIRED_CLAIMS = ["exp", "nbf", "aud", "iss", "sub"] + SUPPORTED_ALGORITHMS = ["RS256", "RS384", "RS512"] + def authenticate(self, request): try: - raw_token = self.get_raw_jwt_token(request) + raw_token = self.get_raw_token(request) except ValueError: return None - try: - validated_token = jwt.decode( - jwt=raw_token, - algorithms=["RS256", "RS384", "RS512"], - key=self.get_public_key(), - options={ - "require": [ - "exp", - "nbf", - "aud", - "iss", - "sub", - ] - }, - audience=settings.JWT_AUDIENCE, - issuer=settings.JWT_ISSUER, - ) - except jwt.exceptions.PyJWTError as e: - raise AuthenticationFailed(f"Error validating JWT token: {e}") - username = validated_token["sub"] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise AuthenticationFailed(f"No user found for username {username}") - - return user, validated_token + validated_token = self.decode_token(raw_token) + return self.get_user(validated_token), validated_token - def get_public_key(self): + def get_public_key(self, kid): response = urlopen(settings.JWK_ENDPOINT) jwks = json.loads(response.read()) - jwk = jwks["keys"][0] - public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) - return public_key + for jwk in jwks.get("keys"): + if jwk["kid"] == kid: + return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) + raise AuthenticationFailed(f"Invalid kid '{kid}'") - def get_raw_jwt_token(self, request): + def get_raw_token(self, request): """Raises ValueError if a jwt token could not be found""" auth_header = request.META.get("HTTP_AUTHORIZATION") if not auth_header: @@ -74,5 +54,31 @@ def get_raw_jwt_token(self, request): except ValueError as e: raise ValueError(f"Failed to parse Authorization header: {e}") if scheme != settings.JWT_AUTH_SCHEME: - raise ValueError(f"Invalid Authorization scheme: {scheme}") + raise ValueError(f"Invalid Authorization scheme '{scheme}'") return token + + def decode_token(self, raw_token): + header = jwt.get_unverified_header(raw_token) + kid = header.get("kid") + if not kid: + raise AuthenticationFailed("Token must include the 'kid' header") + public_key = self.get_public_key(kid) + try: + validated_token = jwt.decode( + jwt=raw_token, + algorithms=self.SUPPORTED_ALGORITHMS, + key=public_key, + options={"require": self.REQUIRED_CLAIMS}, + audience=settings.JWT_AUDIENCE, + issuer=settings.JWT_ISSUER, + ) + return validated_token + except jwt.exceptions.PyJWTError as e: + raise AuthenticationFailed(f"Error validating token: {e}") + + def get_user(self, token): + username = token["sub"] + try: + return User.objects.get(username=username) + except User.DoesNotExist: + raise AuthenticationFailed(f"No user found for username '{username}'") From 79ab5cfd31cb1ce3fec47c4e2c01d8e9238d2067 Mon Sep 17 00:00:00 2001 From: Simon Oliver Tveit Date: Mon, 21 Nov 2022 16:39:21 +0100 Subject: [PATCH 3/3] Reduce settings --- src/argus/auth/authentication.py | 38 +++++++++++++++++++++++--------- src/argus/site/settings/base.py | 4 +--- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/argus/auth/authentication.py b/src/argus/auth/authentication.py index 545658474..6f81af07e 100644 --- a/src/argus/auth/authentication.py +++ b/src/argus/auth/authentication.py @@ -1,5 +1,6 @@ from datetime import timedelta from urllib.request import urlopen +from urllib.parse import urljoin import json import jwt @@ -27,6 +28,7 @@ def authenticate_credentials(self, key): class JWTAuthentication(BaseAuthentication): REQUIRED_CLAIMS = ["exp", "nbf", "aud", "iss", "sub"] SUPPORTED_ALGORITHMS = ["RS256", "RS384", "RS512"] + AUTH_SCHEME = "Bearer" def authenticate(self, request): try: @@ -37,8 +39,8 @@ def authenticate(self, request): return self.get_user(validated_token), validated_token def get_public_key(self, kid): - response = urlopen(settings.JWK_ENDPOINT) - jwks = json.loads(response.read()) + r = urlopen(self.get_jwk_endpoint()) + jwks = json.loads(r.read()) for jwk in jwks.get("keys"): if jwk["kid"] == kid: return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) @@ -53,24 +55,20 @@ def get_raw_token(self, request): scheme, token = auth_header.split() except ValueError as e: raise ValueError(f"Failed to parse Authorization header: {e}") - if scheme != settings.JWT_AUTH_SCHEME: + if scheme != self.AUTH_SCHEME: raise ValueError(f"Invalid Authorization scheme '{scheme}'") return token def decode_token(self, raw_token): - header = jwt.get_unverified_header(raw_token) - kid = header.get("kid") - if not kid: - raise AuthenticationFailed("Token must include the 'kid' header") - public_key = self.get_public_key(kid) + kid = self.get_kid(raw_token) try: validated_token = jwt.decode( jwt=raw_token, algorithms=self.SUPPORTED_ALGORITHMS, - key=public_key, + key=self.get_public_key(kid), options={"require": self.REQUIRED_CLAIMS}, audience=settings.JWT_AUDIENCE, - issuer=settings.JWT_ISSUER, + issuer=self.get_openid_issuer(), ) return validated_token except jwt.exceptions.PyJWTError as e: @@ -82,3 +80,23 @@ def get_user(self, token): return User.objects.get(username=username) except User.DoesNotExist: raise AuthenticationFailed(f"No user found for username '{username}'") + + def get_openid_config(self): + url = urljoin(settings.OIDC_ENDPOINT, ".well-known/openid-configuration") + r = urlopen(url) + return json.loads(r.read()) + + def get_jwk_endpoint(self): + openid_config = self.get_openid_config() + return openid_config["jwks_uri"] + + def get_openid_issuer(self): + openid_config = self.get_openid_config() + return openid_config["issuer"] + + def get_kid(self, token): + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if not kid: + raise AuthenticationFailed("Token must include the 'kid' header") + return kid diff --git a/src/argus/site/settings/base.py b/src/argus/site/settings/base.py index fcd1b6a3d..d61ba88ef 100644 --- a/src/argus/site/settings/base.py +++ b/src/argus/site/settings/base.py @@ -303,7 +303,5 @@ # SOCIAL_AUTH_DATAPORTEN_FEIDE_KEY = SOCIAL_AUTH_DATAPORTEN_KEY # SOCIAL_AUTH_DATAPORTEN_FEIDE_SECRET = SOCIAL_AUTH_DATAPORTEN_SECRET -JWK_ENDPOINT = get_str_env("JWK_ENDPOINT") -JWT_ISSUER = get_str_env("JWT_ISSUER") +OIDC_ENDPOINT = get_str_env("OIDC_ENDPOINT") JWT_AUDIENCE = get_str_env("JWT_AUDIENCE") -JWT_AUTH_SCHEME = get_str_env("JWT_AUTH_SCHEME")