added SSO support using Python Social Auth including custom backend for EF Identity
This commit is contained in:
parent
42da0ffa27
commit
09b2375d79
10 changed files with 119 additions and 9 deletions
|
@ -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
|
||||
|
||||
|
|
34
src/c3nav/control/sso/__init__.py
Normal file
34
src/c3nav/control/sso/__init__.py
Normal 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
|
11
src/c3nav/control/sso/backends.py
Normal file
11
src/c3nav/control/sso/backends.py
Normal 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"
|
|
@ -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',
|
||||
)
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
2
src/requirements/sso.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
social-auth-app-django==5.4.2
|
||||
social-auth-core==4.5.4
|
Loading…
Add table
Add a link
Reference in a new issue