Skip to content

Commit

Permalink
Merge pull request #147 from fsinfuhh/openid_connect
Browse files Browse the repository at this point in the history
OpenId Connect integration
  • Loading branch information
timonegk authored Jan 12, 2024
2 parents 8248fb2 + bd25c88 commit 81fe0d9
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 24 deletions.
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
111 changes: 111 additions & 0 deletions bitpoll/base/openid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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 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


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.preferred_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()
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):
# get users from openid
# request 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),
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()
6 changes: 6 additions & 0 deletions bitpoll/invitations/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions bitpoll/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@

ANTI_SPAM_CHALLENGE_TTL = 60 * 60 * 24 * 7 # Defaults to 7 days

OPENID_ENABLED = False

from .settings_local import *

INSTALLED_APPS += INSTALLED_APPS_LOCAL
Expand Down
16 changes: 15 additions & 1 deletion bitpoll/settings_local.sample.py
Original file line number Diff line number Diff line change
@@ -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 <https://docs.djangoproject.com/en/dev/howto/deployment/checklist/#secret-key>
Expand All @@ -25,6 +25,20 @@
# ]
INSTALLED_APPS_LOCAL = []

# To use OpenId:
#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"
#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"

# 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 = {}
Expand Down
22 changes: 18 additions & 4 deletions bitpoll/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
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
from django.urls import path
from django.views.generic.base import RedirectView
import django.conf.urls.i18n

from bitpoll import settings
Expand All @@ -27,16 +29,28 @@
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.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"),
]
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')),
Expand Down
15 changes: 5 additions & 10 deletions docker_files/uwsgi-bitpoll.ini
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion requirements-production.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
sentry-sdk
django-auth-ldap
uwsgi
psycopg2-binary
simple_openid_connect[django]
30 changes: 24 additions & 6 deletions requirements-production.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,20 @@ 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
# django-encrypted-model-fields
# 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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -87,27 +98,34 @@ 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
# via
# requests
# sentry-sdk
uwsgi==2.0.23
# via -r requirements-production.in
vobject==0.9.6.1
# via caldav
webencodings==0.5.1
Expand Down

0 comments on commit 81fe0d9

Please sign in to comment.