🔧(backend) configure Authorization Code authentication

Integrate 'mozilla-django-oidc' dependency, to support
Authorization Code flow, which is required by Agent Connect.

Thus, we provide a secure back channel OIDC flow, and return
to the client only a session cookie.

Done:
- Replace JWT authentication by Session based authentication in DRF
- Update Django settings to make OIDC configurations easily editable
- Add 'mozilla-django-oidc' routes to our router
- Implement a custom Django Authentication class to adapt
'mozilla-django-oidc' to our needs

'mozilla-django-oidc' routes added are:
- /authenticate
- /callback (the redirect_uri called back by the Idp)
- /logout
This commit is contained in:
Lebaud Antoine
2024-02-24 00:21:10 +01:00
committed by Samuel Paccoud
parent 23e92d12fb
commit b9eee3e643
10 changed files with 172 additions and 125 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"}

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<template_id>[0-9a-z-]*)/",
include(template_related_router.urls),

View File

@@ -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):

View File

@@ -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]