Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,261 changes: 900 additions & 2,361 deletions src/locale/de/LC_MESSAGES/django.po

Large diffs are not rendered by default.

3,017 changes: 807 additions & 2,210 deletions src/locale/en/LC_MESSAGES/django.po

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion src/shiftings/accounts/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from django.contrib import admin

from shiftings.accounts.models import User
from shiftings.accounts.models import OIDCOfflineToken, User

admin.site.register(User)


@admin.register(OIDCOfflineToken)
class OIDCOfflineTokenAdmin(admin.ModelAdmin):
list_display = ('user', 'updated')
search_fields = ('user__username',)
readonly_fields = ('updated',)
44 changes: 44 additions & 0 deletions src/shiftings/accounts/management/commands/sync_oidc_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

import logging

from django.core.management.base import BaseCommand

from shiftings.accounts.models import OIDCOfflineToken

logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = 'Sync all users with stored OIDC offline tokens (groups, admin status).'

def add_arguments(self, parser):
parser.add_argument(
'--purge-expired',
action='store_true',
help='Delete offline tokens that fail to refresh (expired/revoked).',
)

def handle(self, *args, **options):
tokens = OIDCOfflineToken.objects.select_related('user').all()
total = tokens.count()
success = 0
failed = 0

self.stdout.write(f'Syncing {total} OIDC user(s)...')

for offline_token in tokens:
username = offline_token.user.username
if offline_token.refresh_user_info():
success += 1
logger.info('Synced user %s', username)
else:
failed += 1
logger.warning('Failed to sync user %s', username)
if options['purge_expired']:
offline_token.delete()
logger.info('Purged expired token for user %s', username)

self.stdout.write(self.style.SUCCESS(
f'Done. {success} synced, {failed} failed (of {total} total).'
))
Comment on lines +23 to +44
Copy link
Copy Markdown
Collaborator

@hd1ex hd1ex Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also print out the time how long a refresh run took, so an admin can get a feeling of the performance impact? I also saw some async stuff was added to Django recently. I don't know if it is easy to add but it maybe an option to improve overall reactiveness, if this task is IO heavy.

When does such a token expire? Do I understand it correctly that we have to refresh the token regularly and expired tokens can only be "refreshed" if the user does relogin?

If so, this only solves #41 for future deployments.
On a running system no tokens are there (yet?) so this implies for me some kind of purge when this change is rolled out:

  • Terminate all user sessions (forcing them to relogin)
  • Update all group memberships by
    • either clearing them all and let the users log back in to rejoin
    • or using some kind of other data source (e.g. LDAP). This also does not solve the problem for inactive users, so I am in favor of the first option.

Maybe some scripting/documentation to support this would be nice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The offline token expiry is handled by the OIDC IdP. This implementation currently does nothing on expiry. Ways to solve this is adding a grace period until the 30. June and begin clearing group associations and requiring re-authentication from then. The HaDiKo deployment of Shiftings should use quite short token periods which would force everybody using the App to have logged in at least once since then.

27 changes: 27 additions & 0 deletions src/shiftings/accounts/migrations/0002_oidcofflinetoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 6.0.2 on 2026-03-15 16:45

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='OIDCOfflineToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('refresh_token', models.TextField(verbose_name='Refresh Token')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Updated')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='oidc_offline_token', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'default_permissions': (),
},
),
]
1 change: 1 addition & 0 deletions src/shiftings/accounts/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .oidc_token import OIDCOfflineToken
from .user import BaseUser, User
103 changes: 103 additions & 0 deletions src/shiftings/accounts/models/oidc_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from __future__ import annotations

import logging
from functools import cache
from typing import Any

import requests
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _

logger = logging.getLogger(__name__)


@cache
def _get_oidc_endpoints() -> dict[str, str]:
"""Fetch and cache the OpenID Connect discovery document."""
resp = requests.get(settings.OPENID_CONF_URL, timeout=10)
resp.raise_for_status()
return resp.json()


class OIDCOfflineToken(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='oidc_offline_token',
verbose_name=_('User'),
)
refresh_token = models.TextField(verbose_name=_('Refresh Token'))
updated = models.DateTimeField(auto_now=True, verbose_name=_('Updated'))

class Meta:
default_permissions = ()

def __str__(self) -> str:
return f'OIDCOfflineToken for {self.user}'

def refresh_user_info(self) -> bool:
"""Use the stored offline token to refresh the user's OIDC data.

Returns True on success, False if the token is expired or invalid.
"""
try:
endpoints = _get_oidc_endpoints()
token_endpoint = endpoints['token_endpoint']
userinfo_endpoint = endpoints['userinfo_endpoint']
except Exception:
logger.exception('Failed to fetch OIDC discovery document')
return False

client_config = settings.AUTHLIB_OAUTH_CLIENTS['shiftings']

try:
token_resp = requests.post(
token_endpoint,
data={
'grant_type': 'refresh_token',
'refresh_token': self.refresh_token,
'client_id': client_config['client_id'],
'client_secret': client_config['client_secret'],
},
timeout=10,
)
except Exception:
logger.exception('Failed to call OIDC token endpoint')
return False

