➕(backend) add django-lasuite dependency
Use the OIDC backend from the `django-lasuite` library
This commit is contained in:
committed by
aleb_the_flash
parent
51f1f0ebbf
commit
10d759bdbb
@@ -287,6 +287,7 @@ These are the environmental options available on meet backend.
|
|||||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||||
|
| OIDC_OP_USER_ENDPOINT_FORMAT | oidc endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||||
@@ -301,6 +302,7 @@ These are the environmental options available on meet backend.
|
|||||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||||
|
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
|
||||||
| LIVEKIT_API_KEY | livekit api key | |
|
| LIVEKIT_API_KEY | livekit api key | |
|
||||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||||
| LIVEKIT_API_URL | livekit api url | |
|
| LIVEKIT_API_URL | livekit api url | |
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import requests
|
from lasuite.oidc_login.backends import (
|
||||||
from mozilla_django_oidc.auth import (
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
@@ -17,93 +18,46 @@ from core.services.marketing import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
|
|
||||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
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.
|
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_userinfo(self, access_token, id_token, payload):
|
def get_extra_claims(self, user_info):
|
||||||
"""Return user details dictionary.
|
"""
|
||||||
|
Return extra claims from user_info.
|
||||||
|
|
||||||
Parameters:
|
Args:
|
||||||
- access_token (str): The access token.
|
user_info (dict): The user information dictionary.
|
||||||
- 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:
|
Returns:
|
||||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
dict: A dictionary of extra claims.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
return {
|
||||||
user_response = requests.get(
|
# Get user's full name from OIDC fields defined in settings
|
||||||
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 not sub:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
_("User info contained no recognizable user identification")
|
|
||||||
)
|
|
||||||
|
|
||||||
email = user_info.get("email")
|
|
||||||
user = self.get_existing_user(sub, email)
|
|
||||||
|
|
||||||
claims = {
|
|
||||||
"email": email,
|
|
||||||
"full_name": self.compute_full_name(user_info),
|
"full_name": self.compute_full_name(user_info),
|
||||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||||
}
|
}
|
||||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
|
||||||
user = User.objects.create(
|
|
||||||
sub=sub,
|
|
||||||
password="!", # noqa: S106
|
|
||||||
**claims,
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||||
self.signup_to_marketing_email(email)
|
"""
|
||||||
|
Post-processing after user creation or retrieval.
|
||||||
|
|
||||||
elif not user:
|
Args:
|
||||||
return None
|
user (User): The user instance.
|
||||||
|
claims (dict): The claims dictionary.
|
||||||
|
is_new_user (bool): Indicates if the user was newly created.
|
||||||
|
|
||||||
if not user.is_active:
|
Returns:
|
||||||
raise SuspiciousOperation(_("User account is disabled"))
|
- None
|
||||||
|
|
||||||
self.update_user_if_needed(user, claims)
|
"""
|
||||||
|
email = claims["email"]
|
||||||
return user
|
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||||
|
self.signup_to_marketing_email(email)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def signup_to_marketing_email(email):
|
def signup_to_marketing_email(email):
|
||||||
@@ -116,7 +70,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
|
|
||||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||||
"""
|
"""
|
||||||
try:
|
with contextlib.suppress(
|
||||||
|
ContactCreationError, ImproperlyConfigured, ImportError
|
||||||
|
):
|
||||||
marketing_service = get_marketing_service()
|
marketing_service = get_marketing_service()
|
||||||
contact_data = ContactData(
|
contact_data = ContactData(
|
||||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||||
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
marketing_service.create_contact(
|
marketing_service.create_contact(
|
||||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||||
)
|
)
|
||||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_existing_user(self, sub, email):
|
def get_existing_user(self, sub, email):
|
||||||
"""Fetch existing user by sub or email."""
|
"""Fetch existing user by sub or email."""
|
||||||
@@ -142,32 +96,3 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
_("Multiple user accounts share a common email.")
|
_("Multiple user accounts share a common email.")
|
||||||
) from e
|
) from e
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def compute_full_name(user_info):
|
|
||||||
"""Compute user's full name based on OIDC fields in settings."""
|
|
||||||
full_name = " ".join(
|
|
||||||
filter(
|
|
||||||
None,
|
|
||||||
(
|
|
||||||
user_info.get(field)
|
|
||||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return full_name or None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_user_if_needed(user, claims):
|
|
||||||
"""Update user claims if they have changed."""
|
|
||||||
user_fields = vars(user.__class__) # Get available model fields
|
|
||||||
updated_claims = {
|
|
||||||
key: value
|
|
||||||
for key, value in claims.items()
|
|
||||||
if value and key in user_fields and value != getattr(user, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if not updated_claims:
|
|
||||||
return
|
|
||||||
|
|
||||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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,
|
|
||||||
]
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
"""Authentication Views for the People core app."""
|
|
||||||
|
|
||||||
import copy
|
|
||||||
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 (
|
|
||||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
|
||||||
)
|
|
||||||
from mozilla_django_oidc.views import (
|
|
||||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
|
||||||
"""Custom callback view for handling the silent login 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 login 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
|
|
||||||
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
|||||||
|
|
||||||
assert user.sub == "123"
|
assert user.sub == "123"
|
||||||
assert user.email is None
|
assert user.email is None
|
||||||
assert user.password == "!"
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
|||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name is None
|
assert user.full_name is None
|
||||||
assert user.short_name is None
|
assert user.short_name is None
|
||||||
assert user.password == "!"
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
|||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name == "John Doe"
|
assert user.full_name == "John Doe"
|
||||||
assert user.short_name == "John"
|
assert user.short_name == "John"
|
||||||
assert user.password == "!"
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
|||||||
django_assert_num_queries(0),
|
django_assert_num_queries(0),
|
||||||
pytest.raises(
|
pytest.raises(
|
||||||
SuspiciousOperation,
|
SuspiciousOperation,
|
||||||
match="User info contained no recognizable user identification",
|
match="Claims verification failed",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
"""Unit tests for the Authentication Views."""
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.test import RequestFactory
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import crypto
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
from core.authentication.views import (
|
|
||||||
MozillaOIDCAuthenticationCallbackView,
|
|
||||||
OIDCAuthenticationCallbackView,
|
|
||||||
OIDCAuthenticationRequestView,
|
|
||||||
OIDCLogoutCallbackView,
|
|
||||||
OIDCLogoutView,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
|
||||||
def test_view_logout_anonymous():
|
|
||||||
"""Anonymous users calling the logout url,
|
|
||||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_custom")
|
|
||||||
response = APIClient().get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
|
||||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
|
||||||
)
|
|
||||||
def test_view_logout(mocked_oidc_logout_url):
|
|
||||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_custom")
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
mocked_oidc_logout_url.assert_called_once()
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
|
||||||
@mock.patch.object(
|
|
||||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
|
||||||
)
|
|
||||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
|
||||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_custom")
|
|
||||||
|
|
||||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
|
||||||
response = client.get(url)
|
|
||||||
mocked_oidc_logout_url.assert_called_once()
|
|
||||||
mock_logout.assert_called_once()
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/default-redirect-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
|
||||||
def test_view_logout_callback_anonymous():
|
|
||||||
"""Anonymous users calling the logout callback url,
|
|
||||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_callback")
|
|
||||||
response = APIClient().get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"initial_oidc_states",
|
|
||||||
[{}, {"other_state": "foo"}],
|
|
||||||
)
|
|
||||||
def test_view_logout_persist_state(initial_oidc_states):
|
|
||||||
"""State value should be persisted in session's data."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
if initial_oidc_states:
|
|
||||||
request.session["oidc_states"] = initial_oidc_states
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
mocked_state = "mock_state"
|
|
||||||
|
|
||||||
OIDCLogoutView().persist_state(request, mocked_state)
|
|
||||||
|
|
||||||
assert "oidc_states" in request.session
|
|
||||||
assert request.session["oidc_states"] == {
|
|
||||||
"mock_state": {},
|
|
||||||
**initial_oidc_states,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
|
||||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
|
||||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
|
||||||
def test_view_logout_construct_oidc_logout_url(
|
|
||||||
mocked_get_random_string, mocked_persist_state
|
|
||||||
):
|
|
||||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
|
||||||
|
|
||||||
mocked_persist_state.assert_called_once()
|
|
||||||
mocked_get_random_string.assert_called_once()
|
|
||||||
|
|
||||||
params = parse_qs(urlparse(redirect_url).query)
|
|
||||||
|
|
||||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
|
||||||
assert params["state"][0] == "mocked_state"
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_callback")
|
|
||||||
assert url in params["post_logout_redirect_uri"][0]
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
|
||||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
|
||||||
"""If no ID token is available in the session,
|
|
||||||
the user should be redirected to the final URL."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
|
||||||
|
|
||||||
assert redirect_url == "/"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"initial_state",
|
|
||||||
[None, {"other_state": "foo"}],
|
|
||||||
)
|
|
||||||
def test_view_logout_callback_wrong_state(initial_state):
|
|
||||||
"""Should raise an error if OIDC state doesn't match session data."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
if initial_state:
|
|
||||||
request.session["oidc_states"] = initial_state
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
callback_view = OIDCLogoutCallbackView.as_view()
|
|
||||||
|
|
||||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
|
||||||
callback_view(request)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
|
||||||
def test_view_logout_callback():
|
|
||||||
"""If state matches, callback should clear OIDC state and redirects."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
mocked_state = "mocked_state"
|
|
||||||
|
|
||||||
request.session["oidc_states"] = {mocked_state: {}}
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
callback_view = OIDCLogoutCallbackView.as_view()
|
|
||||||
|
|
||||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
|
||||||
|
|
||||||
def clear_user(request):
|
|
||||||
# Assert state is cleared prior to logout
|
|
||||||
assert request.session["oidc_states"] == {}
|
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
|
||||||
mock_logout.side_effect = clear_user
|
|
||||||
response = callback_view(request)
|
|
||||||
mock_logout.assert_called_once()
|
|
||||||
|
|
||||||
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")
|
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.authentication.urls import urlpatterns as oidc_urls
|
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
|
|||||||
@@ -359,8 +359,8 @@ class Base(Configuration):
|
|||||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||||
|
|
||||||
# OIDC - Authorization Code Flow
|
# OIDC - Authorization Code Flow
|
||||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||||
OIDC_CREATE_USER = values.BooleanValue(
|
OIDC_CREATE_USER = values.BooleanValue(
|
||||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -394,6 +394,9 @@ class Base(Configuration):
|
|||||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||||
|
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||||
|
)
|
||||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -440,6 +443,11 @@ class Base(Configuration):
|
|||||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
|
||||||
|
default=[],
|
||||||
|
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Video conference configuration
|
# Video conference configuration
|
||||||
LIVEKIT_CONFIGURATION = {
|
LIVEKIT_CONFIGURATION = {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ dependencies = [
|
|||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.7.0",
|
"django-cors-headers==4.7.0",
|
||||||
"django-countries==7.6.1",
|
"django-countries==7.6.1",
|
||||||
|
"django-lasuite==0.0.7",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==5.4.0",
|
"django-redis==5.4.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user