10.9. Авторизація для програмних клієнтів

Токени-носії — це спосіб, яким телефони, супутні мікроконтролери та хмарні обробники автентифікуються на сервері: клієнт передає токен у заголовку Authorization при кожному запиті, а сервер його перевіряє. Супутній додаток на телефоні надсилає POST з підтвердженням «ack», коли власник натискає «переглянуто» на сповіщенні про рух; ці запити потребують токена.

10.9.1. Секрет підпису

Секрет — це приватний ключ камери для підписання та верифікації токенів. Згенеруйте 32 випадкові байти з апаратного генератора випадкових чисел камери при першому завантаженні, збережіть їх у файловій системі та повторно використовуйте ті самі байти при кожному наступному завантаженні:

# 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() є криптографічно придатним джерелом випадкових чисел на кожному порті камери — на більшості портів він безпосередньо зчитує апаратний генератор випадкових чисел чипа. Файл зберігається у робочому каталозі камери (/sdcard, якщо вставлена SD-картка, або /flash інакше) і ніколи не залишає камеру. Видалення secret.bin та перезавантаження ротує секрет і анулює всі токени, видані під старим.

Примітка

machine.unique_id() не є заміною. На деяких портах камери він також використовується для отримання мережевої MAC-адреси, що означає, що його значення передається з кожним пакетом, відправленим камерою — а це не та властивість, яку повинен мати секрет підпису.

10.9.2. Видача токенів

Телефону спочатку потрібен спосіб отримати токен. JSON Web Token (JWT) з коротким терміном дії — кодоване в base64 JSON-навантаження з доданим підписом — достатньо для першого проходу: власник обмінює пароль на токен, а потім використовує токен до його закінчення:

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}

Телефон один раз надсилає POST з {user, pass}, зберігає повернений токен у локальному сховищі та включає його як Authorization: Bearer <token> у кожному наступному запиті.

Поле exp — це Unix-мітка часу. Верифікатор у наступному розділі покладається на це поле для автоматичного відхилення прострочених токенів — а це означає, що годинник камери має бути встановлений, інакше кожен токен виглядатиме або далеко в майбутньому, або вже простроченим. Дивіться Час та NTP для рецепту синхронізації NTP.

10.9.3. Перевірка токенів за допомогою TokenAuth

microdot.auth.TokenAuth — це фабрика декораторів для маршрутів, що вимагають заголовка Authorization: Bearer <token>. Зворотний виклик верифікатора отримує рядок токена та повертає будь-який об’єкт ідентичності, який слід прикріпити до запиту, або None для відхилення:

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() генерує виняток jwt.exceptions.PyJWTError (або підклас) коли підпис неправильний, токен має невірний формат або поле exp минуло. Верифікатор перехоплює всі ці випадки та повертає None, тому microdot відповідає 401, і обробник нічого не бачить.

Коли верифікатор повертає значення, відмінне від None, microdot зберігає його в request.g.current_user, щоб обробник міг його прочитати — у цьому випадку це поле sub (тема) JWT.

10.9.4. BasicAuth для закритих мереж

Для адміністративного кінцевої точки, до якої звертаються лише з довіреної мережі, microdot.auth.BasicAuth є простішим варіантом — браузер показує нативний діалог введення імені користувача/пароля та надсилає їх у заголовку Authorization: Basic ... при кожному запиті:

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

Облікові дані передаються у відкритому вигляді, якщо з’єднання не є HTTPS, тому цей підхід має сенс лише у довіреній мережі або за наявності HTTPS.

/api/ack та /api/login тепер розуміють токени; /admin вимагає Basic-автентифікації.