diff --git a/accounts/oidc.py b/accounts/oidc.py index a79d001..7c4c5a8 100644 --- a/accounts/oidc.py +++ b/accounts/oidc.py @@ -130,7 +130,9 @@ def _public_key(kid: str): raise OidcError('Signaturschlüssel des Anbieters nicht gefunden') -def verify_id_token(token: str, nonce: str, now: float = None) -> dict: +def verify_id_token(token: str, nonce, now: float = None, client_id: str = None) -> dict: + """Check signature and claims. `nonce` is the expected value, or a function that says + whether the token's nonce is good (the apps' signed ones, oidc_views.app_login).""" try: header_b64, payload_b64, sig_b64 = token.split('.') header = json.loads(_b64url_decode(header_b64)) @@ -151,11 +153,12 @@ def verify_id_token(token: str, nonce: str, now: float = None) -> dict: 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]): + if (client_id or 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: + good = nonce(claims.get('nonce') or '') if callable(nonce) else (nonce and claims.get('nonce') == nonce) + if not good: raise OidcError('nonce passt nicht') if not claims.get('sub'): raise OidcError('id_token ohne sub') diff --git a/accounts/oidc_views.py b/accounts/oidc_views.py index b610988..d45365b 100644 --- a/accounts/oidc_views.py +++ b/accounts/oidc_views.py @@ -1,17 +1,22 @@ """Sign-in with Authelia and linking it to an existing account (accounts/oidc.py).""" +import json import logging +import secrets 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.core import signing +from django.http import JsonResponse from django.shortcuts import redirect, render from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme +from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from . import oidc -from .models import OidcIdentity +from .models import ApiToken, OidcIdentity log = logging.getLogger(__name__) @@ -162,3 +167,79 @@ def oidc_key(request): 'next': next_url, } return render(request, 'accounts/oidc_key.html', {'config': config}) + + +# --------------------------------------------------------------------------- +# The apps (Android, desktop): sign in with Authelia as the public client themselves +# --------------------------------------------------------------------------- +# +# 1. GET /accounts/oidc/app/ -> where Authelia is, the client id and a nonce +# signed by this server (good for APP_NONCE_AGE). +# 2. The app runs the code flow with PKCE in the system browser (redirect to its own +# scheme or loopback), trades the code itself and reads `diora_key` from userinfo. +# 3. POST /accounts/oidc/app/login/ {id_token} -> the account's API token, as the +# password login (diora-android Auth.kt) hands it out. +# The book key never passes here: the app opens the vault "authelia-key" itself. + +APP_NONCE_SALT = 'diora.oidc.app' +APP_NONCE_AGE = 600 + + +@require_http_methods(['GET']) +def app_config(request): + if not oidc.key_enabled(): + return JsonResponse({'enabled': False}) + try: + disco = oidc.discovery() + except oidc.OidcError as exc: + log.warning('oidc app config: %s', exc) + return JsonResponse({'enabled': False, 'error': 'provider unreachable'}, status=502) + return JsonResponse({ + 'enabled': True, + 'provider': settings.OIDC_PROVIDER_NAME, + 'issuer': settings.OIDC_ISSUER.rstrip('/'), + 'authorize': disco['authorization_endpoint'], + 'token': disco['token_endpoint'], + 'userinfo': disco.get('userinfo_endpoint', ''), + 'client_id': settings.OIDC_APP_CLIENT_ID, + 'scope': 'openid profile diora_key', + 'nonce': signing.dumps(secrets.token_urlsafe(16), salt=APP_NONCE_SALT), + }) + + +def _app_nonce_ok(value): + try: + signing.loads(value, salt=APP_NONCE_SALT, max_age=APP_NONCE_AGE) + return True + except signing.BadSignature: + return False + + +@csrf_exempt # a native client without cookies; the signed id_token is the proof +@require_http_methods(['POST']) +def app_login(request): + if not oidc.key_enabled(): + return JsonResponse({'error': 'not enabled'}, status=404) + try: + body = json.loads(request.body or b'{}') + id_token = body['id_token'] + if not isinstance(id_token, str): + raise TypeError + except (ValueError, KeyError, TypeError): + return JsonResponse({'error': 'id_token required'}, status=400) + try: + claims = oidc.verify_id_token(id_token, _app_nonce_ok, client_id=settings.OIDC_APP_CLIENT_ID) + except oidc.OidcError as exc: + log.warning('oidc app login: %s', exc) + return JsonResponse({'error': 'Die Anmeldung über Authelia war ungültig.'}, status=401) + identity = OidcIdentity.objects.filter( + issuer=settings.OIDC_ISSUER.rstrip('/'), sub=claims['sub']).select_related('user').first() + if identity is None: + return JsonResponse({'error': 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. ' + 'Verknüpfe es einmal im Browser unter Einstellungen.'}, status=403) + if not identity.user.is_active: + return JsonResponse({'error': 'Dieses diora-Konto ist gesperrt.'}, status=403) + OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now()) + token, _ = ApiToken.objects.get_or_create(user=identity.user) + return JsonResponse({'token': token.token, 'user_id': identity.user.id, + 'username': identity.user.username, 'sub': claims['sub']}) diff --git a/accounts/test_oidc.py b/accounts/test_oidc.py index 2900513..3d0c699 100644 --- a/accounts/test_oidc.py +++ b/accounts/test_oidc.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import User from django.test import TestCase, override_settings from . import oidc -from .models import KeyShare, OidcIdentity +from .models import ApiToken, KeyShare, OidcIdentity ISSUER = 'https://auth.example.org' PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048) @@ -199,6 +199,65 @@ class OidcFlowTests(TestCase): self.assertNotContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden') +@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret', + OIDC_APP_CLIENT_ID='diora-app') +class OidcAppLoginTests(TestCase): + def setUp(self): + oidc._cache.clear() + self.user = User.objects.create_user(username='crm', password='pw12345678') + OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + p = patch('accounts.oidc.requests.get', side_effect=fake_get) + p.start() + self.addCleanup(p.stop) + + def _nonce(self): + cfg = self.client.get('/accounts/oidc/app/').json() + self.assertEqual((cfg['client_id'], cfg['token']), ('diora-app', DISCOVERY['token_endpoint'])) + return cfg['nonce'] + + def _login(self, **claims): + c = {'iss': ISSUER, 'aud': ['diora-app'], 'sub': 'uuid-1', 'exp': time.time() + 300, + 'nonce': self._nonce()} + c.update(claims) + return self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(c)}), + content_type='application/json') + + def test_linked_account_gets_its_existing_token(self): + existing = ApiToken.objects.create(user=self.user) + resp = self._login() + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {'token': existing.token, 'user_id': self.user.id, + 'username': 'crm', 'sub': 'uuid-1'}) + + def test_token_is_made_when_there_is_none(self): + resp = self._login() + self.assertEqual(resp.json()['token'], ApiToken.objects.get(user=self.user).token) + + def test_refused(self): + for name, claims, code in [ + ('unlinked', {'sub': 'other'}, 403), + ('web client token', {'aud': 'diora'}, 401), + ('own nonce', {'nonce': 'made-up'}, 401), + ('expired', {'exp': time.time() - 3600}, 401), + ]: + with self.subTest(name): + self.assertEqual(self._login(**claims).status_code, code) + self.assertEqual(self.client.post('/accounts/oidc/app/login/', '{}', + content_type='application/json').status_code, 400) + + def test_old_nonce_is_refused(self): + nonce = self._nonce() + with patch('django.core.signing.time.time', return_value=time.time() + 3600): + resp = self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token( + {'iss': ISSUER, 'aud': 'diora-app', 'sub': 'uuid-1', 'exp': time.time() + 7200, 'nonce': nonce})}), + content_type='application/json') + self.assertEqual(resp.status_code, 401) + + def test_off_without_app_client(self): + with override_settings(OIDC_APP_CLIENT_ID=''): + self.assertEqual(self.client.get('/accounts/oidc/app/').json(), {'enabled': False}) + + PUB = base64.b64encode(b'\x04' + b'\x01' * 64).decode() diff --git a/accounts/urls.py b/accounts/urls.py index aa1c606..1ec9344 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -23,6 +23,8 @@ urlpatterns = [ path('vault//', views.vault, name='vault'), path('oidc/start/', oidc_views.oidc_start, name='oidc_start'), path('oidc/callback/', oidc_views.oidc_callback, name='oidc_callback'), + path('oidc/app/', oidc_views.app_config, name='oidc_app_config'), + path('oidc/app/login/', oidc_views.app_login, name='oidc_app_login'), path('oidc/key/', oidc_views.oidc_key, name='oidc_key'), path('oidc/key/callback/', oidc_views.oidc_key, name='oidc_key_callback'), path('oidc//unlink/', oidc_views.oidc_unlink, name='oidc_unlink'), diff --git a/docs/accounts.md b/docs/accounts.md index 09dcaa1..38cacaa 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -77,6 +77,15 @@ Einstellungsseite erzeugt ebenfalls keinen Schlüssel mehr. 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: Login im Systembrowser -als `diora-app` mit PKCE, Redirect `xyz.creamfresh.diora:/oauth`, -`xyz.creamfresh.diora.reader:/oauth` bzw. Loopback `http://127.0.0.1:/oauth` (jeder Port). +API-Token holen). Mit Authelia melden sie sich selbst als öffentlicher Client `diora-app` an: + +1. `GET /accounts/oidc/app/` sagt, wo Authelia ist, gibt die Client-ID und eine vom Server + signierte nonce (10 min gültig). +2. Die App führt den Code-Flow mit PKCE im Systembrowser aus. Redirect: + `xyz.creamfresh.diora:/oauth` bzw. `xyz.creamfresh.diora.reader:/oauth` (Android), + Loopback `http://127.0.0.1:/oauth` (Desktop, jeder Port). Den Code tauscht sie selbst + und liest `diora_key` aus userinfo. +3. `POST /accounts/oidc/app/login/ {id_token}` prüft das Token (`aud` = `diora-app`, nonce) und + gibt den API-Token des verknüpften Kontos heraus (den vorhandenen, kein neuer). +4. Die App holt mit dem Token `/accounts/vault/authelia-key/` und öffnet den Bücher-Schlüssel + wie die Webseite (HKDF, Salt `sub`). Ist dort noch nichts, fragt sie nach dem diora-Passwort. diff --git a/static/js/sw.js b/static/js/sw.js index fe342cd..48403f1 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v162'; +const CACHE = 'diora-v163'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',