🔧(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_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: publish.settings DJANGO_SETTINGS_MODULE: publish.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost DB_HOST: localhost
DB_NAME: publish DB_NAME: publish
DB_USER: dinum DB_USER: dinum

View File

@@ -55,7 +55,7 @@ COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
# collectstatic # collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the # Replace duplicated file by a symlink to decrease the overall size of the

View File

@@ -7,9 +7,6 @@ DJANGO_SUPERUSER_PASSWORD=admin
# Python # Python
PYTHONPATH=/app PYTHONPATH=/app
#JWT
DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly
# publish settings # publish settings
# Mail # Mail
@@ -18,3 +15,21 @@ DJANGO_EMAIL_PORT=1025
# Backend url # Backend url
publish_BASE_URL="http://localhost:8072" 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.""" """Authentication for the Impress core app."""
from django.conf import settings
from django.utils.functional import SimpleLazyObject from django.core.exceptions import SuspiciousOperation
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from drf_spectacular.authentication import SessionScheme, TokenScheme import requests
from drf_spectacular.plumbing import build_bearer_security_scheme_object from mozilla_django_oidc.auth import (
from rest_framework import authentication OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
from rest_framework_simplejwt.authentication import JWTAuthentication
class DelegatedJWTAuthentication(JWTAuthentication):
"""Override JWTAuthentication to create missing users on the fly."""
def get_user(self, validated_token):
"""
Return the user related to the given validated token, creating or updating it if necessary.
"""
get_user = import_string(settings.JWT_USER_GETTER)
return SimpleLazyObject(lambda: get_user(validated_token))
class OpenApiJWTAuthenticationExtension(TokenScheme):
"""Extension for specifying JWT authentication schemes."""
target_class = "core.authentication.DelegatedJWTAuthentication"
name = "DelegatedJWTAuthentication"
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
) )
from .models import User
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 class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
for the WWW-Authenticate header can be retrieved and the response code is """Custom OpenID Connect (OIDC) Authentication Backend.
automatically set to 401 in case of unauthenticated requests.
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 authenticate_header(self, request): def get_userinfo(self, access_token, id_token, payload):
return "Session" """Return user details dictionary.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
class OpenApiSessionAuthenticationExtension(SessionScheme): Note: The id_token and payload parameters are unused in this implementation,
"""Extension for specifying session authentication schemes.""" but were kept to preserve base method signature.
target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader" 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.
"""
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.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
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
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
)
return user

View File

@@ -16,8 +16,6 @@ from django.utils.translation import gettext_lazy as _
import frontmatter import frontmatter
import markdown import markdown
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from weasyprint import CSS, HTML from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration from weasyprint.text.fonts import FontConfiguration
@@ -331,27 +329,3 @@ class TemplateAccess(BaseModel):
"retrieve": bool(role), "retrieve": bool(role),
"set_role_to": set_role_to, "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.conf import settings
from django.urls import include, path, re_path 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 rest_framework.routers import DefaultRouter
from core.api import viewsets from core.api import viewsets
@@ -26,6 +27,7 @@ urlpatterns = [
include( include(
[ [
*router.urls, *router.urls,
*oidc_urls,
re_path( re_path(
r"^templates/(?P<template_id>[0-9a-z-]*)/", r"^templates/(?P<template_id>[0-9a-z-]*)/",
include(template_related_router.urls), include(template_related_router.urls),

View File

@@ -181,6 +181,7 @@ class Base(Configuration):
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
"core.authentication.OIDCAuthenticationBackend",
] ]
# Django applications from the highest priority to the lowest # Django applications from the highest priority to the lowest
@@ -204,6 +205,8 @@ class Base(Configuration):
"django.contrib.sites", "django.contrib.sites",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
# OIDC third party
"mozilla_django_oidc",
] ]
# Cache # Cache
@@ -213,7 +216,8 @@ class Base(Configuration):
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (
"core.authentication.DelegatedJWTAuthentication", "mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
), ),
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser", "rest_framework.parsers.JSONParser",
@@ -242,23 +246,6 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR", "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 # Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend") EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None) EMAIL_HOST = values.Value(None)
@@ -288,6 +275,63 @@ class Base(Configuration):
CELERY_BROKER_URL = values.Value("redis://redis:6379/0") CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}) 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 # pylint: disable=invalid-name
@property @property
def ENVIRONMENT(self): def ENVIRONMENT(self):

View File

@@ -35,7 +35,6 @@ dependencies = [
"django-storages==1.14.2", "django-storages==1.14.2",
"django-timezone-field>=5.1", "django-timezone-field>=5.1",
"django==5.0.2", "django==5.0.2",
"djangorestframework-simplejwt==5.3.0",
"djangorestframework==3.14.0", "djangorestframework==3.14.0",
"drf_spectacular==0.26.5", "drf_spectacular==0.26.5",
"dockerflow==2022.8.0", "dockerflow==2022.8.0",
@@ -53,6 +52,7 @@ dependencies = [
"url-normalize==1.4.3", "url-normalize==1.4.3",
"WeasyPrint>=60.2", "WeasyPrint>=60.2",
"whitenoise==6.6.0", "whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
] ]
[project.urls] [project.urls]