Anmelden mit Authelia als zweiter Weg, Bücher auf neuen Geräten per Code entsperren (sw CACHE v161)
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:
parent
3eca4d8404
commit
077e6d4543
18 changed files with 1132 additions and 11 deletions
134
accounts/keyshare.py
Normal file
134
accounts/keyshare.py
Normal 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})
|
||||||
50
accounts/migrations/0008_oidc_keyshare.py
Normal file
50
accounts/migrations/0008_oidc_keyshare.py
Normal 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -142,3 +142,40 @@ class ApiToken(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"ApiToken({self.user.username})"
|
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
158
accounts/oidc.py
Normal 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
124
accounts/oidc_views.py
Normal 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
237
accounts/test_oidc.py
Normal 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)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib.auth.views import LogoutView
|
from django.contrib.auth.views import LogoutView
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import keyshare, oidc_views, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('register/', views.register, name='register'),
|
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>/test/', views.webdav_test, name='webdav_test'),
|
||||||
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
|
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
|
||||||
path('vault/<slug:name>/', views.vault, name='vault'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from django.views.decorators.http import require_http_methods
|
||||||
from radio import lastfm as lastfm_module
|
from radio import lastfm as lastfm_module
|
||||||
|
|
||||||
from .models import ApiToken, UserVault, WebDAVSource
|
from .models import ApiToken, UserVault, WebDAVSource
|
||||||
|
from .oidc_views import oidc_context
|
||||||
from .webdav import WebDAVError, list_directory
|
from .webdav import WebDAVError, list_directory
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
@ -56,7 +57,7 @@ def login_view(request):
|
||||||
else:
|
else:
|
||||||
form = AuthenticationForm()
|
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),
|
'password_form': PasswordChangeForm(request.user),
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
'webdav_sources': request.user.webdav_sources.all(),
|
'webdav_sources': request.user.webdav_sources.all(),
|
||||||
|
**oidc_context(request),
|
||||||
}
|
}
|
||||||
return render(request, 'accounts/settings.html', context)
|
return render(request, 'accounts/settings.html', context)
|
||||||
|
|
||||||
|
|
@ -217,6 +219,7 @@ def change_password(request):
|
||||||
'password_form_open': True,
|
'password_form_open': True,
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
'webdav_sources': request.user.webdav_sources.all(),
|
'webdav_sources': request.user.webdav_sources.all(),
|
||||||
|
**oidc_context(request),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,14 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
LOGIN_URL = '/accounts/login/'
|
LOGIN_URL = '/accounts/login/'
|
||||||
LOGIN_REDIRECT_URL = '/'
|
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 = '/'
|
LOGOUT_REDIRECT_URL = '/'
|
||||||
|
|
||||||
# Last.fm
|
# Last.fm
|
||||||
|
|
|
||||||
55
docs/accounts.md
Normal file
55
docs/accounts.md
Normal 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.
|
||||||
|
|
@ -734,6 +734,19 @@ html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3
|
||||||
font-size: 0.75rem;
|
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 {
|
.auth-switch {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,19 @@ async function getOrCreateEncKey() {
|
||||||
return _encKey;
|
return _encKey;
|
||||||
} catch (e) { /* fall through, generate new */ }
|
} catch (e) { /* fall through, generate new */ }
|
||||||
}
|
}
|
||||||
// No key found — generate one and store it
|
// Just signed in with the password: the login form left the derived key (99-init.js
|
||||||
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
// moves it too, but this may run first).
|
||||||
const raw = await crypto.subtle.exportKey('raw', _encKey);
|
const pending = window.USER_ID ? localStorage.getItem('diora_pending_enc_key') : null;
|
||||||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
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;
|
return _encKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
254
static/js/app/33-unlock.js
Normal file
254
static/js/app/33-unlock.js
Normal 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(); });
|
||||||
|
})();
|
||||||
|
|
@ -41,6 +41,13 @@ async function loadBookList() {
|
||||||
try {
|
try {
|
||||||
key = await getOrCreateEncKey();
|
key = await getOrCreateEncKey();
|
||||||
} catch (e) {
|
} 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>`;
|
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v160';
|
const CACHE = 'diora-v161';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.js',
|
||||||
|
|
@ -16,6 +16,7 @@ const SHELL = [
|
||||||
'/static/js/app/29-m3u.js',
|
'/static/js/app/29-m3u.js',
|
||||||
'/static/js/app/30-contrast.js',
|
'/static/js/app/30-contrast.js',
|
||||||
'/static/js/app/32-crypto.js',
|
'/static/js/app/32-crypto.js',
|
||||||
|
'/static/js/app/33-unlock.js',
|
||||||
'/static/js/app/34-wallpaper.js',
|
'/static/js/app/34-wallpaper.js',
|
||||||
'/static/js/app/40-epub.js',
|
'/static/js/app/40-epub.js',
|
||||||
'/static/js/app/44-books.js',
|
'/static/js/app/44-books.js',
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||||
</form>
|
</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">
|
<p class="auth-switch">
|
||||||
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
|
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,25 @@
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</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;">
|
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn">Abmelden</button>
|
<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']);
|
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
// Never a new key here: it would lock the existing books away (static/js/app/33-unlock.js).
|
||||||
const raw = await crypto.subtle.exportKey('raw', key);
|
throw new Error('Bücher sind auf diesem Gerät noch nicht entsperrt – öffne zuerst die Bibliothek');
|
||||||
localStorage.setItem(storageKey, _bytesToBase64(raw));
|
|
||||||
return key;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,7 @@
|
||||||
<script src="/static/js/app/29-m3u.js"></script>
|
<script src="/static/js/app/29-m3u.js"></script>
|
||||||
<script src="/static/js/app/30-contrast.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/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/34-wallpaper.js"></script>
|
||||||
<script src="/static/js/app/40-epub.js"></script>
|
<script src="/static/js/app/40-epub.js"></script>
|
||||||
<script src="/static/js/app/44-books.js"></script>
|
<script src="/static/js/app/44-books.js"></script>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue