10.9. Authentifizierung für programmatische Clients

Bearer-Tokens sind die Art und Weise, wie sich Telefone, begleitende Mikrocontroller und Cloud-Worker bei einem Server authentifizieren – der Client legt bei jeder Anfrage einen Token in den Authorization-Header, und der Server prüft ihn. Die begleitende Telefon-App sendet ein „ack“ per POST, wenn der Besitzer bei einer Bewegungsbenachrichtigung auf „gesehen“ tippt; diese Anfragen benötigen einen Token.

10.9.1. Das Signaturgeheimnis

Das Geheimnis ist der private Schlüssel der Cam zum Signieren und Verifizieren von Tokens. Erzeugen Sie beim ersten Start der Cam 32 zufällige Bytes aus dem Hardware-RNG, speichern Sie sie dauerhaft im Dateisystem und verwenden Sie bei jedem weiteren Start dieselben Bytes erneut:

# auth/tokens.py
import os

try:
    with open('secret.bin', 'rb') as f:
        SECRET = f.read()
except OSError:
    SECRET = os.urandom(32)
    with open('secret.bin', 'wb') as f:
        f.write(SECRET)

os.urandom() ist die kryptografisch geeignete Zufallsquelle auf jedem Cam-Port – auf den meisten Ports liest sie direkt den Hardware-Zufallszahlengenerator des Chips. Die Datei befindet sich im Arbeitsverzeichnis der Cam (/sdcard, falls eine SD-Karte eingebunden ist, andernfalls /flash) und verlässt die Cam nie. Das Löschen von secret.bin und ein Neustart rotieren das Geheimnis und machen jeden unter dem alten Geheimnis ausgestellten Token ungültig.

Bemerkung

machine.unique_id() ist kein Ersatz. Auf einigen Cam-Ports wird es auch zur Ableitung der Netzwerk-MAC-Adresse verwendet, was bedeutet, dass sein Wert mit jedem Paket reist, das die Cam sendet – nicht die Eigenschaft, die ein Signaturgeheimnis braucht.

10.9.2. Tokens ausstellen

Das Telefon braucht überhaupt erst eine Möglichkeit, einen Token zu erhalten. Ein kurzlebiges JSON Web Token (JWT) – eine base64-kodierte JSON-Nutzlast mit angehängter Signatur – genügt für einen ersten Ansatz: Der Besitzer tauscht ein Passwort gegen einen Token und verwendet den Token dann, bis er abläuft:

import jwt
import time

@app.post('/api/login')
async def api_login(request):
    creds = request.json
    if creds.get('user') != 'owner' or creds.get('pass') != load_password():
        abort(401)
    token = jwt.encode({
        'sub': 'owner',
        'exp': int(time.time()) + 3600,
    }, SECRET)
    return {'token': token}

Das Telefon sendet einmal {user, pass} per POST, speichert den zurückgegebenen Token im lokalen Speicher und fügt ihn bei jeder weiteren Anfrage als Authorization: Bearer <token> hinzu.

Der exp-Claim ist ein Unix-Zeitstempel. Der Verifizierer im nächsten Abschnitt verlässt sich auf diesen Claim, um abgelaufene Tokens automatisch abzulehnen – was bedeutet, dass die Echtzeituhr der Cam gestellt sein muss, denn andernfalls erscheint jeder Token entweder weit in der Zukunft oder bereits abgelaufen. Siehe Zeit und NTP für das Rezept zur NTP-Synchronisation.

10.9.3. Tokens mit TokenAuth verifizieren

microdot.auth.TokenAuth ist die Decorator-Factory für Routen, die einen Authorization: Bearer <token>-Header erfordern. Der Verifizierer-Callback erhält die Token-Zeichenkette und gibt das Identitätsobjekt zurück, das an die Anfrage angehängt werden soll – oder None, um abzulehnen:

from microdot.auth import TokenAuth

tokens = TokenAuth()

@tokens.authenticate
async def check_token(request, token):
    try:
        claims = jwt.decode(token, SECRET)
    except jwt.exceptions.PyJWTError:
        return None
    return claims['sub']

@app.post('/api/ack')
@tokens
async def ack(request):
    body = request.json
    if not body or 'count' not in body:
        abort(400, 'missing count')
    return {'ok': True, 'user': request.g.current_user}

jwt.decode() löst jwt.exceptions.PyJWTError (oder eine Unterklasse) aus, wenn die Signatur falsch ist, der Token fehlerhaft ist oder der exp-Claim verstrichen ist. Der Verifizierer fängt all diese ab und gibt None zurück, sodass microdot mit 401 antwortet, ohne dass der Handler etwas davon mitbekommt.

Wenn der Verifizierer einen von None verschiedenen Wert zurückgibt, speichert microdot ihn in request.g.current_user, sodass der Handler ihn lesen kann – in diesem Fall ist es der sub-Claim (Subject) des JWT.

10.9.4. BasicAuth für geschlossene Netzwerke

Für einen Admin-Endpunkt, der nur jemals aus einem vertrauenswürdigen Netzwerk angesprochen wird, ist microdot.auth.BasicAuth einfacher – der Browser öffnet einen nativen Dialog für Benutzername/Passwort und sendet sie bei jeder Anfrage im Authorization: Basic ...-Header:

from microdot.auth import BasicAuth

basic = BasicAuth(realm='Backyard cam admin')

@basic.authenticate
async def check_basic(request, username, password):
    if username == 'owner' and password == load_password():
        return 'owner'
    return None

@app.get('/admin')
@basic
async def admin(request):
    return 'hi ' + request.g.current_user

Die Anmeldedaten werden im Klartext übertragen, sofern die Verbindung nicht HTTPS ist, daher ist dieser Ansatz nur in einem vertrauenswürdigen Netzwerk oder mit vorhandenem HTTPS sinnvoll.

/api/ack und /api/login verstehen nun Tokens; /admin erfordert Basic-Authentifizierung.