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/sentry.txt \
|
||||||
-r requirements/metrics.txt \
|
-r requirements/metrics.txt \
|
||||||
-r requirements/uwu.txt \
|
-r requirements/uwu.txt \
|
||||||
|
-r requirements/sso.txt \
|
||||||
-r requirements/server-asgi.txt && \
|
-r requirements/server-asgi.txt && \
|
||||||
pip install --cache-dir /pip-cache uwsgi
|
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 pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import django.conf.locale
|
||||||
from django.contrib.messages import constants as messages
|
from django.contrib.messages import constants as messages
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.utils.crypto import get_random_string
|
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()
|
env = Env()
|
||||||
config = C3navConfigParser(env=env)
|
C3NAV_CONFIG = config = C3navConfigParser(env=env)
|
||||||
if 'C3NAV_CONFIG' in env:
|
if 'C3NAV_CONFIG' in env:
|
||||||
# if a config file is explicitly defined, make sure we can read it.
|
# if a config file is explicitly defined, make sure we can read it.
|
||||||
env.path('C3NAV_CONFIG').open('r')
|
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'
|
TILE_ACCESS_COOKIE_SAMESITE = 'none' if SESSION_COOKIE_SECURE else 'lax'
|
||||||
|
|
||||||
|
|
||||||
|
SSO_ENABLED = config.getboolean('sso', 'enabled', fallback=False)
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
@ -354,6 +358,7 @@ INSTALLED_APPS = [
|
||||||
'channels',
|
'channels',
|
||||||
'compressor',
|
'compressor',
|
||||||
'bootstrap3',
|
'bootstrap3',
|
||||||
|
*(['social_django'] if SSO_ENABLED else []),
|
||||||
*(["ninja"] if SERVE_API else []),
|
*(["ninja"] if SERVE_API else []),
|
||||||
'c3nav.api',
|
'c3nav.api',
|
||||||
'c3nav.mapdata',
|
'c3nav.mapdata',
|
||||||
|
@ -454,7 +459,6 @@ EXTRA_LANG_INFO = {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add custom languages not provided by Django
|
# Add custom languages not provided by Django
|
||||||
import django.conf.locale
|
|
||||||
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
|
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
|
||||||
django.conf.locale.LANG_INFO = LANG_INFO
|
django.conf.locale.LANG_INFO = LANG_INFO
|
||||||
|
|
||||||
|
@ -772,3 +776,37 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
'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;
|
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;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
@ -1343,7 +1343,7 @@ main .narrow p, main .narrow form, main .narrow button {
|
||||||
margin-bottom: 1.0rem;
|
margin-bottom: 1.0rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main .narrow form button {
|
main .narrow form button, main .narrow form .button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1344,7 +1344,7 @@ c3nav = {
|
||||||
_modal_link_click: function (e) {
|
_modal_link_click: function (e) {
|
||||||
var location = $(this).attr('href');
|
var location = $(this).attr('href');
|
||||||
if ($(this).is('[target]') || c3nav._href_modal_open_tab(location)) {
|
if ($(this).is('[target]') || c3nav._href_modal_open_tab(location)) {
|
||||||
$(this).attr('target', '_blank');
|
if(!$(this).is('[target]')) $(this).attr('target', '_blank');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
@ -17,6 +17,11 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
<button type="submit">{{ title }}</button>
|
<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 %}
|
{% if bottom_link_url %}
|
||||||
<a href="{{ bottom_link_url }}?{{ request.META.QUERY_STRING }}">{{ bottom_link_text }}</a>
|
<a href="{{ bottom_link_url }}?{{ request.META.QUERY_STRING }}">{{ bottom_link_text }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -6,7 +6,7 @@ from urllib.parse import urlparse
|
||||||
import qrcode
|
import qrcode
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
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.decorators import login_required
|
||||||
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
|
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
|
||||||
from django.core.cache import cache
|
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.paginator import Paginator
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.db import transaction
|
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.middleware import csrf
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
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.grid import grid
|
||||||
from c3nav.mapdata.models import Location, Source
|
from c3nav.mapdata.models import Location, Source
|
||||||
from c3nav.mapdata.models.access import AccessPermission, AccessPermissionToken
|
from c3nav.mapdata.models.access import AccessPermission, AccessPermissionToken
|
||||||
from c3nav.mapdata.models.locations import LocationRedirect, Position, SpecificLocation, get_position_secret, \
|
from c3nav.mapdata.models.locations import (LocationGroup, LocationRedirect, Position, SpecificLocation,
|
||||||
LocationGroup
|
get_position_secret)
|
||||||
from c3nav.mapdata.models.report import Report, ReportUpdate
|
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,
|
from c3nav.mapdata.utils.locations import (get_location_by_id_for_request, get_location_by_slug_for_request,
|
||||||
levels_by_short_label_for_request)
|
levels_by_short_label_for_request)
|
||||||
|
@ -303,9 +303,19 @@ def login_view(request):
|
||||||
else:
|
else:
|
||||||
form = AuthenticationForm(request)
|
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 = {
|
ctx = {
|
||||||
'title': _('Log in'),
|
'title': _('Log in'),
|
||||||
'form': form,
|
'form': form,
|
||||||
|
'redirect_path': redirect_path,
|
||||||
|
'redirect_query': redirect_query.urlencode(safe="/")
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.USER_REGISTRATION:
|
if settings.USER_REGISTRATION:
|
||||||
|
@ -314,6 +324,10 @@ def login_view(request):
|
||||||
'bottom_link_text': _('Create new account')
|
'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)
|
return render(request, 'site/account_form.html', ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -52,3 +52,8 @@ if settings.SERVE_ANYTHING:
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import django_prometheus # noqu
|
import django_prometheus # noqu
|
||||||
urlpatterns.insert(0, path('prometheus/', include('django_prometheus.urls')))
|
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