diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ca5f2709..860486e9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -130,7 +130,7 @@ jobs: DJANGO_CONFIGURATION: Test DJANGO_SETTINGS_MODULE: publish.settings DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly - DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly + OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only DB_HOST: localhost DB_NAME: publish DB_USER: dinum diff --git a/Dockerfile b/Dockerfile index 8c7c20f6..fc535dab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ COPY ./src/backend /app/ WORKDIR /app # collectstatic -RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ +RUN DJANGO_CONFIGURATION=Build \ python manage.py collectstatic --noinput # Replace duplicated file by a symlink to decrease the overall size of the diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 80097c75..4509845b 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -7,9 +7,6 @@ DJANGO_SUPERUSER_PASSWORD=admin # Python PYTHONPATH=/app -#JWT -DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly - # publish settings # Mail @@ -18,3 +15,21 @@ DJANGO_EMAIL_PORT=1025 # Backend url publish_BASE_URL="http://localhost:8072" + +# OIDC +OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs +OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth +OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token +OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo + +OIDC_RP_CLIENT_ID=impress +OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly +OIDC_RP_SIGN_ALGO=RS256 +OIDC_RP_SCOPES="openid email" + +LOGIN_REDIRECT_URL=http://localhost:3000 +LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000 +LOGOUT_REDIRECT_URL=http://localhost:3000 + +OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"] +OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} diff --git a/src/backend/core/api/utils.py b/src/backend/core/api/utils.py deleted file mode 100644 index a145a0e8..00000000 --- a/src/backend/core/api/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Utils that can be useful throughout the publish core app -""" -from rest_framework_simplejwt.tokens import RefreshToken - - -def get_tokens_for_user(user): - """Get JWT tokens for user authentication.""" - refresh = RefreshToken.for_user(user) - - return { - "refresh": str(refresh), - "access": str(refresh.access_token), - } diff --git a/src/backend/core/authentication.py b/src/backend/core/authentication.py index b87bb889..a317e670 100644 --- a/src/backend/core/authentication.py +++ b/src/backend/core/authentication.py @@ -1,59 +1,100 @@ -"""Authentication for the publish core app.""" -from django.conf import settings -from django.utils.functional import SimpleLazyObject -from django.utils.module_loading import import_string +"""Authentication for the Impress core app.""" + +from django.core.exceptions import SuspiciousOperation from django.utils.translation import gettext_lazy as _ -from drf_spectacular.authentication import SessionScheme, TokenScheme -from drf_spectacular.plumbing import build_bearer_security_scheme_object -from rest_framework import authentication -from rest_framework_simplejwt.authentication import JWTAuthentication +import requests +from mozilla_django_oidc.auth import ( + OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend, +) + +from .models import User -class DelegatedJWTAuthentication(JWTAuthentication): - """Override JWTAuthentication to create missing users on the fly.""" +class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): + """Custom OpenID Connect (OIDC) Authentication Backend. - def get_user(self, validated_token): + This class overrides the default OIDC Authentication Backend to accommodate differences + in the User and Identity models, and handles signed and/or encrypted UserInfo response. + """ + + def get_userinfo(self, access_token, id_token, payload): + """Return user details dictionary. + + Parameters: + - access_token (str): The access token. + - id_token (str): The id token (unused). + - payload (dict): The token payload (unused). + + Note: The id_token and payload parameters are unused in this implementation, + but were kept to preserve base method signature. + + Note: It handles signed and/or encrypted UserInfo Response. It is required by + Agent Connect, which follows the OIDC standard. It forces us to override the + base method, which deal with 'application/json' response. + + Returns: + - dict: User details dictionary obtained from the OpenID Connect user endpoint. """ - Return the user related to the given validated token, creating or updating it if necessary. + + user_response = requests.get( + self.OIDC_OP_USER_ENDPOINT, + headers={"Authorization": f"Bearer {access_token}"}, + verify=self.get_settings("OIDC_VERIFY_SSL", True), + timeout=self.get_settings("OIDC_TIMEOUT", None), + proxies=self.get_settings("OIDC_PROXY", None), + ) + user_response.raise_for_status() + userinfo = self.verify_token(user_response.text) + return userinfo + + def get_or_create_user(self, access_token, id_token, payload): + """Return a User based on userinfo. Get or create a new user if no user matches the Sub. + + Parameters: + - access_token (str): The access token. + - id_token (str): The ID token. + - payload (dict): The user payload. + + Returns: + - User: An existing or newly created User instance. + + Raises: + - Exception: Raised when user creation is not allowed and no existing user is found. """ - get_user = import_string(settings.JWT_USER_GETTER) - return SimpleLazyObject(lambda: get_user(validated_token)) + user_info = self.get_userinfo(access_token, id_token, payload) + sub = user_info.get("sub") -class OpenApiJWTAuthenticationExtension(TokenScheme): - """Extension for specifying JWT authentication schemes.""" + if sub is None: + raise SuspiciousOperation( + _("User info contained no recognizable user identification") + ) - target_class = "core.authentication.DelegatedJWTAuthentication" - name = "DelegatedJWTAuthentication" + try: + user = User.objects.get(sub=sub) + except User.DoesNotExist: + if self.get_settings("OIDC_CREATE_USER", True): + user = self.create_user(user_info) + else: + user = None - def get_security_definition(self, auto_schema): - """Return the security definition for JWT authentication.""" - return build_bearer_security_scheme_object( - header_name="Authorization", - token_prefix="Bearer", # noqa S106 + return user + + def create_user(self, claims): + """Return a newly created User instance.""" + + sub = claims.get("sub") + + if sub is None: + raise SuspiciousOperation( + _("Claims contained no recognizable user identification") + ) + + user = User.objects.create( + sub=sub, + email=claims.get("email"), + password="!", # noqa: S106 ) - -class SessionAuthenticationWithAuthenticateHeader(authentication.SessionAuthentication): - """ - This class is needed, because REST Framework's default SessionAuthentication does - never return 401's, because they cannot fill the WWW-Authenticate header with a - valid value in the 401 response. As a result, we cannot distinguish calls that are - not unauthorized (401 unauthorized) and calls for which the user does not have - permission (403 forbidden). - See https://github.com/encode/django-rest-framework/issues/5968 - - We do set authenticate_header function in SessionAuthentication, so that a value - for the WWW-Authenticate header can be retrieved and the response code is - automatically set to 401 in case of unauthenticated requests. - """ - - def authenticate_header(self, request): - return "Session" - - -class OpenApiSessionAuthenticationExtension(SessionScheme): - """Extension for specifying session authentication schemes.""" - - target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader" + return user diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 764cb5d5..2d4f2d95 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -16,8 +16,6 @@ from django.utils.translation import gettext_lazy as _ import frontmatter import markdown -from rest_framework_simplejwt.exceptions import InvalidToken -from rest_framework_simplejwt.settings import api_settings from timezone_field import TimeZoneField from weasyprint import CSS, HTML from weasyprint.text.fonts import FontConfiguration @@ -331,27 +329,3 @@ class TemplateAccess(BaseModel): "retrieve": bool(role), "set_role_to": set_role_to, } - - -def oidc_user_getter(validated_token): - """ - Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db. - - The token is expected to have the following fields in payload: - - sub - - email - - ... - """ - try: - user_id = validated_token[api_settings.USER_ID_CLAIM] - except KeyError as exc: - raise InvalidToken( - _("Token contained no recognizable user identification") - ) from exc - - try: - user = User.objects.get(sub=user_id) - except User.DoesNotExist: - user = User.objects.create(sub=user_id, email=validated_token.get("email")) - - return user diff --git a/src/backend/core/tests/utils.py b/src/backend/core/tests/utils.py deleted file mode 100644 index e94be94d..00000000 --- a/src/backend/core/tests/utils.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Utils for tests in the publish core application""" -from rest_framework_simplejwt.tokens import AccessToken - - -class OIDCToken(AccessToken): - """Set payload on token from user/contact/email""" - - @classmethod - def for_user(cls, user): - """Returns an authorization token for the given user for testing.""" - token = cls() - token["sub"] = str(user.sub) - token["email"] = user.email - - return token diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 4ee4f947..7530b81a 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -2,6 +2,7 @@ from django.conf import settings from django.urls import include, path, re_path +from mozilla_django_oidc.urls import urlpatterns as oidc_urls from rest_framework.routers import DefaultRouter from core.api import viewsets @@ -26,6 +27,7 @@ urlpatterns = [ include( [ *router.urls, + *oidc_urls, re_path( r"^templates/(?P[0-9a-z-]*)/", include(template_related_router.urls), diff --git a/src/backend/publish/settings.py b/src/backend/publish/settings.py index 18c059cc..9d89b5ec 100755 --- a/src/backend/publish/settings.py +++ b/src/backend/publish/settings.py @@ -181,6 +181,7 @@ class Base(Configuration): AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", + "core.authentication.OIDCAuthenticationBackend", ] # Django applications from the highest priority to the lowest @@ -204,6 +205,8 @@ class Base(Configuration): "django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", + # OIDC third party + "mozilla_django_oidc", ] # Cache @@ -213,7 +216,8 @@ class Base(Configuration): REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( - "core.authentication.DelegatedJWTAuthentication", + "mozilla_django_oidc.contrib.drf.OIDCAuthentication", + "rest_framework.authentication.SessionAuthentication", ), "DEFAULT_PARSER_CLASSES": [ "rest_framework.parsers.JSONParser", @@ -242,23 +246,6 @@ class Base(Configuration): "REDOC_DIST": "SIDECAR", } - SIMPLE_JWT = { - "ALGORITHM": values.Value("HS256", environ_name="JWT_ALGORITHM"), - "SIGNING_KEY": values.SecretValue( - environ_name="JWT_PRIVATE_SIGNING_KEY", - ), - "AUTH_HEADER_TYPES": ("Bearer",), - "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", - "USER_ID_FIELD": "sub", - "USER_ID_CLAIM": "sub", - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), - } - JWT_USER_GETTER = values.Value( - "core.models.oidc_user_getter", - environ_name="publish_JWT_USER_GETTER", - environ_prefix=None, - ) - # Mail EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend") EMAIL_HOST = values.Value(None) @@ -288,6 +275,63 @@ class Base(Configuration): CELERY_BROKER_URL = values.Value("redis://redis:6379/0") CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}) + # Session + SESSION_ENGINE = "django.contrib.sessions.backends.cache" + SESSION_COOKIE_AGE = 60 * 60 * 12 + + # OIDC - Authorization Code Flow + OIDC_CREATE_USER = values.BooleanValue( + default=True, + environ_name="OIDC_CREATE_USER", + ) + OIDC_RP_SIGN_ALGO = values.Value( + "RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None + ) + OIDC_RP_CLIENT_ID = values.Value( + "impress", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None + ) + OIDC_RP_CLIENT_SECRET = values.Value( + None, + environ_name="OIDC_RP_CLIENT_SECRET", + environ_prefix=None, + ) + OIDC_OP_JWKS_ENDPOINT = values.Value( + environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None + ) + OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value( + environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None + ) + OIDC_OP_TOKEN_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None + ) + OIDC_OP_USER_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None + ) + OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue( + {}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None + ) + OIDC_RP_SCOPES = values.Value( + "openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None + ) + LOGIN_REDIRECT_URL = values.Value( + None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None + ) + LOGIN_REDIRECT_URL_FAILURE = values.Value( + None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None + ) + LOGOUT_REDIRECT_URL = values.Value( + None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None + ) + OIDC_USE_NONCE = values.BooleanValue( + default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None + ) + OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue( + default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None + ) + OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue( + default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 2c6f624a..2a6684f4 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -35,7 +35,6 @@ dependencies = [ "django-storages==1.14.2", "django-timezone-field>=5.1", "django==5.0.2", - "djangorestframework-simplejwt==5.3.0", "djangorestframework==3.14.0", "drf_spectacular==0.26.5", "dockerflow==2022.8.0", @@ -53,6 +52,7 @@ dependencies = [ "url-normalize==1.4.3", "WeasyPrint>=60.2", "whitenoise==6.6.0", + "mozilla-django-oidc==4.0.0", ] [project.urls]