Skip to content

Commit

Permalink
assign users to groups from OIDC claims (#1115)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeriox authored Oct 31, 2023
1 parent 55184b0 commit 111ddf1
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 50 deletions.
28 changes: 21 additions & 7 deletions docs/admin/additional_features/oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,27 @@ JWKS_URI https://your-oidc-provider.com/certs

The following additional configuration options are available:

============================== =================================================== ========================
Value Usage Default value
============================== =================================================== ========================
scopes Scopes to request from the RP (for additional data) ``openid profile email``
end_session_endpoint redirect the user to the logout page of the IDP None (no redirect)
default groups groups to add all users logging in with this IDP to None (no groups)
============================== =================================================== ========================
============================== ==================================================== ========================
Value Usage Default value
============================== ==================================================== ========================
scopes Scopes to request from the IDP (for additional data) ``openid profile email``
end_session_endpoint redirect the user to the logout page of the IDP None (no redirect)
============================== ==================================================== ========================

Apart from the values above, you can also configure the handling of groups for each identity provider.
If your identity providers supports exposing groups in the identity token, ephios can use this information to automatically assign users to groups.
You may be required to configure your identity provider to expose the groups in the identity token, please refer to the documentation of your identity provider for more information.
It may also be neccessary to request an additional scope from the identity provider, e.g. ``groups``. Append this scope to the ``scopes`` setting if required.
To enable the functionality, you need to provide the name of the claim that contains the group information.
For example, if your identity provider exposes the groups in a claim called ``groups``, you would enter ``groups`` there.
You can also access nested claims by using a dot-separated path, e.g. ``extra_information.groups`` if the group list is situated inside an ``extra_information`` dict in the identity token.
The value of the claim must be a list of strings, where each string is the name of a group in ephios (case-insensitive).
The user will be a member of all groups that are listed in the claim, meaning they will be removed from groups that they have been manually assigned to in ephios and that are not listed in the claim.
Default groups (if configured as described below) will be added to the user in addition to the groups from the claim.
By default, any groups from the claim that do not exist in ephios will be ignored. You can change this behaviour by activating the ``create missing groups`` setting.

If your identity provider does not expose the groups in the identity token, you can still assign users to groups by using the ``default groups`` setting.
All users that log in using this identity provider will be added to the groups listed there. Existing group memberships will not be altered.

If users are logged in exclusively using identity providers, you can also hide the local login form with the appropriate settings under "ephios instance".

Expand Down
24 changes: 12 additions & 12 deletions ephios/core/dynamic_preferences_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ class OrganizationName(StringPreference):
required = False


@global_preferences_registry.register
class HideLoginForm(BooleanPreference):
name = "hide_login_form"
verbose_name = _("Hide login form")
help_text = _(
"Hide the login form on the login page. This only takes effect if you configured at least one identity provider."
)
default = False
section = general_global_section
required = False


@global_preferences_registry.register
class EnabledPlugins(MultipleChoicePreference):
name = "enabled_plugins"
Expand Down Expand Up @@ -102,18 +114,6 @@ def set_last_call(cls, value):
preferences[f"{cls.section.name}__{cls.name}"] = value


@global_preferences_registry.register
class HideLoginForm(BooleanPreference):
name = "hide_login_form"
verbose_name = _("Hide login form")
help_text = _(
"Hide the login form on the login page. This only takes effect if you configured at least one identity provider."
)
default = False
section = general_global_section
required = False


@user_preferences_registry.register
class NotificationPreference(JSONPreference):
name = "notifications"
Expand Down
40 changes: 40 additions & 0 deletions ephios/core/forms/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ModelForm,
ModelMultipleChoiceField,
MultipleChoiceField,
PasswordInput,
inlineformset_factory,
)
from django.urls import reverse
Expand All @@ -20,6 +21,7 @@

from ephios.core.consequences import WorkingHoursConsequenceHandler
from ephios.core.models import QualificationGrant, UserProfile, WorkingHours
from ephios.core.models.users import IdentityProvider
from ephios.core.services.notifications.backends import enabled_notification_backends
from ephios.core.services.notifications.types import enabled_notification_types
from ephios.core.signals import register_group_permission_fields
Expand Down Expand Up @@ -480,3 +482,41 @@ def clean_url(self):
if not url.endswith("/"):
url += "/"
return url


class IdentityProviderForm(ModelForm):
class Meta:
model = IdentityProvider
fields = [
"label",
"client_id",
"client_secret",
"scopes",
"default_groups",
"group_claim",
"create_missing_groups",
"authorization_endpoint",
"token_endpoint",
"userinfo_endpoint",
"end_session_endpoint",
"jwks_uri",
]
widgets = {
"default_groups": Select2MultipleWidget,
}

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields["client_secret"] = forms.CharField(
widget=PasswordInput(attrs={"placeholder": "********"}),
required=False,
label=_("Client secret"),
help_text=_("Leave empty to keep the current secret."),
)

