added SSO support using Python Social Auth including custom backend for EF Identity

This commit is contained in:
Jenny Danzmayr 2024-09-10 00:37:45 +02:00
parent 42da0ffa27
commit 09b2375d79
10 changed files with 119 additions and 9 deletions

View file

@ -70,6 +70,7 @@ RUN --mount=type=cache,target=/pip-cache \
-r requirements/sentry.txt \
-r requirements/metrics.txt \
-r requirements/uwu.txt \
-r requirements/sso.txt \
-r requirements/server-asgi.txt && \
pip install --cache-dir /pip-cache uwsgi

View file

@ -0,0 +1,34 @@
from django.conf import settings
from social_core.utils import SETTING_PREFIX as SOCIAL_AUTH_SETTING_PREFIX
from social_core.backends.utils import load_backends
from social_django.strategy import DjangoStrategy
_sso_services = None
def get_sso_services() -> dict[str, str]:
global _sso_services
from social_django.utils import load_strategy
if _sso_services is None:
_sso_services = dict()
for backend in load_backends(load_strategy().get_backends()).values():
_sso_services[backend.name] = getattr(backend, 'verbose_name', backend.name.replace('-', ' ').capitalize())
return _sso_services
class C3navStrategy(DjangoStrategy):
"""A subclass of DjangoStrategy that uses our config parser in addition to django settings"""
_list_keys = {'authentication_backends', 'pipeline'}
def get_setting(self, name: str):
config_name = name.removeprefix(SOCIAL_AUTH_SETTING_PREFIX + '_').lower()
value = settings.C3NAV_CONFIG.get('sso', config_name,
fallback=None)
if value is not None:
if config_name in self._list_keys:
value = tuple(item.strip() for item in value.split(','))
else:
value = super().get_setting(name)
return value

View file

@ -0,0 +1,11 @@
from social_core.backends.open_id_connect import OpenIdConnectAuth
class EurofurenceIdentityOpenId(OpenIdConnectAuth):
"""Eurofurence Identity OpenID authentication backend"""
name = 'eurofurence-identity'
OIDC_ENDPOINT = 'https://identity.eurofurence.org/'
DEFAULT_SCOPE = ['openid', 'profile', 'groups']
EXTRA_DATA = ["id_token", "refresh_token", ("sub", "id"), "groups"]
TOKEN_ENDPOINT_AUTH_METHOD = 'client_secret_post'
USERNAME_KEY = "name"

View file

