Anmelden mit Authelia als zweiter Weg, Bücher auf neuen Geräten per Code entsperren (sw CACHE v161)
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 11m22s
Test / e2e (push) Failing after 1m29s

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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-01 15:01:25 +01:00
parent 3eca4d8404
commit 077e6d4543
18 changed files with 1132 additions and 11 deletions

134
accounts/keyshare.py Normal file
View file

@ -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/<token>/ -> {state, pub, iv, ct}
GET /accounts/keyshare/pending/ -> {requests: [...]}
POST /accounts/keyshare/<token>/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})

View file

@ -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')},
},
),
]

View file

@ -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)

158
accounts/oidc.py Normal file
View file

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

124
accounts/oidc_views.py Normal file
View file

@ -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')

237
accounts/test_oidc.py Normal file
View file

@ -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)

View file

@ -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/<int:pk>/test/', views.webdav_test, name='webdav_test'),
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
path('vault/<slug:name>/', 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/<int:pk>/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/<str:token>/', keyshare.status, name='keyshare_status'),
path('keyshare/<str:token>/answer/', keyshare.answer, name='keyshare_answer'),
]

View file

@ -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),
})

View file

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

55
docs/accounts.md Normal file
View file

@ -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:<username>`,
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_<userId>` (`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.

View file

@ -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;

View file

@ -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;
}

254
static/js/app/33-unlock.js Normal file
View file

@ -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(); });
})();

View file

@ -41,6 +41,13 @@ async function loadBookList() {
try {
key = await getOrCreateEncKey();
} catch (e) {
if (e.name === 'DioraLocked') {
listEl.innerHTML = '<p class="muted">Deine Bücher sind auf diesem Gerät noch gesperrt. <button class="btn" type="button" data-unlock-books>Entsperren</button></p>';
listEl.querySelector('[data-unlock-books]')?.addEventListener('click', () => {
dioraUnlock({force: true}).then(() => loadBookList(), () => {});
});
return;
}
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
return;
}

View file

@ -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',

View file

@ -28,6 +28,12 @@
{% endif %}
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
{% if oidc_enabled %}
<div class="auth-alt">
<div class="auth-or"><span>oder</span></div>
<a class="btn btn-full" href="{% url 'oidc_start' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}">Mit {{ oidc_provider }} anmelden</a>
</div>
{% endif %}
<p class="auth-switch">
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
</p>

View file

@ -94,6 +94,25 @@
</form>
</details>
{% if oidc_enabled or oidc_identities %}
<div class="oidc-links" style="margin-top:1rem;">
<h3 style="font-size:1rem; margin:0 0 0.4rem;">Anmelden über {{ oidc_provider }}</h3>
{% for ident in oidc_identities %}
<form method="post" action="{% url 'oidc_unlink' ident.pk %}" class="inline-form" style="display:flex; gap:0.6rem; align-items:center; margin-bottom:0.4rem;">
{% csrf_token %}
<span>Verknüpft mit <strong>{{ ident.username|default:ident.sub }}</strong>{% if ident.email %} ({{ ident.email }}){% endif %}</span>
<button type="submit" class="btn">Lösen</button>
</form>
{% empty %}
<p class="muted" style="margin:0 0 0.4rem;">Verknüpfe dein Konto, dann kannst du dich auch über {{ oidc_provider }} anmelden. Die Anmeldung mit Passwort bleibt.</p>
{% endfor %}
{% if oidc_enabled and not oidc_identities %}
<a class="btn" href="{% url 'oidc_start' %}?link=1">Mit {{ oidc_provider }} verknüpfen</a>
{% endif %}
<p class="muted" style="font-size:0.85rem; margin-top:0.4rem;">Deine Bücher bleiben mit deinem diora-Passwort verschlüsselt. Ein Gerät, das du über {{ oidc_provider }} anmeldest, bekommt den Schlüssel von einem schon entsperrten Gerät, oder du gibst dort einmal dein Passwort ein.</p>
</div>
{% endif %}
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %}
<button type="submit" class="btn">Abmelden</button>
@ -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) {

View file

@ -371,6 +371,7 @@
<script src="/static/js/app/29-m3u.js"></script>
<script src="/static/js/app/30-contrast.js"></script>
<script src="/static/js/app/32-crypto.js"></script>
<script src="/static/js/app/33-unlock.js"></script>
<script src="/static/js/app/34-wallpaper.js"></script>
<script src="/static/js/app/40-epub.js"></script>
<script src="/static/js/app/44-books.js"></script>