def clean_client_secret(self):
client_secret = self.cleaned_data["client_secret"]
if self.instance.pk and client_secret == "":
return self.instance.client_secret
return client_secret
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.6 on 2023-10-23 20:14

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0023_remove_userprofile_first_name_and_more"),
]

operations = [
migrations.AddField(
model_name="identityprovider",
name="create_missing_groups",
field=models.BooleanField(
default=False,
help_text="If enabled, groups from the claim defined above that do not exist yet will be created automatically.",
verbose_name="create missing groups",
),
),
migrations.AddField(
model_name="identityprovider",
name="group_claim",
field=models.CharField(
blank=True,
help_text="The name of the claim that contains the user's groups. Leave empty if your provider does not support this. You can use dot notation to access nested claims.",
max_length=254,
null=True,
verbose_name="group claim",
),
),
]
16 changes: 16 additions & 0 deletions ephios/core/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,22 @@ class IdentityProvider(Model):
verbose_name=_("default groups"),
help_text=_("The groups that users logging in with this provider will be added to."),
)
group_claim = models.CharField(
max_length=254,
blank=True,
null=True,
verbose_name=_("group claim"),
help_text=_(
"The name of the claim that contains the user's groups. Leave empty if your provider does not support this. You can use dot notation to access nested claims."
),
)
create_missing_groups = models.BooleanField(
default=False,
verbose_name=_("create missing groups"),
help_text=_(
"If enabled, groups from the claim defined above that do not exist yet will be created automatically."
),
)

def __str__(self):
return _("Identity provider {label}").format(label=self.label)
7 changes: 7 additions & 0 deletions ephios/core/templates/core/group_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ <h1>{% translate "Edit group" %}</h1>
<h1>{% translate "Create new group" %}</h1>
{% endif %}
</div>
{% if oidc_group_claims %}
<div class="alert alert-warning" role="alert">
{% blocktranslate trimmed %}
This ephios instance uses an identity provider that manages group memberships. Any changes to the group memberships here will be overwritten by the identity provider when a user logs in the next time using the identity provider.
{% endblocktranslate %}
</div>
{% endif %}
{% crispy form %}
{% endblock %}

Expand Down
8 changes: 6 additions & 2 deletions ephios/core/templates/core/settings/settings_base.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load settings_extras %}
{% load i18n %}
{% load settings_extras %}

Expand All @@ -20,8 +21,11 @@ <h1>{% translate "Settings" %} {% block settings_title %}{% endblock %}</h1>
href="{% url "core:settings_notifications" %}">{% translate "Notifications" %}</a></li>
<li class="nav-item"><a class="nav-link {% if request.resolver_match.url_name == "settings_calendar" %}active{% endif %}"
href="{% url "core:settings_calendar" %}">{% translate "Calendar" %}</a></li>
<li class="nav-item"><a class="nav-link {% if request.resolver_match.url_name == "settings_password_change" %}active{% endif %}"
href="{% url "core:settings_password_change" %}">{% translate "Change password" %}</a></li>
{% identity_providers as providers %}
{% if not global_preferences.general__hide_login_form or not providers.exists %}
<li class="nav-item"><a class="nav-link {% if request.resolver_match.url_name == "settings_password_change" %}active{% endif %}"
href="{% url "core:settings_password_change" %}">{% translate "Change password" %}</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link {% if "settings-access-token" in request.resolver_match.url_name %}active{% endif %}"
href="{% url "api:settings-access-token-list" %}">{% translate "Integrations" %}</a></li>
{% available_management_settings_sections request as management_settings_sections %}
Expand Down
7 changes: 7 additions & 0 deletions ephios/core/templates/core/userprofile_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ <h1>{% translate "Edit user" %}</h1>
<h1>{% translate "Add new user" %}</h1>
{% endif %}
</div>
{% if oidc_group_claims %}
<div class="alert alert-warning" role="alert">
{% blocktranslate trimmed %}
This ephios instance uses an identity provider that manages group memberships. Any changes to the group memberships here will be overwritten by the identity provider when a user logs in the next time using the identity provider.
{% endblocktranslate %}
</div>
{% endif %}
<form method="post" class="form">
{% csrf_token %}
{{ userprofile_form|crispy }}
Expand Down
10 changes: 10 additions & 0 deletions ephios/core/views/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
UserProfileForm,
)
from ephios.core.models import Qualification, QualificationGrant, UserProfile
from ephios.core.models.users import IdentityProvider
from ephios.core.services.notifications.types import (
NewProfileNotification,
ProfileUpdateNotification,
Expand Down Expand Up @@ -175,6 +176,9 @@ def get_context_data(self, **kwargs):
self.object = self.get_object()
kwargs.setdefault("userprofile_form", self.get_userprofile_form())
kwargs.setdefault("qualification_formset", self.get_qualification_formset())
kwargs.setdefault(
"oidc_group_claims", IdentityProvider.objects.filter(group_claim__isnull=False).exists()
)
return super().get_context_data(**kwargs)

def post(self, request, *args, **kwargs):
Expand Down Expand Up @@ -329,6 +333,12 @@ class GroupUpdateView(CustomPermissionRequiredMixin, UpdateView):
template_name = "core/group_form.html"
form_class = GroupForm

def get_context_data(self, **kwargs):
kwargs.setdefault(
"oidc_group_claims", IdentityProvider.objects.filter(group_claim__isnull=False).exists()
)
return super().get_context_data(**kwargs)

def get_success_url(self):
messages.success(
self.request, _('Group "{group}" updated successfully.').format(group=self.object)
Expand Down
33 changes: 6 additions & 27 deletions ephios/core/views/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from requests import PreparedRequest, RequestException
from requests_oauthlib import OAuth2Session

from ephios.core.forms.users import OIDCDiscoveryForm
from ephios.core.forms.users import IdentityProviderForm, OIDCDiscoveryForm
from ephios.core.models.users import IdentityProvider
from ephios.extra.mixins import StaffRequiredMixin

Expand Down Expand Up @@ -89,24 +89,13 @@ def get_redirect_url(self, *args, **kwargs):

class IdentityProviderCreateView(StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = IdentityProvider
fields = [
"label",
"client_id",
"client_secret",
"scopes",
"default_groups",
"authorization_endpoint",
"token_endpoint",
"userinfo_endpoint",
"end_session_endpoint",
"jwks_uri",
]
form_class = IdentityProviderForm
success_url = reverse_lazy("core:settings_idp_list")
success_message = _("Identity provider saved.")

def get_initial(self):
initial = super().get_initial()
if "url" in self.request.GET:
if not self.request.POST and "url" in self.request.GET:
try:
oidc_configuration = requests.get(
urljoin(self.request.GET["url"], ".well-known/openid-configuration"), timeout=10
Expand Down Expand Up @@ -147,21 +136,11 @@ class IdentityProviderListView(StaffRequiredMixin, ListView):
model = IdentityProvider


class IdentityProviderUpdateView(StaffRequiredMixin, UpdateView):
class IdentityProviderUpdateView(StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = IdentityProvider
fields = [
"label",
"client_id",
"client_secret",
"scopes",
"default_groups",
"authorization_endpoint",
"token_endpoint",
"userinfo_endpoint",
"end_session_endpoint",
"jwks_uri",
]
form_class = IdentityProviderForm
success_url = reverse_lazy("core:settings_idp_list")
success_message = _("Identity provider saved.")


class IdentityProviderDeleteView(StaffRequiredMixin, DeleteView):
Expand Down
21 changes: 20 additions & 1 deletion ephios/extra/auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from datetime import date
from functools import reduce
from typing import Any, Dict
from urllib.parse import urljoin

import jwt
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Group
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
from jwt import InvalidTokenError
Expand Down Expand Up @@ -42,7 +44,24 @@ def update_user(self, user, claims):
except ValueError:
pass
user.save()
if hasattr(self, "provider") and self.provider.default_groups.exists():
if self.provider.group_claim:
groups = set(self.provider.default_groups.all())
groups_in_claims = (
reduce(
lambda d, key: d.get(key, None) if isinstance(d, dict) else None,
self.provider.group_claim.split("."),
claims,
)
or []
)
for group_name in groups_in_claims:
try:
groups.add(Group.objects.get(name__iexact=group_name))
except Group.DoesNotExist:
if self.provider.create_missing_groups:
groups.add(Group.objects.create(name=group_name))
user.groups.set(groups)
elif self.provider.default_groups.exists():
user.groups.add(*self.provider.default_groups.all())
return user

Expand Down
2 changes: 1 addition & 1 deletion ephios/templates/registration/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ <h2>{{ login_message }}</h2>
<div class="col-lg pt-4">
{% for provider in providers %}
<div class="mb-1">
<a class="btn btn-primary w-100" href="{% url 'core:oidc_initiate' provider.id %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}">{% blocktranslate trimmed with label=provider.label %}Login with {{ label }}{% endblocktranslate %}</a>
<a class="btn btn-primary w-100" href="{% url 'core:oidc_initiate' provider.id %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}">{{ provider.label }}</a>
</div>
{% endfor %}
</div>
Expand Down
Loading

0 comments on commit 111ddf1

Please sign in to comment.