Skip to content

Commit

Permalink
Merge pull request #100 from maykinmedia/feature/99-oidc-init-view
Browse files Browse the repository at this point in the history
Define "generic" OpenID Connect init view
  • Loading branch information
sergei-maertens authored May 16, 2024
2 parents 0a09fad + 1a033f8 commit ddc1100
Show file tree
Hide file tree
Showing 14 changed files with 530 additions and 34 deletions.
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
import sys
from pathlib import Path

import django

_root_dir = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(_root_dir))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")

django.setup()

# -- Project information -----------------------------------------------------

Expand Down
7 changes: 7 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Reference

Public API documentation.

Views
=====

.. automodule:: mozilla_django_oidc_db.views
:members:


Utils
=====

Expand Down
6 changes: 0 additions & 6 deletions mozilla_django_oidc_db/compat.py

This file was deleted.

38 changes: 38 additions & 0 deletions mozilla_django_oidc_db/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Helpers to work with (dynamic) OIDC configuration.
The utilities here make it easier to work with configuration that lives on a
configuration model instance rather than in Django settings, while also handling
settings that are still defined in the django settings layer.
"""

from typing import Any

from mozilla_django_oidc.utils import import_from_settings

from .models import OpenIDConnectConfigBase


def get_setting_from_config(config: OpenIDConnectConfigBase, attr: str, *args) -> Any:
"""
Look up a setting from the config record or fall back to Django settings.
Django settings are defined as ``OIDC_SOME_SETTING``, in upper case, while our
model fields typically match the name, but in lower case. So, we look up if the
requested setting exists as an attribut on the configuration instance and use that
when provided, otherwise we fall back to the django settings module.
.. note:: A setting may also be defined as a (calculated) property of some kind on
a/the configuration instance, rather than an explicit model field. That's why
we use ``hasattr`` checks rather than relying on
``config._meta.get_field(some_field)``.
"""
attr_lowercase = attr.lower()
if hasattr(config, attr_lowercase):
# Workaround for OIDC_RP_IDP_SIGN_KEY being an empty string by default.
# mozilla-django-oidc explicitly checks if `OIDC_RP_IDP_SIGN_KEY` is not `None`
# https://github.com/mozilla/mozilla-django-oidc/blob/master/mozilla_django_oidc/auth.py#L189
if (value_from_config := getattr(config, attr_lowercase)) == "":
return None
return value_from_config
return import_from_settings(attr, *args)
2 changes: 2 additions & 0 deletions mozilla_django_oidc_db/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class OIDCProviderOutage(Exception):
pass
25 changes: 11 additions & 14 deletions mozilla_django_oidc_db/mixins.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import ClassVar, Generic, TypeVar, cast

from mozilla_django_oidc.utils import import_from_settings
import warnings
from typing import Any, ClassVar, Generic, TypeVar, cast

from .config import get_setting_from_config
from .models import OpenIDConnectConfig, OpenIDConnectConfigBase

T = TypeVar("T", bound=OpenIDConnectConfigBase)
Expand All @@ -28,17 +28,8 @@ def refresh_config(self) -> None:
if hasattr(self, "_solo_config"):
del self._solo_config

def get_settings(self, attr, *args):
attr_lowercase = attr.lower()
if hasattr(self.config, attr_lowercase):
# Workaround for OIDC_RP_IDP_SIGN_KEY being an empty string by default.
# mozilla-django-oidc explicitly checks if `OIDC_RP_IDP_SIGN_KEY` is not `None`
# https://github.com/mozilla/mozilla-django-oidc/blob/master/mozilla_django_oidc/auth.py#L189
value_from_config = getattr(self.config, attr_lowercase)
if value_from_config == "":
return None
return value_from_config
return import_from_settings(attr, *args)
def get_settings(self, attr: str, *args: Any):
return get_setting_from_config(self.config, attr, *args)


class GetAttributeMixin:
Expand All @@ -50,6 +41,12 @@ def __getattribute__(self, attr: str):
if not attr.startswith("OIDC"):
return super().__getattribute__(attr)

warnings.warn(
"GetAttributeMixin will be deprecated, instead use an explicit descriptor",
category=PendingDeprecationWarning,
stacklevel=2,
)

try:
default = super().__getattribute__(attr)
except AttributeError:
Expand Down
2 changes: 1 addition & 1 deletion mozilla_django_oidc_db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import models
from django.utils.encoding import force_str
from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _

from django_jsonform.models.fields import ArrayField
from solo.models import SingletonModel, get_cache

import mozilla_django_oidc_db.settings as oidc_settings

from .compat import classproperty
from .fields import ClaimField


Expand Down
216 changes: 206 additions & 10 deletions mozilla_django_oidc_db/views.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
import logging
from typing import Any, ClassVar, Generic, TypeVar, cast
from urllib.parse import parse_qs, urlsplit

from django.contrib import admin
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import DisallowedRedirect, PermissionDenied, ValidationError
from django.db import IntegrityError, transaction
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.generic import TemplateView

from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView,
)

from .mixins import SoloConfigMixin
from .config import get_setting_from_config
from .models import OpenIDConnectConfig, OpenIDConnectConfigBase

logger = logging.getLogger(__name__)
OIDC_ERROR_SESSION_KEY = "oidc-error"

_OIDC_ERROR_SESSION_KEY = "oidc-error"
"""
Session key where to store authentication error messages.
During the callback flow, if any errors are encountered, they are stored in the session
under this key so that :class:`AdminLoginFailure` can read and display them to the
end-user.
"""

_RETURN_URL_SESSION_KEY = "oidc-db_redirect_next"
"""
Session key for the "next" URL to redirect the user to.
This is the equivalent of the "oidc_login_next" session key from mozilla_django_oidc,
which we deliberately do not rely on as their usage may change and it is private API.
In some situations the value of this session key needs to be used as base to properly
display problems (used in the ``failure_url`` flow of the callback view).
"""


def get_exception_message(exc: Exception) -> str:
Expand Down Expand Up @@ -44,11 +68,11 @@ def get(self, request):
exc_info=exc,
)
exc_message = get_exception_message(exc)
request.session[OIDC_ERROR_SESSION_KEY] = exc_message
request.session[_OIDC_ERROR_SESSION_KEY] = exc_message
return self.login_failure()
else:
if OIDC_ERROR_SESSION_KEY in request.session:
del request.session[OIDC_ERROR_SESSION_KEY]
if _OIDC_ERROR_SESSION_KEY in request.session:
del request.session[_OIDC_ERROR_SESSION_KEY]
return response


Expand All @@ -60,16 +84,188 @@ class AdminLoginFailure(TemplateView):
template_name = "admin/oidc_failure.html"

def dispatch(self, request, *args, **kwargs):
if OIDC_ERROR_SESSION_KEY not in request.session:
if _OIDC_ERROR_SESSION_KEY not in request.session:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(admin.site.each_context(self.request))
context["oidc_error"] = self.request.session[OIDC_ERROR_SESSION_KEY]
context["oidc_error"] = self.request.session[_OIDC_ERROR_SESSION_KEY]
return context


class OIDCAuthenticationRequestView(SoloConfigMixin, _OIDCAuthenticationRequestView):
pass
T = TypeVar("T", bound=OpenIDConnectConfigBase)


class OIDCInit(Generic[T], _OIDCAuthenticationRequestView):
"""
A 'view' to start an OIDC authentication flow.
This view class is parametrized with the config model/class to retrieve the
specific configuration, such as the identity provider endpoint to redirect the
user to.
This view is not necessarily meant to be exposed directly via a URL pattern, but
rather specific views are to be created from it, e.g.:
.. code-block:: python
>>> digid_init = OIDCInit.as_view(config_class=OpenIDConnectPublicConfig)
>>> redirect_response = digid_init(request)
# Redirect to some keycloak instance, for example.
These concrete views are intended to be wrapped by your own views so that you can
supply the ``return_url`` parameter:
.. code-block:: python
def my_digid_login(request):
return digid_init(request, return_url=request.GET["next"])
Compared to :class:`mozilla_django_oidc.views.OIDCAuthenticationRequestView`, some
extra actions are performed:
* Any Keycloak IdP hint is added, if configured
* The ``return_url`` is validated against unsafe redirects
* The availability of the identity provider endpoint can be checked, if it's not
available, the :class:`mozilla_django_oidc_db.exceptions.OIDCProviderOutage`
exception is raised. Note that your own code needs to handle this appropriately!
"""

_config: T
config_class: ClassVar[type[OpenIDConnectConfigBase]] = OpenIDConnectConfigBase
"""
The config model/class to get the endpoints/credentials from.
Specify this as a kwarg in the ``as_view(config_class=...)`` class method.
"""

allow_next_from_query: bool = False
"""
Specify if the url-to-redirect-to may be provided as a query string parameter.
For OIDC auth in the admin, you want to enable this to make URLs like
``/oidc/authenticate/?next=/admin/`` work as expected. For more advanced flows,
you may want explicit control over this URL via your own wrapper view:
.. code-block:: python
digid_init = OIDCInit.as_view(
config_class=OpenIDConnectPublicConfig, allow_next_from_query=False
)
def my_digid_login(request):
return digid_init(request, return_url="/some-fixed-url")
"""

def get_settings(self, attr: str, *args: Any) -> Any: # type: ignore
"""
Look up the request setting from the database config.
For the duration of the request, the configuration instance is cached on the
view.
"""
if (config := getattr(self, "_config", None)) is None:
# django-solo and type checking is challenging, but a new release is on the
# way and should fix that :fingers_crossed:
config = cast(T, self.config_class.get_solo())
self._config = config
return get_setting_from_config(config, attr, *args)

def get(
self, request: HttpRequest, return_url: str = "", *args, **kwargs
) -> HttpResponseRedirect:
if not self.allow_next_from_query:
self._validate_return_url(request, return_url=return_url)

self.check_idp_availability()

response = super().get(request, *args, **kwargs)

# update the return_url value with what the upstream library extracted from the
# GET query parameters.
if self.allow_next_from_query:
return_url = request.session["oidc_login_next"]

# We add our own key to keep track of the redirect URL. In the case of
# authentication failure (or canceled logins), the session is cleared by the
# upstream library, so in the callback view we store this URL so that we know
# where to redirect with the error information.
request.session[_RETURN_URL_SESSION_KEY] = return_url

# mozilla-django-oidc grabs this from request.GET and since that is not mutable,
# it's easiest to just override the session key with the correct value.
request.session["oidc_login_next"] = return_url

# Store which config class to use in the state. We can not simply pass this as
# a querystring parameter appended to redirect_uri, as these are likely to be
# strictly validated. We must grab the state from the redirect Location.
# This config reference is later used in the authentication callback view and
# the authentication backend.
query = parse_qs(urlsplit(response.url).query)
state_params: list[str] = query["state"]
assert len(state_params) == 1, "Expected only a single state parameter"
state_key = state_params[0]
options = self.config_class._meta

# update the state. the parent class caused the session to be marked as modified,
# so django's middleware will take care of persisting this to the session backend.
state = request.session["oidc_states"][state_key]
state["config_class"] = f"{options.app_label}.{options.object_name}"

return response

@staticmethod
def _validate_return_url(request: HttpRequest, return_url: str) -> None:
"""
Validate that the return URL meets the requirements.
1. A non-empty value needs to be provided.
2. The URL must be a safe redirect - only internal redirects are allowed.
"""
if not return_url:
raise ValueError("You must pass a return URL")

url_is_safe = url_has_allowed_host_and_scheme(
url=return_url,
allowed_hosts=request.get_host(),
require_https=request.is_secure(),
)
if not url_is_safe:
raise DisallowedRedirect(f"Can't redirect to '{return_url}'")

def check_idp_availability(self) -> None:
"""
Hook for subclasses.
Raise :class:`OIDCProviderOutage` if the Identity Provider is not available.
"""
pass

def get_extra_params(self, request: HttpRequest) -> dict[str, str]:
"""
Add a keycloak identity provider hint if configured.
"""
extra = super().get_extra_params(request)
if kc_idp_hint := self.get_settings("OIDC_KEYCLOAK_IDP_HINT", ""):
extra["kc_idp_hint"] = kc_idp_hint
return extra


class OIDCAuthenticationRequestView(OIDCInit[OpenIDConnectConfig]):
"""
Start an OIDC authentication flow.
This view is pre-configured to use the OIDC configuration included in this library,
intended for admin authentication. Enable it in your Django settings with:
.. code-block:: python
OIDC_AUTHENTICATE_CLASS = (
"mozilla_django_oidc_db.views.OIDCAuthenticationRequestView"
)
"""

config_class = OpenIDConnectConfig
allow_next_from_query = True
Loading

0 comments on commit ddc1100

Please sign in to comment.