From 09b2375d79707446ee7a618646dcde5fe53fe749 Mon Sep 17 00:00:00 2001 From: Jenny Danzmayr Date: Tue, 10 Sep 2024 00:37:45 +0200 Subject: [PATCH] added SSO support using Python Social Auth including custom backend for EF Identity --- docker/Dockerfile | 1 + src/c3nav/control/sso/__init__.py | 34 +++++++++++++++ src/c3nav/control/sso/backends.py | 11 +++++ src/c3nav/settings.py | 42 ++++++++++++++++++- src/c3nav/site/static/site/css/c3nav.scss | 4 +- src/c3nav/site/static/site/js/c3nav.js | 2 +- .../site/templates/site/account_form.html | 5 +++ src/c3nav/site/views.py | 22 ++++++++-- src/c3nav/urls.py | 5 +++ src/requirements/sso.txt | 2 + 10 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 src/c3nav/control/sso/__init__.py create mode 100644 src/c3nav/control/sso/backends.py create mode 100644 src/requirements/sso.txt diff --git a/docker/Dockerfile b/docker/Dockerfile index 484cb211..b289fada 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/src/c3nav/control/sso/__init__.py b/src/c3nav/control/sso/__init__.py new file mode 100644 index 00000000..062a519a --- /dev/null +++ b/src/c3nav/control/sso/__init__.py @@ -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 diff --git a/src/c3nav/control/sso/backends.py b/src/c3nav/control/sso/backends.py new file mode 100644 index 00000000..8d300f64 --- /dev/null +++ b/src/c3nav/control/sso/backends.py @@ -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" diff --git a/src/c3nav/settings.py b/src/c3nav/settings.py index 7e40d7b0..e1a0ae0a 100644 --- a/src/c3nav/settings.py +++ b/src/c3nav/settings.py @@ -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', +) diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss index 13638153..81aced88 100644 --- a/src/c3nav/site/static/site/css/c3nav.scss +++ b/src/c3nav/site/static/site/css/c3nav.scss @@ -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%; } diff --git a/src/c3nav/site/static/site/js/c3nav.js b/src/c3nav/site/static/site/js/c3nav.js index 7bc29552..91577aee 100644 --- a/src/c3nav/site/static/site/js/c3nav.js +++ b/src/c3nav/site/static/site/js/c3nav.js @@ -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(); diff --git a/src/c3nav/site/templates/site/account_form.html b/src/c3nav/site/templates/site/account_form.html index e7756562..b0640ebf 100644 --- a/src/c3nav/site/templates/site/account_form.html +++ b/src/c3nav/site/templates/site/account_form.html @@ -17,6 +17,11 @@ {% csrf_token %} {{ form.as_p }} + {% if sso_services %} + {% for sso_service_id, sso_service_name in sso_services.items %} + Login with {{ sso_service_name }} + {% endfor %} + {% endif %} {% if bottom_link_url %} {{ bottom_link_text }} {% endif %} diff --git a/src/c3nav/site/views.py b/src/c3nav/site/views.py index 6f539438..dd50aa0a 100644 --- a/src/c3nav/site/views.py +++ b/src/c3nav/site/views.py @@ -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) diff --git a/src/c3nav/urls.py b/src/c3nav/urls.py index b65d9057..aa58c0bf 100644 --- a/src/c3nav/urls.py +++ b/src/c3nav/urls.py @@ -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')) + ] diff --git a/src/requirements/sso.txt b/src/requirements/sso.txt new file mode 100644 index 00000000..b0a2e981 --- /dev/null +++ b/src/requirements/sso.txt @@ -0,0 +1,2 @@ +social-auth-app-django==5.4.2 +social-auth-core==4.5.4 \ No newline at end of file