(backend) support silent login

Silent login attempts to re-authenticate the user without interaction,
provided they have an active session, improving UX by reducing manual auth.

It's an essential feature to really feel the SSO in La Suite.

A new query parameter, 'silent', allows the client to initiate a silent login.
In this flow, an extra parameter, 'prompt=none', is passed to the OIDC provider.

The requested flow is persisted in session data to adapt the authentication
callback behavior.

In a silent login flow, an authentication failure should not be considered as a
real failure. Instead, users should be redirected back to the originating view.
A silent login fails when user has no active session.

Why return the 'success_url'? The 'success_url' will redirect the user agent to
the 'returnTo' parameter provided when requesting authentication.
It's necessary to enable a silent login on any URL.

Minimal test coverage has been added for these two custom views to ensure
correct behavior.
This commit is contained in:
lebaudantoine
2024-07-24 22:11:03 +02:00
committed by aleb_the_flash
parent e7ea700c3d
commit d167490c09
3 changed files with 175 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
@@ -11,6 +12,12 @@ from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
@@ -135,3 +142,40 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent loging flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent loging flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params

View File

@@ -15,7 +15,13 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@@ -229,3 +235,125 @@ def test_view_logout_callback():
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")

View File

@@ -284,6 +284,8 @@ class Base(Configuration):
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",