10.9. Аутентификация для программных клиентов

Токены Bearer — это способ, которым телефоны, сопутствующие микроконтроллеры и облачные обработчики проходят аутентификацию на сервере: клиент помещает токен в заголовок Authorization в каждом запросе, а сервер его проверяет. Сопутствующее приложение на телефоне отправляет POST с «подтверждением», когда владелец нажимает «просмотрено» в уведомлении о движении; этим запросам нужен токен.

10.9.1. Секрет подписи

Секрет — это закрытый ключ камеры для подписи и проверки токенов. Сгенерируйте 32 случайных байта из аппаратного RNG при первой загрузке камеры, сохраните их в файловой системе и повторно используйте те же байты при каждой последующей загрузке:

# 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) — JSON-нагрузки в кодировке base64 с приложенной подписью — достаточно для первого подхода: владелец обменивает пароль на токен, затем использует токен, пока тот не истечёт:

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, чтобы обработчик мог его прочитать, — в данном случае это утверждение JWT sub (субъект).

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-аутентификации.