diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 12fd7c04a..be0e3faab 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -100,3 +100,47 @@ You might want to completely bypass the authorization form, for instance if your in-house product or if you already trust the application owner by other means. To this end, you have to set ``skip_authorization = True`` on the ``Application`` model, either programmatically or within the Django admin. Users will *not* be prompted for authorization, even on the first use of the application. + + +.. _override-views: + +Overriding views +================ + +You may want to override whole views from Django OAuth Toolkit, for instance if you want to +change the login view for unregistred users depending on some query params. + +In order to do that, you need to write a custom urlpatterns + +.. code-block:: python + + from django.urls import re_path + from oauth2_provider import views as oauth2_views + from oauth2_provider import urls + + from .views import CustomeAuthorizationView + + + app_name = "oauth2_provider" + + urlpatterns = [ + # Base urls + re_path(r"^authorize/", CustomeAuthorizationView.as_view(), name="authorize"), + re_path(r"^token/$", oauth2_views.TokenView.as_view(), name="token"), + re_path(r"^revoke_token/$", oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^introspect/$", oauth2_views.IntrospectTokenView.as_view(), name="introspect"), + ] + urls.management_urlpatterns + urls.oidc_urlpatterns + +You can then replace ``oauth2_provider.urls`` with the path to your urls file, but make sure you keep the +same namespace as before. + +.. code-block:: python + + from django.urls import include, path + + urlpatterns = [ + ... + path('o/', include('path.to.custom.urls', namespace='oauth2_provider')), + ] + +This method also allows to remove some of the urls (such as managements) urls if you don't want them. diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 195f7a877..26bc977f2 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,4 +1,5 @@ import json +import warnings from urllib.parse import urlparse from django.contrib.auth import logout @@ -225,6 +226,8 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir will be validated against each other. """ + warnings.warn("This method is deprecated and will be removed in version 2.5.0.", DeprecationWarning) + id_token = None must_prompt_logout = True token_user = None @@ -315,8 +318,7 @@ def get(self, request, *args, **kwargs): state = request.GET.get("state") try: - prompt, (redirect_uri, application), token_user = validate_logout_request( - request=request, + application, token_user = self.validate_logout_request( id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, @@ -324,8 +326,8 @@ def get(self, request, *args, **kwargs): except OIDCError as error: return self.error_response(error) - if not prompt: - return self.do_logout(application, redirect_uri, state, token_user) + if not self.must_prompt(token_user): + return self.do_logout(application, post_logout_redirect_uri, state, token_user) self.oidc_data = { "id_token_hint": id_token_hint, @@ -347,21 +349,100 @@ def form_valid(self, form): state = form.cleaned_data.get("state") try: - prompt, (redirect_uri, application), token_user = validate_logout_request( - request=self.request, + application, token_user = self.validate_logout_request( id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, ) - if not prompt or form.cleaned_data.get("allow"): - return self.do_logout(application, redirect_uri, state, token_user) + if not self.must_prompt(token_user) or form.cleaned_data.get("allow"): + return self.do_logout(application, post_logout_redirect_uri, state, token_user) else: raise LogoutDenied() except OIDCError as error: return self.error_response(error) + def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri): + """ + Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter + """ + + if not post_logout_redirect_uri: + return + + if not application: + raise InvalidOIDCClientError() + scheme = urlparse(post_logout_redirect_uri)[0] + if not scheme: + raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.") + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and ( + scheme == "http" and application.client_type != "confidential" + ): + raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.") + if scheme not in application.get_allowed_schemes(): + raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.') + if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri): + raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.") + + def validate_logout_request_user(self, id_token_hint, client_id): + """ + Validate the an OIDC RP-Initiated Logout Request user + """ + + if not id_token_hint: + return + + # Only basic validation has been done on the IDToken at this point. + id_token, claims = _load_id_token(id_token_hint) + + if not id_token or not _validate_claims(self.request, claims): + raise InvalidIDTokenError() + + # If both id_token_hint and client_id are given it must be verified that they match. + if client_id: + if id_token.application.client_id != client_id: + raise ClientIdMissmatch() + + return id_token + + def get_request_application(self, id_token, client_id): + if client_id: + return get_application_model().objects.get(client_id=client_id) + if id_token: + return id_token.application + + def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri): + """ + Validate an OIDC RP-Initiated Logout Request. + `(application, token_user)` is returned. + + If it is set, `application` is the Application that is requesting the logout. + `token_user` is the id_token user, which will used to revoke the tokens if found. + + The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they + will be validated against each other. + """ + + id_token = self.validate_logout_request_user(id_token_hint, client_id) + application = self.get_request_application(id_token, client_id) + self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri) + + return application, id_token.user if id_token else None + + def must_prompt(self, token_user): + """Indicate whether the logout has to be confirmed by the user. This happens if the + specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. + + A logout without user interaction (i.e. no prompt) is only allowed + if an ID Token is provided that matches the current user. + """ + return ( + oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT + or token_user is None + or token_user != self.request.user + ) + def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): user = token_user or self.request.user # Delete Access Tokens if a user was found diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 6ff5dc5dc..5ae354e56 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -10,7 +10,12 @@ from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views.oidc import _load_id_token, _validate_claims, validate_logout_request +from oauth2_provider.views.oidc import ( + RPInitiatedLogoutView, + _load_id_token, + _validate_claims, + validate_logout_request, +) from . import presets @@ -187,7 +192,9 @@ def mock_request_for(user): @pytest.mark.django_db @pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) -def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT): +def test_deprecated_validate_logout_request( + oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT +): rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT oidc_tokens = oidc_tokens application = oidc_tokens.application @@ -266,6 +273,84 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp ) +@pytest.mark.django_db +def test_validate_logout_request(oidc_tokens, public_application): + oidc_tokens = oidc_tokens + application = oidc_tokens.application + client_id = application.client_id + id_token = oidc_tokens.id_token + view = RPInitiatedLogoutView() + view.request = mock_request_for(oidc_tokens.user) + assert view.validate_logout_request( + id_token_hint=None, + client_id=None, + post_logout_redirect_uri=None, + ) == (None, None) + assert view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri=None, + ) == (application, None) + assert view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="http://example.org", + ) == (application, None) + assert view.validate_logout_request( + id_token_hint=id_token, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) == (application, oidc_tokens.user) + assert view.validate_logout_request( + id_token_hint=id_token, + client_id=client_id, + post_logout_redirect_uri="http://example.org", + ) == (application, oidc_tokens.user) + with pytest.raises(ClientIdMissmatch): + view.validate_logout_request( + id_token_hint=id_token, + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) + with pytest.raises(InvalidOIDCClientError): + view.validate_logout_request( + id_token_hint=None, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="imap://example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="http://other.org", + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) +def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT): + rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT + oidc_tokens = oidc_tokens + assert RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(None) is True + assert ( + RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(oidc_tokens.user) + == ALWAYS_PROMPT + ) + assert RPInitiatedLogoutView(request=mock_request_for(other_user)).must_prompt(oidc_tokens.user) is True + + def test__load_id_token(): assert _load_id_token("Not a Valid ID Token.") == (None, None)