From d61c8237e2537c50b59c9aed2306a77dd6d2e7ef Mon Sep 17 00:00:00 2001 From: Timon Engelke Date: Sun, 8 Oct 2023 21:12:22 +0200 Subject: [PATCH 1/6] Add option to use OpenId Connect for login --- bitpoll/settings.py | 2 ++ bitpoll/settings_local.sample.py | 10 ++++++++++ bitpoll/urls.py | 18 ++++++++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/bitpoll/settings.py b/bitpoll/settings.py index b2d851b..c7b038c 100644 --- a/bitpoll/settings.py +++ b/bitpoll/settings.py @@ -394,6 +394,8 @@ ANTI_SPAM_CHALLENGE_TTL = 60 * 60 * 24 * 7 # Defaults to 7 days +USE_OPENID = False + from .settings_local import * INSTALLED_APPS += INSTALLED_APPS_LOCAL diff --git a/bitpoll/settings_local.sample.py b/bitpoll/settings_local.sample.py index 30079f9..1c7505d 100644 --- a/bitpoll/settings_local.sample.py +++ b/bitpoll/settings_local.sample.py @@ -25,6 +25,16 @@ # ] INSTALLED_APPS_LOCAL = [] +# To use OpenId: +INSTALLED_APPS_LOCAL.append('simple_openid_connect.integrations.django') +USE_OPENID = True +OPENID_ISSUER = "..." +OPENID_CLIENT_ID = "..." +OPENID_BASE_URI = "..." +OPENID_SCOPE = "openid profile email" +LOGIN_URL = "simple_openid_connect_django:login" +LOGOUT_REDIRECT_URL = "home" + # Compress the JS and CSS files, for more Options see https://django-pipeline.readthedocs.io/en/latest/compressors.html # the Compressor have to be installed in the system PIPELINE_LOCAL = {} diff --git a/bitpoll/urls.py b/bitpoll/urls.py index 178464a..7b1b4de 100644 --- a/bitpoll/urls.py +++ b/bitpoll/urls.py @@ -19,6 +19,7 @@ from django.contrib import admin from django.shortcuts import redirect, render from django.urls import path +from django.views.generic.base import RedirectView import django.conf.urls.i18n from bitpoll import settings @@ -27,16 +28,25 @@ path('poll/', include('bitpoll.poll.urls')), path('', include('bitpoll.base.urls')), path('invitations/', include('bitpoll.invitations.urls')), - path('', lambda req: redirect('index'), name='home'), - path('login/', auth_views.LoginView.as_view(), name='login', ), - path('logout/', auth_views.LogoutView.as_view(next_page='index'), name='logout'), path(r'registration/', include('bitpoll.registration.urls')), path(r'i18n/', include(django.conf.urls.i18n)), path(r'admin/', admin.site.urls), - ] +if settings.USE_OPENID: + urlpatterns += [ + path("auth/openid/", include("simple_openid_connect.integrations.django.urls")), + path("login/", RedirectView.as_view(url="/auth/openid/login/"), name='login'), + path("logout/", RedirectView.as_view(url="/auth/openid/logout/"), name='logout'), + ] +else: + urlpatterns += [ + path('login/', auth_views.LoginView.as_view(), name='login', ), + path('logout/', auth_views.LogoutView.as_view(next_page='index'), name='logout'), + ] + + if settings.CALENDAR_ENABLED: urlpatterns += [ path('caldav/', include('bitpoll.caldav.urls')), From b19a7691026bc2d85f34ad0041bdee9181d2a2d3 Mon Sep 17 00:00:00 2001 From: Timon Engelke Date: Wed, 25 Oct 2023 14:26:28 +0200 Subject: [PATCH 2/6] Update groups when OpenID is used --- bitpoll/base/openid.py | 75 ++++++++++++++++++++++++++++++++ bitpoll/invitations/views.py | 6 +++ bitpoll/settings.py | 2 +- bitpoll/settings_local.sample.py | 18 ++++---- bitpoll/urls.py | 14 +++--- requirements-production.in | 1 + requirements-production.txt | 28 ++++++++++-- 7 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 bitpoll/base/openid.py diff --git a/bitpoll/base/openid.py b/bitpoll/base/openid.py new file mode 100644 index 0000000..89c6fbd --- /dev/null +++ b/bitpoll/base/openid.py @@ -0,0 +1,75 @@ +from urllib.parse import quote + +import requests +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from simple_openid_connect.integrations.django.models import OpenidUser +from simple_openid_connect.integrations.django.user_mapping import UserMapper + + +class BitpollUserMapper(UserMapper): + def handle_federated_userinfo(self, user_data): + # if there is already a user with this username, we create the openid association if it does not exist yet + User = get_user_model() + try: + user = User.objects.get(username=user_data.username) + OpenidUser.objects.get_or_create( + sub=user_data.sub, + defaults={ + "user": user, + }, + ) + except User.DoesNotExist: + # if the user does not exist, it is automatically created by the super class + pass + return super().handle_federated_userinfo(user_data) + + def automap_user_attrs(self, user, user_data): + super().automap_user_attrs(user, user_data) + for group_name in user_data.groups: + group = Group.objects.get_or_create(name=group_name)[0] + group.user_set.add(user) + group.save() + + +def refresh_group_users(group: Group): + # get users from openid + # request token + response = requests.post( + settings.OPENID_ISSUER + "/protocol/openid-connect/token", + data={ + "grant_type": "client_credentials", + "client_id": settings.OPENID_CLIENT_ID, + "client_secret": settings.OPENID_CLIENT_SECRET, + "scope": "openid profile email roles", + }, + ) + access_token = response.json()["access_token"] + # get group id + response = requests.get( + settings.OPENID_API_BASE + "/groups?exact=true&search=" + quote(group.name), + headers={"Authorization": "Bearer " + access_token}, + ) + group_id = response.json()[0]["id"] + # get users + response = requests.get( + settings.OPENID_API_BASE + + "/groups/" + + group_id + + "/members?briefRepresentation=true", + headers={"Authorization": "Bearer " + access_token}, + ) + # add users to group + User = get_user_model() + for user_json in response.json(): + user = User.objects.get_or_create( + username=user_json["username"], + defaults={ + "first_name": user_json["firstName"], + "last_name": user_json["lastName"], + "email": user_json["email"], + }, + )[0] + group.user_set.add(user) + group.save() diff --git a/bitpoll/invitations/views.py b/bitpoll/invitations/views.py index 5a918cb..cefc3a2 100644 --- a/bitpoll/invitations/views.py +++ b/bitpoll/invitations/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import messages from django.contrib.auth.models import Group from django.core.exceptions import ObjectDoesNotExist @@ -67,6 +68,11 @@ def invitation_send(request, poll_url): except ObjectDoesNotExist: try: group = Group.objects.get(name=receiver) + if settings.OPENID_ENABLED: + # import here to avoid import errors if openid is not enabled + from bitpoll.base.openid import refresh_group_users + refresh_group_users(group) + for group_user in group.user_set.all(): try: invitation = Invitation(user=group_user, poll=current_poll, date_created=now(), diff --git a/bitpoll/settings.py b/bitpoll/settings.py index c7b038c..ee30c62 100644 --- a/bitpoll/settings.py +++ b/bitpoll/settings.py @@ -394,7 +394,7 @@ ANTI_SPAM_CHALLENGE_TTL = 60 * 60 * 24 * 7 # Defaults to 7 days -USE_OPENID = False +OPENID_ENABLED = False from .settings_local import * diff --git a/bitpoll/settings_local.sample.py b/bitpoll/settings_local.sample.py index 1c7505d..e8bb955 100644 --- a/bitpoll/settings_local.sample.py +++ b/bitpoll/settings_local.sample.py @@ -26,14 +26,16 @@ INSTALLED_APPS_LOCAL = [] # To use OpenId: -INSTALLED_APPS_LOCAL.append('simple_openid_connect.integrations.django') -USE_OPENID = True -OPENID_ISSUER = "..." -OPENID_CLIENT_ID = "..." -OPENID_BASE_URI = "..." -OPENID_SCOPE = "openid profile email" -LOGIN_URL = "simple_openid_connect_django:login" -LOGOUT_REDIRECT_URL = "home" +#INSTALLED_APPS_LOCAL.append('simple_openid_connect.integrations.django') +#OPENID_ENABLED = True +#OPENID_ISSUER = "https://identity.mafiasi.de/realms/mafiasi" +#OPENID_API_BASE = "https://identity.mafiasi.de/admin/realms/mafiasi" +#OPENID_CLIENT_ID = "..." +#OPENID_CLIENT_SECRET = "..." +#OPENID_BASE_URI = "..." +#OPENID_SCOPE = "openid profile email" +#LOGIN_URL = "simple_openid_connect_django:login" +#LOGOUT_REDIRECT_URL = "index" # Compress the JS and CSS files, for more Options see https://django-pipeline.readthedocs.io/en/latest/compressors.html # the Compressor have to be installed in the system diff --git a/bitpoll/urls.py b/bitpoll/urls.py index 7b1b4de..40f65c8 100644 --- a/bitpoll/urls.py +++ b/bitpoll/urls.py @@ -15,6 +15,7 @@ 3. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) """ from django.contrib.auth import views as auth_views +from django.core.exceptions import ImproperlyConfigured from django.urls import include, path, re_path from django.contrib import admin from django.shortcuts import redirect, render @@ -34,16 +35,19 @@ path(r'admin/', admin.site.urls), ] -if settings.USE_OPENID: +if settings.OPENID_ENABLED and settings.GROUP_MANAGEMENT: + raise ImproperlyConfigured("You can't use both OPENID_ENABLED and GROUP_MANAGEMENT at the same time.") + +if settings.OPENID_ENABLED: urlpatterns += [ path("auth/openid/", include("simple_openid_connect.integrations.django.urls")), - path("login/", RedirectView.as_view(url="/auth/openid/login/"), name='login'), - path("logout/", RedirectView.as_view(url="/auth/openid/logout/"), name='logout'), + path("login/", RedirectView.as_view(url="/auth/openid/login/"), name="login"), + path("logout/", RedirectView.as_view(url="/auth/openid/logout/"), name="logout"), ] else: urlpatterns += [ - path('login/', auth_views.LoginView.as_view(), name='login', ), - path('logout/', auth_views.LogoutView.as_view(next_page='index'), name='logout'), + path("login/", auth_views.LoginView.as_view(), name="login"), + path("logout/", auth_views.LogoutView.as_view(next_page="index"), name="logout"), ] diff --git a/requirements-production.in b/requirements-production.in index 04c128a..fdc0bb0 100644 --- a/requirements-production.in +++ b/requirements-production.in @@ -2,3 +2,4 @@ sentry-sdk django-auth-ldap uwsgi psycopg2-binary +simple_openid_connect[django] diff --git a/requirements-production.txt b/requirements-production.txt index e4da4f3..e93224a 100644 --- a/requirements-production.txt +++ b/requirements-production.txt @@ -19,8 +19,12 @@ cffi==1.16.0 charset-normalizer==3.3.2 # via requests cryptography==41.0.7 - # via django-encrypted-model-fields -django==5.0.1 + # via + # cryptojwt + # django-encrypted-model-fields +cryptojwt==1.8.3 + # via simple-openid-connect +django==4.2.9 # via # -r requirements.in # django-auth-ldap @@ -28,6 +32,7 @@ django==5.0.1 # django-markdownify # django-simple-csp # django-token-bucket + # simple-openid-connect django-auth-ldap==4.6.0 # via -r requirements-production.in django-encrypted-model-fields==0.6.5 @@ -44,6 +49,8 @@ django-token-bucket==0.2.4 # via -r requirements.in django-widget-tweaks==1.5.0 # via -r requirements.in +furl==2.1.3 + # via simple-openid-connect icalendar==5.0.11 # via # -r requirements.in @@ -60,6 +67,8 @@ lxml==5.1.0 # via caldav markdown==3.5.2 # via django-markdownify +orderedmultidict==1.0.1 + # via furl psycopg2-binary==2.9.9 # via -r requirements-production.in pyasn1==0.5.1 @@ -70,6 +79,8 @@ pyasn1-modules==0.3.0 # via python-ldap pycparser==2.21 # via cffi +pydantic==1.10.13 + # via simple-openid-connect python-dateutil==2.8.2 # via # icalendar @@ -87,19 +98,28 @@ pytz==2023.3.post1 recurring-ical-events==2.1.2 # via caldav requests==2.31.0 - # via caldav + # via + # caldav + # cryptojwt + # simple-openid-connect sentry-sdk==1.39.2 # via -r requirements-production.in +simple-openid-connect[django]==0.5.3 + # via -r requirements-production.in six==1.16.0 # via # bleach + # furl + # orderedmultidict # python-dateutil sqlparse==0.4.4 # via django tinycss2==1.2.1 # via bleach typing-extensions==4.9.0 - # via asgiref + # via + # asgiref + # pydantic tzlocal==5.2 # via caldav urllib3==2.1.0 From fd3e1b6d108a6cf3f2548cd1ca5e4ccc770179c1 Mon Sep 17 00:00:00 2001 From: Timon Engelke Date: Mon, 13 Nov 2023 18:13:41 +0100 Subject: [PATCH 3/6] Improve/fix deploymint --- Dockerfile | 4 ++-- bitpoll/settings_local.sample.py | 1 + docker_files/uwsgi-bitpoll.ini | 15 +++++---------- requirements-production.in | 1 - requirements-production.txt | 2 -- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 39759d6..9c0089d 100755 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN mkdir -p /opt/bitpoll WORKDIR /opt/bitpoll -RUN apt update && apt install -y --no-install-recommends libldap-2.5-0 libsasl2-2 && rm -rf /var/lib/apt/lists/* +RUN apt update && apt install -y --no-install-recommends libldap-2.5-0 libsasl2-2 uwsgi uwsgi-plugin-python3 && rm -rf /var/lib/apt/lists/* FROM common-base as base-builder @@ -23,7 +23,7 @@ RUN apt-get update && apt-get -y --no-install-recommends install g++ wget python COPY requirements-production.txt . -RUN pip install --no-warn-script-location --prefix=/install -U -r requirements-production.txt uwsgi +RUN pip install --no-warn-script-location --prefix=/install -U -r requirements-production.txt FROM dependencies as collect-static diff --git a/bitpoll/settings_local.sample.py b/bitpoll/settings_local.sample.py index e8bb955..cf3bb58 100644 --- a/bitpoll/settings_local.sample.py +++ b/bitpoll/settings_local.sample.py @@ -34,6 +34,7 @@ #OPENID_CLIENT_SECRET = "..." #OPENID_BASE_URI = "..." #OPENID_SCOPE = "openid profile email" +#OPENID_USER_MAPPER = 'bitpoll.base.openid.BitpollUserMapper' #LOGIN_URL = "simple_openid_connect_django:login" #LOGOUT_REDIRECT_URL = "index" diff --git a/docker_files/uwsgi-bitpoll.ini b/docker_files/uwsgi-bitpoll.ini index 65a9b81..c80cf4d 100644 --- a/docker_files/uwsgi-bitpoll.ini +++ b/docker_files/uwsgi-bitpoll.ini @@ -1,13 +1,13 @@ [uwsgi] - procname-master = uwsgi %n master = true socket = :3008 http = :3009 -logger = file:/opt/log/bitpoll.log -touch-logreopen = /opt/log/reopen_log.trigger +plugins = python3 + chdir = /opt/bitpoll +virtualenv = /usr/local module = bitpoll.wsgi:application env = DJANGO_SETTINGS_MODULE=bitpoll.settings @@ -19,15 +19,10 @@ uid = www-data gid = www-data umask = 027 -; run with at least 1 process but increase up to 4 when needed +; run with at least 2 process but increase up to 8 when needed processes = 8 +threads = 4 cheaper = 2 -; reload whenever this config file changes -; %p is the full path of the current config file -touch-reload = %p - ; disable uWSGI request logging disable-logging = true - -enable-threads = true diff --git a/requirements-production.in b/requirements-production.in index fdc0bb0..c1c5417 100644 --- a/requirements-production.in +++ b/requirements-production.in @@ -1,5 +1,4 @@ sentry-sdk django-auth-ldap -uwsgi psycopg2-binary simple_openid_connect[django] diff --git a/requirements-production.txt b/requirements-production.txt index e93224a..11088fd 100644 --- a/requirements-production.txt +++ b/requirements-production.txt @@ -126,8 +126,6 @@ urllib3==2.1.0 # via # requests # sentry-sdk -uwsgi==2.0.23 - # via -r requirements-production.in vobject==0.9.6.1 # via caldav webencodings==0.5.1 From aabe76ee5220f0e2b4cec9a7143b98f89cdddc0e Mon Sep 17 00:00:00 2001 From: Timon Engelke Date: Mon, 13 Nov 2023 18:14:00 +0100 Subject: [PATCH 4/6] Fix username attribute in openid mapper --- bitpoll/base/openid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitpoll/base/openid.py b/bitpoll/base/openid.py index 89c6fbd..f6ad1c4 100644 --- a/bitpoll/base/openid.py +++ b/bitpoll/base/openid.py @@ -13,7 +13,7 @@ def handle_federated_userinfo(self, user_data): # if there is already a user with this username, we create the openid association if it does not exist yet User = get_user_model() try: - user = User.objects.get(username=user_data.username) + user = User.objects.get(username=user_data.preferred_username) OpenidUser.objects.get_or_create( sub=user_data.sub, defaults={ From fcaf36d409c10737701dbe736ce579b22d62eda0 Mon Sep 17 00:00:00 2001 From: Timon Engelke Date: Mon, 13 Nov 2023 18:49:38 +0100 Subject: [PATCH 5/6] Add OPENID_ADMIN_GROUPS --- bitpoll/base/openid.py | 3 +++ bitpoll/settings_local.sample.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bitpoll/base/openid.py b/bitpoll/base/openid.py index f6ad1c4..0a8d6d0 100644 --- a/bitpoll/base/openid.py +++ b/bitpoll/base/openid.py @@ -31,6 +31,9 @@ def automap_user_attrs(self, user, user_data): group = Group.objects.get_or_create(name=group_name)[0] group.user_set.add(user) group.save() + if settings.OPENID_ADMIN_GROUPS.fullmatch(group.name) is not None: + user.is_superuser = True + user.is_staff = True def refresh_group_users(group: Group): diff --git a/bitpoll/settings_local.sample.py b/bitpoll/settings_local.sample.py index cf3bb58..59d0932 100644 --- a/bitpoll/settings_local.sample.py +++ b/bitpoll/settings_local.sample.py @@ -1,5 +1,5 @@ # customize to your needs - +import re # You must insert your own random value here # SECURITY WARNING: keep the secret key used in production secret! # see @@ -35,6 +35,7 @@ #OPENID_BASE_URI = "..." #OPENID_SCOPE = "openid profile email" #OPENID_USER_MAPPER = 'bitpoll.base.openid.BitpollUserMapper' +#OPENID_ADMIN_GROUPS = re.compile('admins|superusers') #LOGIN_URL = "simple_openid_connect_django:login" #LOGOUT_REDIRECT_URL = "index" From bd25c88fc04d106969fdbe90cc0a0ee7d8c9b27c Mon Sep 17 00:00:00 2001 From: Timon Engelke Date: Fri, 15 Dec 2023 16:30:24 +0100 Subject: [PATCH 6/6] Add caching for OpenId tokens --- bitpoll/base/openid.py | 53 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/bitpoll/base/openid.py b/bitpoll/base/openid.py index 0a8d6d0..85ede22 100644 --- a/bitpoll/base/openid.py +++ b/bitpoll/base/openid.py @@ -4,6 +4,9 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group +from django.core.cache import cache +from simple_openid_connect.data import TokenSuccessResponse +from simple_openid_connect.integrations.django.apps import OpenidAppConfig from simple_openid_connect.integrations.django.models import OpenidUser from simple_openid_connect.integrations.django.user_mapping import UserMapper @@ -39,16 +42,46 @@ def automap_user_attrs(self, user, user_data): def refresh_group_users(group: Group): # get users from openid # request token - response = requests.post( - settings.OPENID_ISSUER + "/protocol/openid-connect/token", - data={ - "grant_type": "client_credentials", - "client_id": settings.OPENID_CLIENT_ID, - "client_secret": settings.OPENID_CLIENT_SECRET, - "scope": "openid profile email roles", - }, - ) - access_token = response.json()["access_token"] + + CACHE_KEY_ACCESS_TOKEN = "bitpoll.oidc_access_token" + CACHE_KEY_REFRESH_TOKEN = "bitpoll.oidc_refresh_token" + + def oidc_expiry2cache_expiry(n: int) -> int | None: + """ + Openid encodes *never-expires* as `0` while django treats `0` as don't cache. + This function rewrites `0` to `None` which is the django representation for *never-expires*. + """ + if n == 0: + return None + else: + return n + + oidc_client = OpenidAppConfig.get_instance().get_client() + access_token = cache.get(CACHE_KEY_ACCESS_TOKEN) + if access_token is None: + refresh_token = cache.get(CACHE_KEY_REFRESH_TOKEN) + if refresh_token is None: + # get completely new tokens + token_response = oidc_client.client_credentials_grant.authenticate() + else: + # use the cached refresh token to get a new access token + token_response = oidc_client.exchange_refresh_token(refresh_token) + + # save the new tokens + assert isinstance(token_response, TokenSuccessResponse), f"Could not get new tokens: {token_response}" + access_token = token_response.access_token + cache.set( + key=CACHE_KEY_ACCESS_TOKEN, + value=token_response.access_token, + timeout=oidc_expiry2cache_expiry(token_response.expires_in), + ) + if token_response.refresh_token: + cache.set( + key=CACHE_KEY_REFRESH_TOKEN, + value=token_response.refresh_token, + timeout=oidc_expiry2cache_expiry(token_response.refresh_expires_in), + ) + # get group id response = requests.get( settings.OPENID_API_BASE + "/groups?exact=true&search=" + quote(group.name),