Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ logout #60

Merged
merged 4 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Authentication for the Impress core app."""
"""Authentication Backends for the Impress core app."""

from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
Expand All @@ -8,7 +8,7 @@
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)

from .models import User
from core.models import User


class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
Expand Down
18 changes: 18 additions & 0 deletions src/backend/core/authentication/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Authentication URLs for the People core app."""

from django.urls import path

from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls

from .views import OIDCLogoutCallbackView, OIDCLogoutView

urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
137 changes: 137 additions & 0 deletions src/backend/core/authentication/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Authentication Views for the People core app."""

from urllib.parse import urlencode

from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto

from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)


class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.

Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.

The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""

@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary

This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""

if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}

request.session["oidc_states"][state] = {}
request.session.save()

def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.

Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.

If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.

The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""

oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")

if not oidc_logout_endpoint:
return self.redirect_url

reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)

if not id_token:
return self.redirect_url

query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}

self.persist_state(request, query["state"])

return f"{oidc_logout_endpoint}?{urlencode(query)}"

def post(self, request):
"""Handle user logout.

If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.

If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""

logout_url = self.redirect_url

if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)

# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)

return HttpResponseRedirect(logout_url)


class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.

Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.

The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""

http_method_names = ["get"]

def get(self, request):
"""Handle the logout callback.

If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""

if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)

state = request.GET.get("state")

if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)

del request.session["oidc_states"][state]
request.session.save()

auth.logout(request)

return HttpResponseRedirect(self.redirect_url)
Empty file.
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Unit tests for the `get_or_create_user` function."""
"""Unit tests for the Authentication Backends."""

from django.core.exceptions import SuspiciousOperation

import pytest

from core import models
from core.authentication import OIDCAuthenticationBackend
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory

pytestmark = pytest.mark.django_db
Expand Down
10 changes: 10 additions & 0 deletions src/backend/core/tests/authentication/test_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Unit tests for the Authentication URLs."""

from core.authentication.urls import urlpatterns


def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""

url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
Loading
Loading