@ -8,6 +8,7 @@ from contextlib import suppress
from pathlib import Path
from typing import Optional
import django.conf.locale
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured
from django.utils.crypto import get_random_string
@ -37,7 +38,7 @@ def get_data_dir(setting: str, fallback: Path, create: bool = True, parents: boo
env = Env()
config = C3navConfigParser(env=env)
C3NAV_CONFIG = config = C3navConfigParser(env=env)
if 'C3NAV_CONFIG' in env:
# if a config file is explicitly defined, make sure we can read it.
env.path('C3NAV_CONFIG').open('r')
@ -341,6 +342,9 @@ TILE_ACCESS_COOKIE_SECURE = not DEBUG
TILE_ACCESS_COOKIE_SAMESITE = 'none' if SESSION_COOKIE_SECURE else 'lax'
SSO_ENABLED = config.getboolean('sso', 'enabled', fallback=False)
# Application definition
INSTALLED_APPS = [
@ -354,6 +358,7 @@ INSTALLED_APPS = [
'channels',
'compressor',
'bootstrap3',
*(['social_django'] if SSO_ENABLED else []),
*(["ninja"] if SERVE_API else []),
'c3nav.api',
'c3nav.mapdata',
@ -454,7 +459,6 @@ EXTRA_LANG_INFO = {
}
# Add custom languages not provided by Django
import django.conf.locale
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
django.conf.locale.LANG_INFO = LANG_INFO
@ -772,3 +776,37 @@ AUTH_PASSWORD_VALIDATORS = [
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# SSO
SOCIAL_AUTH_STRATEGY = 'c3nav.control.sso.C3navStrategy'
SOCIAL_AUTH_JSONFIELD_ENABLED = DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
)
if SSO_ENABLED:
# add the enabled authentication backends to AUTHENTICATION_BACKENDS
# we need this despite our own strategy looking it up directly because the backends context processor of
# social_django directly uses the django setting without asking the normal config pipeline
AUTHENTICATION_BACKENDS = (
* (
backend.strip()
for backend in config.get('sso', 'authentication_backends', fallback='').split(',')
),
*AUTHENTICATION_BACKENDS,
)
SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_details',
'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.auth_allowed',
'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username',
'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user',
'social_core.pipeline.social_auth.load_extra_data',
'social_core.pipeline.user.user_details',
)

View file

@ -160,7 +160,7 @@ main.account form {
max-width: 400px;
}
#modal-content form button[type=submit], #modal-content .answers .button {
#modal-content form button[type=submit], #modal-content form .button.sso, #modal-content .answers .button {
display: block;
width: 100%;
}
@ -1343,7 +1343,7 @@ main .narrow p, main .narrow form, main .narrow button {
margin-bottom: 1.0rem;
}
main .narrow form button {
main .narrow form button, main .narrow form .button {
width: 100%;
}

View file

@ -1344,7 +1344,7 @@ c3nav = {
_modal_link_click: function (e) {
var location = $(this).attr('href');
if ($(this).is('[target]') || c3nav._href_modal_open_tab(location)) {
$(this).attr('target', '_blank');
if(!$(this).is('[target]')) $(this).attr('target', '_blank');
return;
}
e.preventDefault();

View file

@ -17,6 +17,11 @@
{% csrf_token %}
{{ form.as_p }}
<button type="submit">{{ title }}</button>
{% if sso_services %}
{% for sso_service_id, sso_service_name in sso_services.items %}
<a href="{% url "social:begin" sso_service_id %}?{{ redirect_query }}" class="button sso" target="_self">Login with {{ sso_service_name }}</a>
{% endfor %}
{% endif %}
{% if bottom_link_url %}
<a href="{{ bottom_link_url }}?{{ request.META.QUERY_STRING }}">{{ bottom_link_text }}</a>
{% endif %}

View file

@ -6,7 +6,7 @@ from urllib.parse import urlparse
import qrcode
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login, logout
from django.contrib.auth import REDIRECT_FIELD_NAME, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.core.cache import cache
@ -14,7 +14,7 @@ from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation, Vali
from django.core.paginator import Paginator
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.http import Http404, HttpResponse, HttpResponseBadRequest, QueryDict
from django.middleware import csrf
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@ -32,8 +32,8 @@ from c3nav.control.forms import AccessPermissionForm, SignedPermissionDataError
from c3nav.mapdata.grid import grid
from c3nav.mapdata.models import Location, Source
from c3nav.mapdata.models.access import AccessPermission, AccessPermissionToken
from c3nav.mapdata.models.locations import LocationRedirect, Position, SpecificLocation, get_position_secret, \
LocationGroup
from c3nav.mapdata.models.locations import (LocationGroup, LocationRedirect, Position, SpecificLocation,
get_position_secret)
from c3nav.mapdata.models.report import Report, ReportUpdate
from c3nav.mapdata.utils.locations import (get_location_by_id_for_request, get_location_by_slug_for_request,
levels_by_short_label_for_request)
@ -303,9 +303,19 @@ def login_view(request):
else:
form = AuthenticationForm(request)
redirect_path = request.GET.get(REDIRECT_FIELD_NAME, '/account/')
if referer := request.headers.get('Referer', None):
referer = urlparse(referer)
if referer.netloc == request.META['HTTP_HOST']:
redirect_path = f'{referer.path}?{referer.query}' if referer.query else referer.path
redirect_query = QueryDict(mutable=True)
redirect_query[REDIRECT_FIELD_NAME] = redirect_path
ctx = {
'title': _('Log in'),
'form': form,
'redirect_path': redirect_path,
'redirect_query': redirect_query.urlencode(safe="/")
}
if settings.USER_REGISTRATION:
@ -314,6 +324,10 @@ def login_view(request):
'bottom_link_text': _('Create new account')
})
if settings.SSO_ENABLED:
from c3nav.control.sso import get_sso_services
ctx['sso_services'] = get_sso_services()
return render(request, 'site/account_form.html', ctx)

View file

@ -52,3 +52,8 @@ if settings.SERVE_ANYTHING:
with suppress(ImportError):
import django_prometheus # noqu
urlpatterns.insert(0, path('prometheus/', include('django_prometheus.urls')))
if settings.SSO_ENABLED:
urlpatterns += [
path('sso/', include('social_django.urls', namespace='social'))
]

2
src/requirements/sso.txt Normal file
View file

@ -0,0 +1,2 @@
social-auth-app-django==5.4.2
social-auth-core==4.5.4