2026-11-01 15:01:25 +01:00
|
|
|
"""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
|
2026-11-01 19:35:21 +01:00
|
|
|
from django.shortcuts import redirect, render
|
2026-11-01 15:01:25 +01:00
|
|
|
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.')
|
2026-11-01 19:35:21 +01:00
|
|
|
if oidc.key_enabled(): # leave the book key with Authelia's help right away
|
|
|
|
|
return redirect('/accounts/oidc/key/?next=/accounts/settings/')
|
2026-11-01 15:01:25 +01:00
|
|
|
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')
|
2026-11-01 19:35:21 +01:00
|
|
|
# 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)
|
2026-11-01 15:01:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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')
|
2026-11-01 19:35:21 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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})
|