From 077e6d454382a25d13d4604db90578dbf0dfef6b Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 1 Nov 2026 15:01:25 +0100 Subject: [PATCH] =?UTF-8?q?Anmelden=20mit=20Authelia=20als=20zweiter=20Weg?= =?UTF-8?q?,=20B=C3=BCcher=20auf=20neuen=20Ger=C3=A4ten=20per=20Code=20ent?= =?UTF-8?q?sperren=20(sw=20CACHE=20v161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf der Login-Seite gibt es „Mit Authelia anmelden“, wenn der Server dafür eingerichtet ist. Das eigene Konto wird einmal in den Einstellungen verknüpft, die Anmeldung mit Passwort bleibt. Fremde Authelia-Konten kommen nicht hinein, neue Konten entstehen darüber nicht. Fehlt einem Gerät der Bücher-Schlüssel (nach der Authelia-Anmeldung oder nach gelöschtem Speicher), erzeugt diora keinen neuen mehr, der die Bücher unlesbar machen würde. Stattdessen fragt es: Ein schon entsperrtes Gerät zeigt denselben Code und gibt den Schlüssel verschlüsselt frei, oder man gibt einmal das diora-Passwort ein. Der Server sieht den Schlüssel dabei nie. Details: docs/accounts.md Co-Authored-By: Claude Opus 5.5 --- accounts/keyshare.py | 134 ++++++++++++ accounts/migrations/0008_oidc_keyshare.py | 50 +++++ accounts/models.py | 37 ++++ accounts/oidc.py | 158 ++++++++++++++ accounts/oidc_views.py | 124 +++++++++++ accounts/test_oidc.py | 237 ++++++++++++++++++++ accounts/urls.py | 9 +- accounts/views.py | 5 +- diora/settings.py | 8 + docs/accounts.md | 55 +++++ static/css/app.css | 13 ++ static/js/app/32-crypto.js | 17 +- static/js/app/33-unlock.js | 254 ++++++++++++++++++++++ static/js/app/52-book-list.js | 7 + static/js/sw.js | 3 +- templates/accounts/login.html | 6 + templates/accounts/settings.html | 25 ++- templates/radio/player.html | 1 + 18 files changed, 1132 insertions(+), 11 deletions(-) create mode 100644 accounts/keyshare.py create mode 100644 accounts/migrations/0008_oidc_keyshare.py create mode 100644 accounts/oidc.py create mode 100644 accounts/oidc_views.py create mode 100644 accounts/test_oidc.py create mode 100644 docs/accounts.md create mode 100644 static/js/app/33-unlock.js diff --git a/accounts/keyshare.py b/accounts/keyshare.py new file mode 100644 index 0000000..4593245 --- /dev/null +++ b/accounts/keyshare.py @@ -0,0 +1,134 @@ +"""Passing the book key from an unlocked device to a new one (models.KeyShare). + +The book key never reaches the server in the clear: the new device posts an ECDH P-256 +public key, an unlocked device of the same account answers with its own public key and +the book key AES-GCM-encrypted under the shared secret. Both devices show a code made +from the request's public key (static/js/app/33-unlock.js), so the user approves the +right device. Requests run out after a few minutes; an answer is handed out once. + + POST /accounts/keyshare/ {pub, device} -> {token} + GET /accounts/keyshare// -> {state, pub, iv, ct} + GET /accounts/keyshare/pending/ -> {requests: [...]} + POST /accounts/keyshare//answer/ {pub, iv, ct} | {decline: true} +""" +import base64 +import binascii +import json +from datetime import timedelta + +from django.http import JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from .models import KeyShare + +LIFETIME = timedelta(minutes=10) +# Only requests whose device asked lately are offered: a closed tab drops out quickly. +ALIVE = timedelta(seconds=20) +MAX_OPEN = 5 + + +def _auth(request): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + return None + + +def _body(request): + try: + body = json.loads(request.body or b'{}') + except (json.JSONDecodeError, ValueError): + return None + return body if isinstance(body, dict) else None + + +def _b64(value, low, high): + """A base64 string whose decoded length lies in [low, high], else None.""" + if not isinstance(value, str) or len(value) > 4 * high: + return None + try: + raw = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError): + return None + return value if low <= len(raw) <= high else None + + +def _purge(user): + KeyShare.objects.filter(user=user, created_at__lt=timezone.now() - LIFETIME).delete() + + +@require_http_methods(['POST']) +def create(request): + if (r := _auth(request)): + return r + body = _body(request) + pub = _b64((body or {}).get('pub'), 65, 65) # uncompressed P-256 point + if pub is None: + return JsonResponse({'error': 'pub required'}, status=400) + _purge(request.user) + # A device that reloads asks anew: the oldest requests make room instead of blocking it. + stale = KeyShare.objects.filter(user=request.user).order_by('-created_at').values_list('pk', flat=True)[MAX_OPEN - 1:] + KeyShare.objects.filter(pk__in=list(stale)).delete() + device = str(body.get('device') or '')[:100] + share = KeyShare.objects.create(user=request.user, request_pub=pub, device=device, + polled_at=timezone.now()) + return JsonResponse({'token': share.token}) + + +@require_http_methods(['GET']) +def status(request, token): + if (r := _auth(request)): + return r + _purge(request.user) + share = KeyShare.objects.filter(user=request.user, token=token).first() + if share is None: + return JsonResponse({'state': 'expired'}) + if share.declined: + share.delete() + return JsonResponse({'state': 'declined'}) + if not share.answer_ct: + share.polled_at = timezone.now() + share.save(update_fields=['polled_at']) + return JsonResponse({'state': 'pending'}) + data = {'state': 'answered', 'pub': share.answer_pub, 'iv': share.answer_iv, 'ct': share.answer_ct} + share.delete() # handed out once + return JsonResponse(data) + + +@require_http_methods(['GET']) +def pending(request): + if (r := _auth(request)): + return r + _purge(request.user) + open_ = KeyShare.objects.filter(user=request.user, declined=False, answer_ct='', + polled_at__gte=timezone.now() - ALIVE).order_by('-created_at') + return JsonResponse({'requests': [ + {'token': s.token, 'pub': s.request_pub, 'device': s.device, + 'created_at': s.created_at.isoformat()} for s in open_]}) + + +@require_http_methods(['POST']) +def answer(request, token): + if (r := _auth(request)): + return r + _purge(request.user) + share = KeyShare.objects.filter(user=request.user, token=token).first() + if share is None: + return JsonResponse({'error': 'expired'}, status=404) + if share.answer_ct or share.declined: + return JsonResponse({'error': 'already answered'}, status=409) + body = _body(request) or {} + if body.get('decline'): + share.declined = True + share.answered_at = timezone.now() + share.save(update_fields=['declined', 'answered_at']) + return JsonResponse({'ok': True}) + pub = _b64(body.get('pub'), 65, 65) + iv = _b64(body.get('iv'), 12, 12) + ct = _b64(body.get('ct'), 32 + 16, 32 + 16) # 256-bit key + GCM tag + if not (pub and iv and ct): + return JsonResponse({'error': 'pub, iv and ct required'}, status=400) + share.answer_pub, share.answer_iv, share.answer_ct = pub, iv, ct + share.answered_at = timezone.now() + share.save(update_fields=['answer_pub', 'answer_iv', 'answer_ct', 'answered_at']) + return JsonResponse({'ok': True}) diff --git a/accounts/migrations/0008_oidc_keyshare.py b/accounts/migrations/0008_oidc_keyshare.py new file mode 100644 index 0000000..be4f9de --- /dev/null +++ b/accounts/migrations/0008_oidc_keyshare.py @@ -0,0 +1,50 @@ +# Generated by Django 6.1 on 2026-11-01 13:35 + +import accounts.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_uservault'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='KeyShare', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(default=accounts.models._generate_token, max_length=64, unique=True)), + ('device', models.CharField(blank=True, max_length=100)), + ('request_pub', models.TextField()), + ('answer_pub', models.TextField(blank=True)), + ('answer_iv', models.CharField(blank=True, max_length=64)), + ('answer_ct', models.TextField(blank=True)), + ('declined', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('polled_at', models.DateTimeField(blank=True, null=True)), + ('answered_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='key_shares', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='OidcIdentity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('issuer', models.CharField(max_length=200)), + ('sub', models.CharField(max_length=255)), + ('username', models.CharField(blank=True, max_length=150)), + ('email', models.CharField(blank=True, max_length=254)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_login_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oidc_identities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('issuer', 'sub')}, + }, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index c06b29d..a755a34 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -142,3 +142,40 @@ class ApiToken(models.Model): def __str__(self): return f"ApiToken({self.user.username})" + + +class OidcIdentity(models.Model): + """An account of the sign-in provider (Authelia) linked to a diora account + (accounts/oidc.py). `sub` is the provider's stable id; name and mail are only shown.""" + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='oidc_identities') + issuer = models.CharField(max_length=200) + sub = models.CharField(max_length=255) + username = models.CharField(max_length=150, blank=True) + email = models.CharField(max_length=254, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + last_login_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('issuer', 'sub') + + def __str__(self): + return f"OidcIdentity({self.username or self.sub} -> {self.user_id})" + + +class KeyShare(models.Model): + """A device without the book key asks an unlocked device of the same account for it + (accounts/keyshare.py). The server only passes on public keys and ciphertext: the new + device sends an ECDH public key, the unlocked one answers with its own public key and + the book key encrypted for the shared secret. Both show a code made from the request's + public key, so the user can see the answer goes to the right device.""" + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='key_shares') + token = models.CharField(max_length=64, unique=True, default=_generate_token) + device = models.CharField(max_length=100, blank=True) + request_pub = models.TextField() # base64 raw P-256 point of the asking device + answer_pub = models.TextField(blank=True) + answer_iv = models.CharField(max_length=64, blank=True) + answer_ct = models.TextField(blank=True) + declined = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + polled_at = models.DateTimeField(null=True, blank=True) # the asking device still waits + answered_at = models.DateTimeField(null=True, blank=True) diff --git a/accounts/oidc.py b/accounts/oidc.py new file mode 100644 index 0000000..833b94c --- /dev/null +++ b/accounts/oidc.py @@ -0,0 +1,158 @@ +"""Sign-in with an OpenID Connect provider (Authelia, docs/accounts.md). + +An extra way in next to username + password: an account is linked once in the settings +while signed in normally, after that the provider's login leads into it. Nobody gets a new +account from the provider — an unlinked identity is turned away. + +Authorization code flow with PKCE (S256) and a confidential client (client_secret_basic). +The id_token is checked here (RS256 against the provider's JWKS, iss, aud, exp, nonce) +with `cryptography`, so no OIDC library is needed. + +The book key is not touched: it is derived from the diora password on the device. A device +that signs in this way gets it from an unlocked device or by typing the password once +(accounts/keyshare.py, static/js/app/33-unlock.js). +""" +import base64 +import hashlib +import json +import secrets +import time + +from urllib.parse import urlencode + +import requests +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from django.conf import settings + +TIMEOUT = 10 +_CACHE_SECONDS = 3600 +_cache = {} # url -> (fetched_at, json) + + +class OidcError(Exception): + pass + + +def enabled() -> bool: + return bool(settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET) + + +def _b64url_decode(s: str) -> bytes: + return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii') + + +def _get_json(url: str, fresh: bool = False) -> dict: + hit = _cache.get(url) + if hit and not fresh and time.time() - hit[0] < _CACHE_SECONDS: + return hit[1] + try: + resp = requests.get(url, timeout=TIMEOUT) + resp.raise_for_status() + data = resp.json() + except (requests.RequestException, ValueError) as exc: + raise OidcError(f'{url} nicht erreichbar: {exc}') from exc + _cache[url] = (time.time(), data) + return data + + +def discovery() -> dict: + issuer = settings.OIDC_ISSUER.rstrip('/') + return _get_json(issuer + '/.well-known/openid-configuration') + + +def new_flow() -> dict: + """Random state, nonce and PKCE verifier for one sign-in (kept in the session).""" + return { + 'state': secrets.token_urlsafe(24), + 'nonce': secrets.token_urlsafe(24), + 'verifier': secrets.token_urlsafe(48), + } + + +def authorize_url(flow: dict, redirect_uri: str) -> str: + challenge = _b64url(hashlib.sha256(flow['verifier'].encode('ascii')).digest()) + params = { + 'response_type': 'code', + 'client_id': settings.OIDC_CLIENT_ID, + 'redirect_uri': redirect_uri, + 'scope': 'openid profile email groups', + 'state': flow['state'], + 'nonce': flow['nonce'], + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + } + return discovery()['authorization_endpoint'] + '?' + urlencode(params) + + +def exchange_code(code: str, flow: dict, redirect_uri: str) -> dict: + """Trade the code for tokens and return the checked id_token claims.""" + try: + resp = requests.post( + discovery()['token_endpoint'], + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'code_verifier': flow['verifier'], + }, + auth=(settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET), + timeout=TIMEOUT, + ) + except requests.RequestException as exc: + raise OidcError(f'Token-Abruf fehlgeschlagen: {exc}') from exc + if resp.status_code != 200: + raise OidcError(f'Token-Abruf abgelehnt ({resp.status_code})') + try: + id_token = resp.json()['id_token'] + except (ValueError, KeyError) as exc: + raise OidcError('Antwort ohne id_token') from exc + return verify_id_token(id_token, flow['nonce']) + + +def _public_key(kid: str): + jwks_uri = discovery()['jwks_uri'] + for fresh in (False, True): # a key rotation shows up as an unknown kid: fetch again once + for jwk in _get_json(jwks_uri, fresh=fresh).get('keys', []): + if jwk.get('kty') == 'RSA' and (not kid or jwk.get('kid') == kid): + n = int.from_bytes(_b64url_decode(jwk['n']), 'big') + e = int.from_bytes(_b64url_decode(jwk['e']), 'big') + return rsa.RSAPublicNumbers(e, n).public_key() + raise OidcError('Signaturschlüssel des Anbieters nicht gefunden') + + +def verify_id_token(token: str, nonce: str, now: float = None) -> dict: + try: + header_b64, payload_b64, sig_b64 = token.split('.') + header = json.loads(_b64url_decode(header_b64)) + claims = json.loads(_b64url_decode(payload_b64)) + signature = _b64url_decode(sig_b64) + except (ValueError, TypeError) as exc: + raise OidcError('id_token unlesbar') from exc + if header.get('alg') != 'RS256': + raise OidcError(f'nicht unterstützte Signatur {header.get("alg")!r} (erwartet RS256)') + try: + _public_key(header.get('kid', '')).verify( + signature, f'{header_b64}.{payload_b64}'.encode('ascii'), + padding.PKCS1v15(), hashes.SHA256()) + except InvalidSignature as exc: + raise OidcError('Signatur des id_token ungültig') from exc + + now = time.time() if now is None else now + if claims.get('iss', '').rstrip('/') != settings.OIDC_ISSUER.rstrip('/'): + raise OidcError('falscher Aussteller') + aud = claims.get('aud') + if settings.OIDC_CLIENT_ID not in (aud if isinstance(aud, list) else [aud]): + raise OidcError('id_token ist nicht für diora') + if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] < now - 60: + raise OidcError('id_token abgelaufen') + if not nonce or claims.get('nonce') != nonce: + raise OidcError('nonce passt nicht') + if not claims.get('sub'): + raise OidcError('id_token ohne sub') + return claims diff --git a/accounts/oidc_views.py b/accounts/oidc_views.py new file mode 100644 index 0000000..1e655ad --- /dev/null +++ b/accounts/oidc_views.py @@ -0,0 +1,124 @@ +"""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 +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.') + 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') + # The page asks for the book key (static/js/app/33-unlock.js); a password login + # brings it along, this one cannot. + return redirect(flow.get('next') or '/') + + +@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') diff --git a/accounts/test_oidc.py b/accounts/test_oidc.py new file mode 100644 index 0000000..4f08c3e --- /dev/null +++ b/accounts/test_oidc.py @@ -0,0 +1,237 @@ +import base64 +import json +import time +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from django.contrib.auth.models import User +from django.test import TestCase, override_settings + +from . import oidc +from .models import KeyShare, OidcIdentity + +ISSUER = 'https://auth.example.org' +PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048) +OTHER_PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _b64url(b): + return base64.urlsafe_b64encode(b).rstrip(b'=').decode() + + +def _int_b64(i): + return _b64url(i.to_bytes((i.bit_length() + 7) // 8, 'big')) + + +def make_token(claims, key=PRIV, alg='RS256', kid='k1'): + header = _b64url(json.dumps({'alg': alg, 'kid': kid, 'typ': 'JWT'}).encode()) + payload = _b64url(json.dumps(claims).encode()) + sig = key.sign(f'{header}.{payload}'.encode(), padding.PKCS1v15(), hashes.SHA256()) + return f'{header}.{payload}.{_b64url(sig)}' + + +DISCOVERY = { + 'issuer': ISSUER, + 'authorization_endpoint': ISSUER + '/api/oidc/authorization', + 'token_endpoint': ISSUER + '/api/oidc/token', + 'jwks_uri': ISSUER + '/jwks.json', +} +pub = PRIV.public_key().public_numbers() +JWKS = {'keys': [{'kty': 'RSA', 'kid': 'k1', 'n': _int_b64(pub.n), 'e': _int_b64(pub.e)}]} + + +class FakeResp: + def __init__(self, data, status=200): + self._data, self.status_code = data, status + + def json(self): + return self._data + + def raise_for_status(self): + if self.status_code >= 400: + raise oidc.requests.HTTPError(str(self.status_code)) + + +def fake_get(url, timeout=None): + return FakeResp(DISCOVERY if url.endswith('openid-configuration') else JWKS) + + +@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret', + OIDC_REDIRECT_URI='https://diora.test/accounts/oidc/callback/') +class OidcFlowTests(TestCase): + def setUp(self): + oidc._cache.clear() + self.user = User.objects.create_user(username='crm', password='pw12345678') + self.get = patch('accounts.oidc.requests.get', side_effect=fake_get) + self.get.start() + self.addCleanup(self.get.stop) + + def _start(self, query=''): + resp = self.client.get('/accounts/oidc/start/' + query) + self.assertEqual(resp.status_code, 302) + q = parse_qs(urlsplit(resp['Location']).query) + self.assertEqual(q['code_challenge_method'], ['S256']) + self.assertEqual(q['client_id'], ['diora']) + return q['state'][0], q['nonce'][0] + + def _callback(self, state, claims, token_status=200): + token = make_token(claims) + with patch('accounts.oidc.requests.post', + return_value=FakeResp({'id_token': token}, token_status)) as post: + resp = self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'c0de'}) + return resp, post + + def _claims(self, nonce, **extra): + c = {'iss': ISSUER, 'aud': ['diora'], 'sub': 'uuid-1', 'exp': time.time() + 300, + 'nonce': nonce, 'preferred_username': 'marwin', 'email': 'm@example.org'} + c.update(extra) + return c + + def test_unlinked_identity_is_turned_away(self): + state, nonce = self._start() + resp, _ = self._callback(state, self._claims(nonce)) + self.assertEqual(resp['Location'], '/accounts/login/') + self.assertNotIn('_auth_user_id', self.client.session) + self.assertFalse(User.objects.exclude(pk=self.user.pk).exists()) + + def test_link_then_login(self): + self.client.force_login(self.user) + state, nonce = self._start('?link=1') + resp, post = self._callback(state, self._claims(nonce)) + self.assertEqual(resp['Location'], '/accounts/settings/') + ident = OidcIdentity.objects.get() + self.assertEqual((ident.user, ident.sub, ident.username), (self.user, 'uuid-1', 'marwin')) + # client_secret_basic + PKCE verifier + self.assertEqual(post.call_args.kwargs['auth'], ('diora', 's3cret')) + self.assertIn('code_verifier', post.call_args.kwargs['data']) + + self.client.logout() + state, nonce = self._start('?next=/books/') + resp, _ = self._callback(state, self._claims(nonce)) + self.assertEqual(resp['Location'], '/books/') + self.assertEqual(int(self.client.session['_auth_user_id']), self.user.pk) + ident.refresh_from_db() + self.assertIsNotNone(ident.last_login_at) + + def test_next_to_other_host_is_dropped(self): + OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + state, nonce = self._start('?next=https://evil.example/') + resp, _ = self._callback(state, self._claims(nonce)) + self.assertEqual(resp['Location'], '/') + + def test_identity_of_other_account_is_not_moved(self): + other = User.objects.create_user(username='bob', password='pw12345678') + OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-1') + self.client.force_login(self.user) + state, nonce = self._start('?link=1') + self._callback(state, self._claims(nonce)) + self.assertEqual(OidcIdentity.objects.get().user, other) + + def test_wrong_state_nonce_audience_or_signature_fail(self): + OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + cases = [ + ('state', {}), + ('nonce', {'nonce': 'other'}), + ('aud', {'aud': 'someone-else'}), + ('iss', {'iss': 'https://evil.example'}), + ('exp', {'exp': time.time() - 3600}), + ] + for name, extra in cases: + with self.subTest(name): + state, nonce = self._start() + claims = {**self._claims(nonce), **extra} + resp, _ = self._callback('wrong' if name == 'state' else state, claims) + self.assertEqual(resp['Location'], '/accounts/login/') + self.assertNotIn('_auth_user_id', self.client.session) + state, nonce = self._start() + with patch('accounts.oidc.requests.post', + return_value=FakeResp({'id_token': make_token(self._claims(nonce), key=OTHER_PRIV)})): + self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'x'}) + self.assertNotIn('_auth_user_id', self.client.session) + + def test_state_is_single_use(self): + OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + state, nonce = self._start() + self._callback(state, self._claims(nonce)) + self.client.logout() + resp, _ = self._callback(state, self._claims(nonce)) + self.assertNotIn('_auth_user_id', self.client.session) + + def test_unlink(self): + ident = OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + other = User.objects.create_user(username='bob', password='pw12345678') + foreign = OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-2') + self.client.force_login(self.user) + self.client.post(f'/accounts/oidc/{foreign.pk}/unlink/') + self.client.post(f'/accounts/oidc/{ident.pk}/unlink/') + self.assertEqual(list(OidcIdentity.objects.all()), [foreign]) + + def test_login_page_shows_button_only_when_configured(self): + self.assertContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden') + with override_settings(OIDC_CLIENT_SECRET=''): + self.assertNotContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden') + + +PUB = base64.b64encode(b'\x04' + b'\x01' * 64).decode() + + +class KeyShareTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='crm', password='pw12345678') + self.client.force_login(self.user) + + def _post(self, url, body): + return self.client.post(url, json.dumps(body), content_type='application/json') + + def test_round_trip_hands_answer_out_once(self): + token = self._post('/accounts/keyshare/', {'pub': PUB, 'device': 'Firefox auf Linux'}).json()['token'] + self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'pending'}) + pending = self.client.get('/accounts/keyshare/pending/').json()['requests'] + self.assertEqual([(r['token'], r['pub'], r['device']) for r in pending], [(token, PUB, 'Firefox auf Linux')]) + + answer = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(), + 'ct': base64.b64encode(b'c' * 48).decode()} + self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 200) + self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 409) + self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], []) + got = self.client.get(f'/accounts/keyshare/{token}/').json() + self.assertEqual(got, {'state': 'answered', **answer}) + self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'expired'}) + + def test_requests_of_devices_that_stopped_asking_are_not_offered(self): + token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] + KeyShare.objects.update(polled_at=KeyShare.objects.get().created_at.replace(year=2000)) + self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], []) + self.client.get(f'/accounts/keyshare/{token}/') # asks again + self.assertEqual(len(self.client.get('/accounts/keyshare/pending/').json()['requests']), 1) + + def test_decline(self): + token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] + self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True}) + self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'declined'}) + + def test_bad_input_is_refused(self): + self.assertEqual(self._post('/accounts/keyshare/', {'pub': 'nope'}).status_code, 400) + token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] + bad = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(), 'ct': base64.b64encode(b'c' * 10).decode()} + self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', bad).status_code, 400) + + def test_other_accounts_see_nothing(self): + token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] + other = User.objects.create_user(username='bob', password='pw12345678') + self.client.force_login(other) + self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], []) + self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True}).status_code, 404) + self.client.logout() + self.assertEqual(self.client.get('/accounts/keyshare/pending/').status_code, 401) + + def test_open_requests_are_limited_and_run_out(self): + tokens = [self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] for _ in range(7)] + self.assertEqual(KeyShare.objects.count(), 5) + self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[0]}/').json(), {'state': 'expired'}) + self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[-1]}/').json(), {'state': 'pending'}) + KeyShare.objects.update(created_at=KeyShare.objects.first().created_at.replace(year=2000)) + self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], []) + self.assertEqual(self._post('/accounts/keyshare/', {'pub': PUB}).status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index 341bd8d..1cac810 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,6 +1,6 @@ from django.contrib.auth.views import LogoutView from django.urls import path -from . import views +from . import keyshare, oidc_views, views urlpatterns = [ path('register/', views.register, name='register'), @@ -21,4 +21,11 @@ urlpatterns = [ path('webdav//test/', views.webdav_test, name='webdav_test'), path('webdav//delete/', views.webdav_delete, name='webdav_delete'), path('vault//', views.vault, name='vault'), + path('oidc/start/', oidc_views.oidc_start, name='oidc_start'), + path('oidc/callback/', oidc_views.oidc_callback, name='oidc_callback'), + path('oidc//unlink/', oidc_views.oidc_unlink, name='oidc_unlink'), + path('keyshare/', keyshare.create, name='keyshare_create'), + path('keyshare/pending/', keyshare.pending, name='keyshare_pending'), + path('keyshare//', keyshare.status, name='keyshare_status'), + path('keyshare//answer/', keyshare.answer, name='keyshare_answer'), ] diff --git a/accounts/views.py b/accounts/views.py index 7b09742..55aa18c 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -13,6 +13,7 @@ from django.views.decorators.http import require_http_methods from radio import lastfm as lastfm_module from .models import ApiToken, UserVault, WebDAVSource +from .oidc_views import oidc_context from .webdav import WebDAVError, list_directory User = get_user_model() @@ -56,7 +57,7 @@ def login_view(request): else: form = AuthenticationForm() - return render(request, 'accounts/login.html', {'form': form}) + return render(request, 'accounts/login.html', {'form': form, **oidc_context(request)}) # --------------------------------------------------------------------------- @@ -78,6 +79,7 @@ def settings_view(request): 'password_form': PasswordChangeForm(request.user), 'api_token': getattr(request.user, 'api_token', None), 'webdav_sources': request.user.webdav_sources.all(), + **oidc_context(request), } return render(request, 'accounts/settings.html', context) @@ -217,6 +219,7 @@ def change_password(request): 'password_form_open': True, 'api_token': getattr(request.user, 'api_token', None), 'webdav_sources': request.user.webdav_sources.all(), + **oidc_context(request), }) diff --git a/diora/settings.py b/diora/settings.py index e0c9112..75bed42 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -138,6 +138,14 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' LOGIN_URL = '/accounts/login/' LOGIN_REDIRECT_URL = '/' + +# Sign-in with Authelia (accounts/oidc.py); off while one of the three is empty. +OIDC_ISSUER = os.environ.get('DIORA_OIDC_ISSUER', '') +OIDC_CLIENT_ID = os.environ.get('DIORA_OIDC_CLIENT_ID', '') +OIDC_CLIENT_SECRET = os.environ.get('DIORA_OIDC_CLIENT_SECRET', '') +# Fixed callback address; empty = built from the request (needs the real https host). +OIDC_REDIRECT_URI = os.environ.get('DIORA_OIDC_REDIRECT_URI', '') +OIDC_PROVIDER_NAME = os.environ.get('DIORA_OIDC_PROVIDER_NAME', 'Authelia') LOGOUT_REDIRECT_URL = '/' # Last.fm diff --git a/docs/accounts.md b/docs/accounts.md new file mode 100644 index 0000000..777744a --- /dev/null +++ b/docs/accounts.md @@ -0,0 +1,55 @@ +# Anmeldung und Bücher-Schlüssel + +## Zwei Wege hinein + +**Benutzername und Passwort** (`accounts/views.py` `login_view`): Das Login-Formular leitet im +Browser aus dem Passwort den Bücher-Schlüssel ab (PBKDF2-SHA256, Salt `diora:`, +200 000 Runden) und legt ihn bis zur nächsten Seite in `localStorage['diora_pending_enc_key']` +ab. Die App-Seite übernimmt ihn als `diora_enc_key_` (`32-crypto.js`, `99-init.js`). + +**Authelia (OpenID Connect)** (`accounts/oidc.py`, `accounts/oidc_views.py`): als zusätzlicher +Weg, die Anmeldung mit Passwort bleibt. Der Ablauf ist Authorization Code mit PKCE (S256) und +einem vertraulichen Client (`client_secret_basic`). Das id_token wird hier geprüft (RS256 gegen +die JWKS des Anbieters, `iss`, `aud`, `exp`, `nonce`), dafür reicht `cryptography`. + +- Ein Konto wird **einmal verknüpft**: in den Einstellungen „Mit Authelia verknüpfen“, während + man normal angemeldet ist (`/accounts/oidc/start/?link=1`). Gespeichert wird `OidcIdentity` + (`issuer`, `sub`; Name und Mail nur zur Anzeige). Danach führt „Mit Authelia anmelden“ auf der + Login-Seite in dieses Konto. +- Eine nicht verknüpfte Authelia-Identität wird abgewiesen. Es entstehen **keine neuen + Konten** über Authelia, und es wird auch nicht über Name oder Mail automatisch verknüpft. +- Wer hinein darf, regelt Authelia (Policy `mitglieder` = Gruppen `admins` oder `freunde`). +- Einstellungen per Umgebung: `DIORA_OIDC_ISSUER`, `DIORA_OIDC_CLIENT_ID`, + `DIORA_OIDC_CLIENT_SECRET`, `DIORA_OIDC_REDIRECT_URI` (fest, weil hinter dem Proxy kein + https erkannt wird), optional `DIORA_OIDC_PROVIDER_NAME`. Fehlt eins der ersten drei, gibt es + keinen Knopf. Auf prod stehen sie in `~/docker-compose/diora-prod/.env` (Strato), das + Gegenstück liegt auf Hetzner in `/opt/authelia/oidc-clients.env`. +- Redirect-URI im Authelia-Client: `https://diora.creamfresh.xyz/accounts/oidc/callback/`. + +## Der Schlüssel nach einer Authelia-Anmeldung + +Ohne Passwort gibt es keinen Schlüssel. Deshalb erzeugt `getOrCreateEncKey()` **nie mehr +selbst einen neuen Schlüssel**: Früher machte es einen zufälligen, wenn keiner da war, und damit +waren die vorhandenen Bücher auf diesem Gerät verloren. Jetzt fragt es `dioraUnlock()` +(`static/js/app/33-unlock.js`). Es gibt zwei Wege: + +1. **Von einem entsperrten Gerät übergeben** (`accounts/keyshare.py`, Modell `KeyShare`). Das + neue Gerät schickt einen ECDH-P-256-Public-Key und zeigt einen 6-stelligen Code, der aus + diesem Key berechnet wird. Entsperrte Geräte fragen alle 15 s + `/accounts/keyshare/pending/` ab und zeigen denselben Code mit „Freigeben/Ablehnen“. Die + Freigabe verschlüsselt den Bücher-Schlüssel mit AES-GCM unter dem gemeinsamen Geheimnis + (HKDF-SHA256, info `diora keyshare v1`). Der Server sieht nur Public Keys und Chiffrat und + gibt die Antwort genau einmal heraus. Anträge laufen nach 10 min ab. Angeboten werden nur + solche, deren Gerät in den letzten 20 s nachgefragt hat, und je Konto höchstens 5, die + ältesten weichen. +2. **Das diora-Passwort einmal eingeben**: dieselbe Ableitung wie beim Login. + +In beiden Fällen wird der Schlüssel an einem Buch geprüft, bevor er gespeichert wird. Mit +„Später“ bleiben Bücher gesperrt, und die Bibliothek zeigt einen Knopf „Entsperren“. Die +Einstellungsseite erzeugt ebenfalls keinen Schlüssel mehr. + +## Apps + +Android und Desktop melden sich bisher nur mit Passwort an (eigene Login-Seiten in +`diora-android/web-native/login.html` und `diora-desktop/src/web/login.js`, die einen +API-Token holen). Authelia für die Apps folgt als eigener Schritt. diff --git a/static/css/app.css b/static/css/app.css index 6cb0976..9c6f893 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -734,6 +734,19 @@ html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3 font-size: 0.75rem; } +.auth-alt { margin-top: 1rem; } +.auth-or { + display: flex; align-items: center; gap: 0.6rem; + margin: 0 0 1rem; font-size: 0.8rem; color: var(--text-muted); +} +.auth-or::before, .auth-or::after { content: ''; flex: 1; border-top: 1px solid var(--border, #333); } +.auth-alt .btn-full { display: block; text-align: center; text-decoration: none; } + +/* Unlocking the books on a device without the key (js/app/33-unlock.js) */ +.unlock-code { font: 600 1.6rem/1.2 ui-monospace, monospace; letter-spacing: 0.15em; text-align: center; margin: 4px 0 14px; } +.unlock-or { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 8px; } +.unlock-status { font-size: 0.85rem; color: var(--text-muted); min-height: 1.2em; margin: 0 0 10px; } + .auth-switch { margin-top: 1.25rem; font-size: 0.85rem; diff --git a/static/js/app/32-crypto.js b/static/js/app/32-crypto.js index 53ff499..257613e 100644 --- a/static/js/app/32-crypto.js +++ b/static/js/app/32-crypto.js @@ -41,10 +41,19 @@ async function getOrCreateEncKey() { return _encKey; } catch (e) { /* fall through, generate new */ } } - // No key found — generate one and store it - _encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); - const raw = await crypto.subtle.exportKey('raw', _encKey); - localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); + // Just signed in with the password: the login form left the derived key (99-init.js + // moves it too, but this may run first). + const pending = window.USER_ID ? localStorage.getItem('diora_pending_enc_key') : null; + if (pending) { + localStorage.setItem(storageKey, pending); + localStorage.removeItem('diora_pending_enc_key'); + return getOrCreateEncKey(); + } + // No key on this device (signed in through Authelia, or the storage was cleared): never + // make a new one — that would lock the existing books away. Ask for the real one + // (33-unlock.js); throws DioraLocked when the user puts it off. + const raw = await dioraUnlock(); + _encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); return _encKey; } diff --git a/static/js/app/33-unlock.js b/static/js/app/33-unlock.js new file mode 100644 index 0000000..ffca201 --- /dev/null +++ b/static/js/app/33-unlock.js @@ -0,0 +1,254 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Unlocking the books on a device without the key (accounts/keyshare.py) +// +// A password login derives the book key on the device (login.html). A device signed in +// another way (Authelia, accounts/oidc.py) has none: getOrCreateEncKey() then asks here +// instead of making a new key. Two ways in: +// - an unlocked device of the account hands it over: this device posts an ECDH P-256 +// public key, the other one shows the same 6-digit code (made from that key), the user +// approves there and the book key comes back encrypted for this device only; +// - or the diora password, the same derivation as at login. +// The server only ever sees public keys and ciphertext. +// --------------------------------------------------------------------------- + +const KEYSHARE_INFO = new TextEncoder().encode('diora keyshare v1'); +let _unlockPromise = null; +let _unlockDismissed = false; + +function dioraLockedError() { + const e = new Error('Bücher sind auf diesem Gerät noch nicht entsperrt'); + e.name = 'DioraLocked'; + return e; +} + +function _csrf() { + return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || ''; +} + +async function _ksFetch(url, body) { + const opts = body === undefined ? {credentials: 'same-origin'} : { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': _csrf()}, + body: JSON.stringify(body), + }; + const res = await fetch(url, opts); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); +} + +// 6-digit code from the asking device's public key, shown on both devices. +async function keyshareCode(pubB64) { + const h = new Uint8Array(await crypto.subtle.digest('SHA-256', base64ToBytes(pubB64))); + const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0; + const s = String(n % 1000000).padStart(6, '0'); + return s.slice(0, 3) + ' ' + s.slice(3); +} + +async function _sharedAesKey(privateKey, otherPubB64) { + const otherPub = await crypto.subtle.importKey('raw', base64ToBytes(otherPubB64), + {name: 'ECDH', namedCurve: 'P-256'}, false, []); + const bits = await crypto.subtle.deriveBits({name: 'ECDH', public: otherPub}, privateKey, 256); + const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: KEYSHARE_INFO}, + hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']); +} + +function _deviceName() { + const ua = navigator.userAgent || ''; + const app = /diora-desktop|Electron/i.test(ua) ? 'diora Desktop' + : /; wv\)|diora/i.test(ua) && /Android/i.test(ua) ? 'diora App' + : /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' + : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser'; + const os = /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS' + : /Windows/i.test(ua) ? 'Windows' : /Mac OS/i.test(ua) ? 'macOS' : /Linux/i.test(ua) ? 'Linux' : ''; + return os ? `${app} auf ${os}` : app; +} + +// Is this raw key the account's book key? Tried on one book's metadata; without books +// there is nothing to compare with, so it is taken. +async function _keyFits(raw) { + let books = []; + try { books = await _ksFetch('/books/'); } catch (e) { return true; } + const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null; + if (!b) return true; + try { + const key = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']); + await decryptBytes(key, b.meta_iv, b.meta_ct); + return true; + } catch (e) { return false; } +} + +function _storeRawKey(raw) { + localStorage.setItem(`diora_enc_key_${window.USER_ID || 0}`, bytesToBase64(new Uint8Array(raw))); +} + +function _el(tag, attrs, text) { + const el = document.createElement(tag); + for (const k in (attrs || {})) el.setAttribute(k, attrs[k]); + if (text) el.textContent = text; + return el; +} + +function _openDialog(build) { + const overlay = _el('div', {class: 'modal-overlay'}); + const dialog = _el('div', {class: 'modal-dialog', role: 'dialog', 'aria-modal': 'true'}); + document.body.append(overlay, dialog); + const close = () => { overlay.remove(); dialog.remove(); }; + build(dialog, close); + return close; +} + +// Ask for the key. Resolves with the raw key (already stored), rejects with DioraLocked. +function dioraUnlock(opts) { + opts = opts || {}; + if (_unlockPromise) return _unlockPromise; + if (_unlockDismissed && !opts.force) return Promise.reject(dioraLockedError()); + _unlockPromise = new Promise((resolve, reject) => { + let stopped = false; + let pair = null, token = null; + const finish = (raw) => { + stopped = true; close(); _unlockPromise = null; _unlockDismissed = false; + _storeRawKey(raw); + resolve(raw); + document.dispatchEvent(new CustomEvent('diora:unlocked')); + }; + const close = _openDialog((d, closeFn) => { + d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Bücher entsperren')); + d.append(_el('p', {class: 'modal-message'}, + 'Deine Bücher sind mit deinem diora-Passwort verschlüsselt. Öffne diora auf einem Gerät, ' + + 'auf dem sie schon offen sind, und bestätige dort diesen Code:')); + const code = _el('div', {class: 'unlock-code'}, '··· ···'); + const status = _el('p', {class: 'unlock-status'}, 'Frage an deine anderen Geräte …'); + d.append(code, status); + d.append(_el('p', {class: 'unlock-or'}, 'Oder gib hier dein diora-Passwort ein:')); + const pw = _el('input', {type: 'password', class: 'modal-input', autocomplete: 'current-password', + placeholder: 'diora-Passwort'}); + d.append(pw); + const actions = _el('div', {class: 'modal-actions'}); + const later = _el('button', {class: 'btn', type: 'button'}, 'Später'); + const ok = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Entsperren'); + actions.append(later, ok); + d.append(actions); + + const tryPassword = async () => { + if (!pw.value) { pw.focus(); return; } + ok.disabled = true; + status.textContent = 'Schlüssel wird abgeleitet …'; + try { + const enc = new TextEncoder(); + const username = document.querySelector('meta[name="username"]')?.content || ''; + const mat = await crypto.subtle.importKey('raw', enc.encode(pw.value), 'PBKDF2', false, ['deriveKey']); + const key = await crypto.subtle.deriveKey( + {name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'}, + mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key)); + if (!(await _keyFits(raw))) { status.textContent = 'Das Passwort passt nicht zu deinen Büchern.'; ok.disabled = false; return; } + finish(raw); + } catch (e) { + status.textContent = 'Fehler: ' + e.message; ok.disabled = false; + } + }; + ok.addEventListener('click', tryPassword); + pw.addEventListener('keydown', (e) => { if (e.key === 'Enter') tryPassword(); }); + later.addEventListener('click', () => { + stopped = true; closeFn(); _unlockPromise = null; _unlockDismissed = true; + reject(dioraLockedError()); + }); + + // Ask the other devices. + (async () => { + try { + pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']); + const pub = bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + token = (await _ksFetch('/accounts/keyshare/', {pub, device: _deviceName()})).token; + code.textContent = await keyshareCode(pub); + status.textContent = 'Warte auf Freigabe …'; + while (!stopped) { + await new Promise(r => setTimeout(r, 3000)); + if (stopped) break; + let st; + try { st = await _ksFetch(`/accounts/keyshare/${token}/`); } catch (e) { continue; } + if (st.state === 'pending') continue; + if (st.state === 'declined') { status.textContent = 'Auf dem anderen Gerät abgelehnt.'; break; } + if (st.state === 'expired') { status.textContent = 'Die Anfrage ist abgelaufen. Lade die Seite neu, um es noch einmal zu versuchen.'; break; } + const aes = await _sharedAesKey(pair.privateKey, st.pub); + const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: base64ToBytes(st.iv)}, aes, base64ToBytes(st.ct))); + if (raw.length !== 32 || !(await _keyFits(raw))) { status.textContent = 'Der übergebene Schlüssel passt nicht.'; break; } + finish(raw); + break; + } + } catch (e) { + if (!stopped) status.textContent = 'Übergabe nicht möglich (' + e.message + '). Das Passwort geht immer.'; + } + })(); + }); + }); + return _unlockPromise; +} + +// --- The other side: an unlocked device approves new ones ------------------------------- + +const _seenShares = new Set(); +let _approving = false; + +async function _approveShare(req, rawKey) { + const codeText = await keyshareCode(req.pub); + return new Promise((resolve) => { + _openDialog((d, close) => { + d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Neues Gerät entsperren?')); + d.append(_el('p', {class: 'modal-message'}, + `„${req.device || 'Ein Gerät'}“ möchte deine Bücher öffnen. Gib nur frei, wenn dort derselbe Code steht:`)); + d.append(_el('div', {class: 'unlock-code'}, codeText)); + const status = _el('p', {class: 'unlock-status'}); + d.append(status); + const actions = _el('div', {class: 'modal-actions'}); + const no = _el('button', {class: 'btn', type: 'button'}, 'Ablehnen'); + const yes = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Freigeben'); + actions.append(no, yes); + d.append(actions); + no.addEventListener('click', async () => { + try { await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {decline: true}); } catch (e) {} + close(); resolve(false); + }); + yes.addEventListener('click', async () => { + yes.disabled = no.disabled = true; + try { + const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']); + const aes = await _sharedAesKey(pair.privateKey, req.pub); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aes, rawKey); + await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, { + pub: bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), + iv: bytesToBase64(iv), ct: bytesToBase64(new Uint8Array(ct)), + }); + close(); resolve(true); + } catch (e) { + status.textContent = 'Freigabe fehlgeschlagen: ' + e.message; + yes.disabled = no.disabled = false; + } + }); + }); + }); +} + +async function checkKeyShareRequests() { + if (_approving || !window.USER_ID || document.hidden) return; + const stored = localStorage.getItem(`diora_enc_key_${window.USER_ID}`); + if (!stored) return; + let data; + try { data = await _ksFetch('/accounts/keyshare/pending/'); } catch (e) { return; } + const req = (data.requests || []).find(r => !_seenShares.has(r.token)); + if (!req) return; + _seenShares.add(req.token); + _approving = true; + try { await _approveShare(req, base64ToBytes(stored)); } finally { _approving = false; } +} + +(function () { + if (!window.USER_ID || !window.crypto || !crypto.subtle) return; + setTimeout(checkKeyShareRequests, 2000); + setInterval(checkKeyShareRequests, 15000); + document.addEventListener('visibilitychange', () => { if (!document.hidden) checkKeyShareRequests(); }); +})(); diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index b5a55ee..ff3f49d 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -41,6 +41,13 @@ async function loadBookList() { try { key = await getOrCreateEncKey(); } catch (e) { + if (e.name === 'DioraLocked') { + listEl.innerHTML = '

Deine Bücher sind auf diesem Gerät noch gesperrt.

'; + listEl.querySelector('[data-unlock-books]')?.addEventListener('click', () => { + dioraUnlock({force: true}).then(() => loadBookList(), () => {}); + }); + return; + } listEl.innerHTML = `

Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.

`; return; } diff --git a/static/js/sw.js b/static/js/sw.js index 6320369..1cd9709 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v160'; +const CACHE = 'diora-v161'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -16,6 +16,7 @@ const SHELL = [ '/static/js/app/29-m3u.js', '/static/js/app/30-contrast.js', '/static/js/app/32-crypto.js', + '/static/js/app/33-unlock.js', '/static/js/app/34-wallpaper.js', '/static/js/app/40-epub.js', '/static/js/app/44-books.js', diff --git a/templates/accounts/login.html b/templates/accounts/login.html index 9e1dcec..6290466 100644 --- a/templates/accounts/login.html +++ b/templates/accounts/login.html @@ -28,6 +28,12 @@ {% endif %} + {% if oidc_enabled %} + + {% endif %}

Noch kein Konto? Registrieren

diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index 4ec824f..1d9a014 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -94,6 +94,25 @@ + {% if oidc_enabled or oidc_identities %} + + {% endif %} +
{% csrf_token %} @@ -268,10 +287,8 @@ async function _getOrCreateEncKey() { return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); } catch (e) {} } - const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); - const raw = await crypto.subtle.exportKey('raw', key); - localStorage.setItem(storageKey, _bytesToBase64(raw)); - return key; + // Never a new key here: it would lock the existing books away (static/js/app/33-unlock.js). + throw new Error('Bücher sind auf diesem Gerät noch nicht entsperrt – öffne zuerst die Bibliothek'); } document.getElementById('pw-change-form')?.addEventListener('submit', function(e) { diff --git a/templates/radio/player.html b/templates/radio/player.html index 395be8b..0673de7 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -371,6 +371,7 @@ +