diora-web/accounts/oidc_views.py
mrwnslz 67f983dd1c
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m27s
Test / e2e (push) Failing after 1m30s
Nach dem Authelia-Login sind die Bücher gleich offen, ohne Code und Passwort (sw CACHE v162)
Authelia gibt dem Gerät (nicht dem diora-Server) ein geheimes Attribut
heraus. Damit legt das Gerät beim Verknüpfen den Bücher-Schlüssel
verschlüsselt bei diora ab und holt ihn auf neuen Geräten nach dem
Authelia-Login unsichtbar zurück. Code und Passwort bleiben als Notweg, wenn
Authelia nichts liefert. Aktiv, sobald DIORA_OIDC_APP_CLIENT_ID gesetzt ist.

Details und Grenzen: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:35:21 +01:00

164 lines
6.9 KiB
Python

"""Sign-in with Authelia and linking it to an existing account (accounts/oidc.py)."""
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_http_methods
from . import oidc
from .models import OidcIdentity
log = logging.getLogger(__name__)
SESSION_KEY = 'oidc_flow'
def _redirect_uri(request):
return settings.OIDC_REDIRECT_URI or request.build_absolute_uri('/accounts/oidc/callback/')
def _safe_next(request, value):
if value and url_has_allowed_host_and_scheme(value, allowed_hosts={request.get_host()},
require_https=request.is_secure()):
return value
return '/'
def oidc_context(request):
"""What the settings page shows about the provider."""
return {
'oidc_enabled': oidc.enabled(),
'oidc_provider': settings.OIDC_PROVIDER_NAME,
'oidc_identities': list(request.user.oidc_identities.all()) if request.user.is_authenticated else [],
}
@require_http_methods(['GET'])
def oidc_start(request):
"""Off to the provider. `?link=1` (signed in) links the account instead of signing in."""
if not oidc.enabled():
messages.error(request, 'Die Anmeldung über Authelia ist auf diesem Server nicht eingerichtet.')
return redirect('login')
link = request.GET.get('link') == '1'
if link and not request.user.is_authenticated:
return redirect('login')
flow = oidc.new_flow()
flow['link'] = link
flow['next'] = _safe_next(request, request.GET.get('next', ''))
try:
url = oidc.authorize_url(flow, _redirect_uri(request))
except oidc.OidcError as exc:
log.warning('oidc start: %s', exc)
messages.error(request, 'Authelia ist gerade nicht erreichbar.')
return redirect('settings' if link else 'login')
request.session[SESSION_KEY] = flow
return redirect(url)
@require_http_methods(['GET'])
def oidc_callback(request):
flow = request.session.pop(SESSION_KEY, None)
fail_to = 'login'
if flow and flow.get('link'):
fail_to = 'settings'
if not flow or request.GET.get('state') != flow.get('state'):
messages.error(request, 'Die Anmeldung über Authelia ist abgelaufen. Bitte noch einmal.')
return redirect(fail_to)
if request.GET.get('error'):
messages.error(request, 'Authelia hat die Anmeldung abgebrochen.')
return redirect(fail_to)
code = request.GET.get('code', '')
try:
claims = oidc.exchange_code(code, flow, _redirect_uri(request))
except oidc.OidcError as exc:
log.warning('oidc callback: %s', exc)
messages.error(request, 'Die Anmeldung über Authelia hat nicht geklappt.')
return redirect(fail_to)
issuer = settings.OIDC_ISSUER.rstrip('/')
sub = claims['sub']
shown = {
'username': (claims.get('preferred_username') or '')[:150],
'email': (claims.get('email') or '')[:254],
}
identity = OidcIdentity.objects.filter(issuer=issuer, sub=sub).select_related('user').first()
if flow.get('link'):
if not request.user.is_authenticated:
return redirect('login')
if identity and identity.user_id != request.user.id:
messages.error(request, 'Dieses Authelia-Konto ist schon mit einem anderen diora-Konto verknüpft.')
return redirect('settings')
if identity is None:
OidcIdentity.objects.create(user=request.user, issuer=issuer, sub=sub, **shown)
else:
OidcIdentity.objects.filter(pk=identity.pk).update(**shown)
messages.success(request, f'Authelia-Konto „{shown["username"] or sub}“ verknüpft. '
'Du kannst dich jetzt auch darüber anmelden.')
if oidc.key_enabled(): # leave the book key with Authelia's help right away
return redirect('/accounts/oidc/key/?next=/accounts/settings/')
return redirect('settings')
if identity is None:
messages.error(request, 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
'Melde dich einmal mit Passwort an und verknüpfe es in den Einstellungen.')
return redirect('login')
if not identity.user.is_active:
messages.error(request, 'Dieses diora-Konto ist gesperrt.')
return redirect('login')
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now(), **shown)
login(request, identity.user, backend='django.contrib.auth.backends.ModelBackend')
# A password login brings the book key along, this one cannot: the device fetches it
# (oidc_key below), else the page asks (static/js/app/33-unlock.js).
next_url = flow.get('next') or '/'
if oidc.key_enabled():
from urllib.parse import urlencode
return redirect('/accounts/oidc/key/?' + urlencode({'next': next_url}))
return redirect(next_url)
@login_required
@require_http_methods(['POST'])
def oidc_unlink(request, pk):
OidcIdentity.objects.filter(user=request.user, pk=pk).delete()
messages.success(request, 'Verknüpfung mit Authelia gelöst.')
return redirect('settings')
@login_required
@require_http_methods(['GET'])
def oidc_key(request):
"""The book key with the provider's help, without asking (docs/accounts.md).
Runs entirely on the device: it signs in once more as the public client
(OIDC_APP_CLIENT_ID, PKCE, prompt=none — the provider session from just before makes it
invisible), reads its `diora_key` claim from userinfo and with it opens the book key the
server keeps wrapped (vault "authelia-key") — or, when the device has the key, wraps and
leaves it there. The server never sees the claim; this view only hands out the page.
The same page is the redirect target (`/accounts/oidc/key/callback/`)."""
next_url = _safe_next(request, request.GET.get('next', ''))
if not oidc.key_enabled():
return redirect(next_url)
try:
disco = oidc.discovery()
except oidc.OidcError as exc:
log.warning('oidc key: %s', exc)
return redirect(next_url)
config = {
'authorize': disco['authorization_endpoint'],
'token': disco['token_endpoint'],
'userinfo': disco.get('userinfo_endpoint', ''),
'client_id': settings.OIDC_APP_CLIENT_ID,
'redirect_uri': request.build_absolute_uri('/accounts/oidc/key/callback/')
if not settings.OIDC_REDIRECT_URI
else settings.OIDC_REDIRECT_URI.replace('/oidc/callback/', '/oidc/key/callback/'),
'subs': [i.sub for i in request.user.oidc_identities.all()],
'user_id': request.user.id,
'next': next_url,
}
return render(request, 'accounts/oidc_key.html', {'config': config})