diora-web/accounts/oidc.py
mrwnslz 04548171bf
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m35s
Test / e2e (push) Failing after 1m29s
Anmeldung mit Authelia für die Apps: Server gibt nach geprüftem Authelia-Login den App-Zugang heraus (sw CACHE v163)
Android und Desktop melden sich selbst bei Authelia an. Mit dem
geprüften Ergebnis bekommen sie von diora den Zugang des verknüpften
Kontos, so wie bisher nach der Anmeldung mit Passwort. Den Bücher-Schlüssel
öffnen sie selbst, der Server sieht ihn nicht. Details: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:12:43 +01:00

165 lines
6.2 KiB
Python

"""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 key_enabled() -> bool:
return enabled() and bool(settings.OIDC_APP_CLIENT_ID)
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, 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))
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 (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')
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')
return claims