if token_resp.status_code != 200:
logger.warning(
'OIDC token refresh failed (status %d): %s',
token_resp.status_code,
token_resp.text,
)
return False

token_data = token_resp.json()

self.refresh_token = token_data['refresh_token']
self.save(update_fields=['refresh_token', 'updated'])

access_token = token_data['access_token']
try:
userinfo_resp = requests.get(
userinfo_endpoint,
headers={'Authorization': f'Bearer {access_token}'},
timeout=10,
)
except Exception:
logger.exception('Failed to call OIDC userinfo endpoint')
return False

if userinfo_resp.status_code != 200:
logger.warning(
'OIDC userinfo request failed (status %d): %s',
userinfo_resp.status_code,
userinfo_resp.text,
)
return False

from shiftings.accounts.oidc import populate_user_from_oidc
populate_user_from_oidc(userinfo_resp.json())
return True
38 changes: 38 additions & 0 deletions src/shiftings/accounts/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import re
from typing import Any

from django.conf import settings
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.auth.views import UserModel

from shiftings.accounts.models import User


def populate_user_from_oidc(user_data: dict[str, Any]) -> AbstractUser:
"""Create or update a Django user from OIDC userinfo claims.

Syncs username, email, groups, and admin status from the OIDC provider.
"""
username = user_data.get(settings.OAUTH_USERNAME_CLAIM, '')
groups: list[str] = user_data.get(settings.OAUTH_GROUP_CLAIM, [])
for group in groups:
if re.match(settings.OAUTH_GROUP_IGNORE_REGEX, group) is None:
Group.objects.get_or_create(name=group)
try:
user: AbstractUser = User.objects.get_by_natural_key(username)
except UserModel.DoesNotExist:
user = User.objects.create(username=username)
user.set_unusable_password()
# ignore secondary names
user.first_name = user_data.get(settings.OAUTH_FIRST_NAME_CLAIM, '').split(' ')[0]
user.last_name = user_data.get(settings.OAUTH_LAST_NAME_CLAIM, '')
user.email = user_data.get(settings.OAUTH_EMAIL_CLAIM, '')
user.groups.set(Group.objects.filter(name__in=groups))

user.is_superuser = settings.OAUTH_ADMIN_GROUP in groups
user.is_staff = settings.OAUTH_ADMIN_GROUP in groups
user.backend = 'django.contrib.auth.backends.ModelBackend'
user.save()
return user
41 changes: 11 additions & 30 deletions src/shiftings/accounts/views/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import re
from json import JSONDecodeError
from typing import Any, Optional

Expand All @@ -9,17 +8,15 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as login_user, logout, REDIRECT_FIELD_NAME
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.auth.views import LoginView, LogoutView, UserModel
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.views import LoginView, LogoutView
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import RedirectView

from shiftings.accounts.models import User


class UserLoginView(LoginView):
redirect_authenticated_user = True
Expand Down Expand Up @@ -95,35 +92,19 @@ def authenticate(self, request: HttpRequest) -> Optional[AbstractUser]:
try:
token = oauth.shiftings.authorize_access_token(request)
if 'userinfo' in token:
return self._populate_user(token['userinfo'])
from shiftings.accounts.oidc import populate_user_from_oidc
user = populate_user_from_oidc(token['userinfo'])
if 'refresh_token' in token:
from shiftings.accounts.models import OIDCOfflineToken
OIDCOfflineToken.objects.update_or_create(
user=user,
defaults={'refresh_token': token['refresh_token']},
)
return user
except JSONDecodeError:
pass
return None

@staticmethod
def _populate_user(user_data: dict[str, Any]) -> AbstractUser:
username = user_data.get(settings.OAUTH_USERNAME_CLAIM, '')
groups: list[str] = user_data.get(settings.OAUTH_GROUP_CLAIM, [])
for group in groups:
if re.match(settings.OAUTH_GROUP_IGNORE_REGEX, group) is None:
Group.objects.get_or_create(name=group)
try:
user: AbstractUser = User.objects.get_by_natural_key(username)
except UserModel.DoesNotExist:
user = User.objects.create(username=username)
user.set_unusable_password()
# ignore secondary names
user.first_name = user_data.get(settings.OAUTH_FIRST_NAME_CLAIM, '').split(' ')[0]
user.last_name = user_data.get(settings.OAUTH_LAST_NAME_CLAIM, '')
user.email = user_data.get(settings.OAUTH_EMAIL_CLAIM, '')
user.groups.set(Group.objects.filter(name__in=groups))

user.is_superuser = settings.OAUTH_ADMIN_GROUP in groups
user.is_staff = settings.OAUTH_ADMIN_GROUP in groups
user.backend = 'django.contrib.auth.backends.ModelBackend'
user.save()
return user


class UserLogoutView(LogoutView):
template_name = 'accounts/logout.html'
Expand Down
1 change: 1 addition & 0 deletions src/shiftings/accounts/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
Q(organization__in=self.object.organizations) |
Q(participants__user=self.object))).distinct()
context['shifts'] = get_pagination_context(self.request, shifts.filter(self.get_filters()), 5, 'shifts')

return context


Expand Down