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>
165 lines
6.2 KiB
Python
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
|