10.9. Authentification pour les clients programmatiques

Les jetons Bearer sont la manière dont les téléphones, les microcontrôleurs compagnons et les workers cloud s’authentifient auprès d’un serveur – le client place un jeton dans l’en-tête Authorization à chaque requête et le serveur le vérifie. L’application compagnon du téléphone envoie un POST « ack » lorsque le propriétaire appuie sur « vu » dans une notification de mouvement ; ces requêtes nécessitent un jeton.

10.9.1. Le secret de signature

Le secret est la clé privée de la caméra pour signer et vérifier les jetons. Générez 32 octets aléatoires à partir du RNG matériel lors du premier démarrage de la caméra, persistez-les dans le système de fichiers, et réutilisez les mêmes octets à chaque démarrage ultérieur :

# 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() est la source aléatoire adaptée à la cryptographie sur chaque port de caméra – sur la plupart des ports, elle lit directement le générateur de nombres aléatoires matériel de la puce. Le fichier réside dans le répertoire de travail de la caméra (/sdcard si une carte SD est montée, /flash sinon) et ne quitte jamais la caméra. Supprimer secret.bin et redémarrer fait tourner le secret et invalide tous les jetons émis sous l’ancien.

Note

machine.unique_id() n’est pas un substitut. Sur certains ports de caméra, il sert aussi à dériver l’adresse MAC réseau, ce qui signifie que sa valeur voyage avec chaque paquet que la caméra envoie – ce qui n’est pas la propriété requise pour un secret de signature.

10.9.2. Émettre des jetons

Le téléphone a d’abord besoin d’un moyen d”obtenir un jeton. Un JSON Web Token (JWT) à courte durée de vie – une charge utile JSON encodée en base64 à laquelle est ajoutée une signature – suffit pour une première approche : le propriétaire échange un mot de passe contre un jeton, puis utilise le jeton jusqu’à son expiration :

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}

Le téléphone envoie une fois un POST {user, pass}, stocke le jeton renvoyé dans le stockage local, et l’inclut sous la forme Authorization: Bearer <token> à chaque requête ultérieure.

La revendication exp est un horodatage Unix. Le vérificateur de la section suivante s’appuie sur cette revendication pour rejeter automatiquement les jetons expirés – ce qui signifie que l’horloge de la caméra doit être réglée, car sinon chaque jeton semblera soit loin dans le futur, soit déjà expiré. Voir L’heure et le NTP pour la recette de synchronisation NTP.

10.9.3. Vérifier les jetons avec TokenAuth

microdot.auth.TokenAuth est la fabrique de décorateurs pour les routes qui exigent un en-tête Authorization: Bearer <token>. La fonction de rappel de vérification reçoit la chaîne du jeton et renvoie l’objet d’identité à attacher à la requête – ou None pour rejeter :

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ève jwt.exceptions.PyJWTError (ou une sous-classe) lorsque la signature est incorrecte, que le jeton est mal formé, ou que la revendication exp est dépassée. Le vérificateur absorbe toutes ces erreurs et renvoie None, de sorte que microdot répond 401 sans que le gestionnaire ne voie quoi que ce soit.

Lorsque le vérificateur renvoie une valeur autre que None, microdot la stocke dans request.g.current_user afin que le gestionnaire puisse la lire – dans ce cas, il s’agit de la revendication sub (sujet) du JWT.

10.9.4. BasicAuth pour les réseaux fermés

Pour un point de terminaison d’administration qui n’est jamais sollicité que depuis un réseau de confiance, microdot.auth.BasicAuth est plus simple – le navigateur fait apparaître une boîte de dialogue native de nom d’utilisateur/mot de passe et les envoie dans l’en-tête Authorization: Basic ... à chaque requête :

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

Les identifiants voyagent en clair à moins que la connexion ne soit en HTTPS, donc cette approche n’a de sens que sur un réseau de confiance ou avec HTTPS en place.

/api/ack et /api/login comprennent désormais les jetons ; /admin exige une authentification Basic.