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):
|
||||
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.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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -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
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 {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue