Генераторы токенов на Python
Готовые скрипты выпуска токенов для каждого механизма авторизации — с пресетами, комментариями и командой проверки.
Встроенного генератора токенов зрителя в продукте нет: ни эндпоинта /api/v1, ни кнопки в панели.
Токены выпускает ваш код. Ниже — рабочие скрипты, которые можно запускать как есть.
| Механизм | Нужен ли генератор | Почему |
|---|---|---|
hs256 |
да, § ниже | токен подписывается общим секретом |
jwks |
да, § ниже | токен подписывается вашим приватным ключом |
internal |
нет | «токен» — статическая строка из конфигурации; подписывать нечего |
http |
нет | формат придумывает ваша служба; сервер пробрасывает предъявленное как есть |
билет ?ssid= |
невозможен и не нужен | выпускается сервером и вплетается в плейлист |
| медиа-грант панели | невозможен снаружи | подписывается внутренним ключом, выпускается только через POST /api/v1/auth/media-grants |
Пресеты live, ip, label прогнаны на стенде целиком: печатаемая ссылка отвечает 200, тот же
путь без токена — 401. Пресет publish проверен реальной публикацией — кодировщик принят.
Пресет archive проверен на стенде без архива: разграничение действий подтверждено (тем же токеном
живой плейлист отвечает 403 — playback не даёт живого просмотра); 200 на плейлисте таймшифта
и на выгрузке клипа получены на стенде с настроенным DVR.
hs256 — без внешних зависимостей#
Сохраните как hs_token.py. Нужен только Python 3.8 и стандартная библиотека.
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Генератор токенов для SirinVideo, бэкенд `auth_backend { type hs256; }`. Зависимостей нет — только стандартная библиотека Python 3.8+. Правьте блок ПАРАМЕТРЫ и запускайте: скрипт печатает готовую ссылку и команду проверки. """ # ─────────────────────────── ПАРАМЕТРЫ ─────────────────────────── SECRET = "секрет-подписи-токенов-зрителей" # ровно то же значение, что в auth_backend { secret … } BASE_URL = "http://media.example.com:8080" # адрес сервера STREAM = "cam" # путь потока БЕЗ ведущего слэша MINUTES = 15 # срок жизни токена PRESET = "live" # live | ip | label | archive | publish CLIENT_IP = "203.0.113.7" # для PRESET="ip": адрес зрителя CLIENT_NET = ["203.0.113.0/24"] # для PRESET="ip": допустимые сети LABELS = ["site-north"] # для PRESET="label": метки, ТОЧНОЕ совпадение ISSUER = None # заполнять, только если задан auth_backend { issuer … } AUDIENCE = None # заполнять, только если задан auth_backend { audience … } PERMS_CLAIM = "grants" # auth_backend { perms_claim … }, по умолчанию "grants" # ───────────────────────────────────────────────────────────────── import base64 import hashlib import hmac import json import time def b64(raw: bytes) -> str: """base64url без выравнивания — кодировка частей компактного JWS.""" return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def mint(secret: str, payload: dict) -> str: """Собрать и подписать JWT. Алгоритм жёстко HS256: сервер сверяет строку `HS256` регистрозависимо и не принимает ни `none`, ни подмену типа ключа.""" header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) body = b64(json.dumps(payload, separators=(",", ":")).encode()) signing = f"{header}.{body}".encode() return f"{header}.{body}.{b64(hmac.new(secret.encode(), signing, hashlib.sha256).digest())}" def label_from_uuid(text: str) -> str: """uuid с дефисами → каноническая 22-символьная метка, как её хранит сервер. Метки внутри JWT НЕ приводятся к канонической форме автоматически — писать нужно результат этой функции, иначе ответ будет 403.""" return base64.urlsafe_b64encode(bytes.fromhex(text.replace("-", ""))).rstrip(b"=").decode() now = int(time.time()) # `sub` — субъект: попадает в учёт сессии и сворачивается в билет. Необязателен. # `exp` — АБСОЛЮТНЫЙ момент истечения в unix-секундах. Сервер отвергает при exp <= now, # БЕЗ допуска на расхождение часов. Если поле опустить — токен станет бессрочным. payload = {"sub": "viewer-42", "exp": now + MINUTES * 60} if ISSUER: payload["iss"] = ISSUER # проверяется, только если issuer настроен на сервере if AUDIENCE: payload["aud"] = AUDIENCE # строка либо список строк clip_path = None if PRESET == "live": # Минимум: смотреть ОДИН живой поток по HLS. # action=read — живой просмотр (playback = архив, publish = публикация) # path — точный путь потока; `*` = все потоки; допустим глоб вида `cam*` # protocols — сузить токен до перечисленных протоколов; поля нет = все payload[PERMS_CLAIM] = [{"action": "read", "path": STREAM, "protocols": ["hls"]}] check_path = f"/{STREAM}/index.m3u8" elif PRESET == "ip": # То же плюс привязка к адресу. Два независимых механизма, можно вместе: # ip — ровно один адрес; запрос с другого → 403 # net — список CIDR (не более 32); достаточно попасть в один # За обратным прокси адрес берётся из X-Forwarded-For ТОЛЬКО если прокси перечислен # в `trusted_proxies`, иначе привяжется адрес самого прокси. payload["ip"] = CLIENT_IP payload["net"] = CLIENT_NET payload[PERMS_CLAIM] = [{"action": "read", "path": STREAM, "protocols": ["hls"]}] check_path = f"/{STREAM}/index.m3u8" elif PRESET == "label": # Доступ к ГРУППЕ потоков по меткам. Сравнение — точная строка: ни глоба, ни префикса, # ни регистронезависимости. Поле `path` можно опустить вовсе. payload[PERMS_CLAIM] = [{"action": "read", "labels": LABELS, "protocols": ["hls"]}] check_path = f"/{STREAM}/index.m3u8" elif PRESET == "archive": # Архив и выгрузка клипа. Одного `playback` с совпавшим путём хватает на оба, потому что # путь совпал. Чтобы выдать ТОЛЬКО экспорт без права смотреть — нужен компактный грант # по метке с маской 10 (см. страницу «Метки потоков»). payload[PERMS_CLAIM] = [{"action": "playback", "path": STREAM, "protocols": ["hls"]}] check_path = f"/{STREAM}/timeshift-{now - 30}.m3u8" clip_path = f"/{STREAM}/dvr/clip?from={(now - 30) * 1000}&to={(now - 5) * 1000}" elif PRESET == "publish": # Публикация. Действие publish — отдельное: токен на просмотр публиковать не даёт. # protocols: rtmp для RTMP/RTMPS, whip для WebRTC-публикации. payload[PERMS_CLAIM] = [{"action": "publish", "path": STREAM, "protocols": ["rtmp", "whip"]}] check_path = None else: raise SystemExit(f"неизвестный PRESET: {PRESET}") token = mint(SECRET, payload) print("payload:", json.dumps(payload, ensure_ascii=False)) print("token :", token) print() if PRESET == "publish": rtmp_host = BASE_URL.split("//", 1)[-1].split(":", 1)[0] print("Адрес для кодировщика (порт — из директивы `rtmp`):") print(f" rtmp://{rtmp_host}:1935/{STREAM}?token={token}") else: print("Готовая ссылка на просмотр:") print(f"{BASE_URL}{check_path}?token={token}") print() print("Проверка (ожидается 200):") print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' \"{BASE_URL}{check_path}?token={token}\"") print("То же через заголовок:") print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' -H 'Authorization: Bearer {token}' {BASE_URL}{check_path}") if clip_path: print() print("Выгрузка клипа тем же токеном:") print(f" curl -o clip.mp4 \"{BASE_URL}{clip_path}&token={token}\"") print() print("Дальше плеер обязан идти по ссылкам ИЗ ПЛЕЙЛИСТА — в них уже подставлен ?ssid=.") print("Прямой запрос сегмента с ?token= вернёт 401: сегменты проверяются только билетом.")
Как убедиться, что токен принят#
python3 hs_token.py # печатает готовую ссылку curl -s -o /dev/null -w '%{http_code}\n' "<напечатанная ссылка>" curl -s -o /dev/null -w '%{http_code}\n' \ http://media.example.com:8080/cam/index.m3u8 # контроль: тот же путь без токена
200 401
Проверенные результаты по пресетам: live → 200, ip → 200, label → 200,
archive → 200 на плейлисте таймшифта и 200 на выгрузке клипа, publish → кодировщик принят.
403 на всех пресетах сразу — сверьте секретТокен подписан верно с точки зрения скрипта, но сервер его не признаёт: значение SECRET
обязано побайтово совпадать с auth_backend { secret … }. Кавычки в конфигурации в значение
не входят, а лишний пробел — входит.
jwks — на PyJWT и cryptography#
pip install "PyJWT>=2" "cryptography>=41"
Сохраните как jwks_token.py. При первом запуске скрипт создаст idp.pem и jwks.json; отдайте
jwks.json по HTTP по адресу из auth_backend { url … } — для проверки достаточно
python3 -m http.server.
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Генератор токенов для SirinVideo, бэкенд `auth_backend { type jwks; }`. Зависимости: pip install "PyJWT>=2" "cryptography>=41" Скрипт делает две вещи: 1) при первом запуске создаёт ключевую пару и файл jwks.json — его надо отдавать по HTTP по адресу из auth_backend { url … }; 2) подписывает токен зрителя приватным ключом. """ # ─────────────────────────── ПАРАМЕТРЫ ─────────────────────────── KEY_FILE = "idp.pem" # приватный ключ, создаётся автоматически JWKS_FILE = "jwks.json" # публичный набор ключей для auth_backend { url … } KID = "idp-1" # идентификатор ключа, попадает в заголовок токена ALG = "RS256" # RS256 (RSA-2048) или ES256 (P-256) — иное сервер не примет BASE_URL = "http://media.example.com:8080" STREAM = "cam" MINUTES = 15 PRESET = "live" # live | ip | label | archive | publish CLIENT_IP = "203.0.113.7" CLIENT_NET = ["203.0.113.0/24"] LABELS = ["site-north"] ISSUER = "https://idp.example" # должен совпасть с auth_backend { issuer … }, если он задан AUDIENCE = "sirinvideo" # должен совпасть с auth_backend { audience … }, если он задан PERMS_CLAIM = "grants" # ───────────────────────────────────────────────────────────────── import base64 import json import os import time import jwt as pyjwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa def b64u_int(value: int) -> str: raw = value.to_bytes((value.bit_length() + 7) // 8, "big") return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def b64u_bytes(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def label_from_uuid(text: str) -> str: """uuid с дефисами → каноническая 22-символьная метка, как её хранит сервер.""" return base64.urlsafe_b64encode(bytes.fromhex(text.replace("-", ""))).rstrip(b"=").decode() # ── 1. ключ и набор ключей ─────────────────────────────────────────────────── if not os.path.exists(KEY_FILE): key = (rsa.generate_private_key(public_exponent=65537, key_size=2048) if ALG == "RS256" else ec.generate_private_key(ec.SECP256R1())) with open(KEY_FILE, "wb") as f: f.write(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) print(f"создан приватный ключ {KEY_FILE}") private_pem = open(KEY_FILE).read() key = serialization.load_pem_private_key(private_pem.encode(), password=None) if ALG == "RS256": numbers = key.public_key().public_numbers() jwk = {"kty": "RSA", "kid": KID, "use": "sig", "alg": "RS256", "n": b64u_int(numbers.n), "e": b64u_int(numbers.e)} else: numbers = key.public_key().public_numbers() jwk = {"kty": "EC", "kid": KID, "use": "sig", "alg": "ES256", "crv": "P-256", "x": b64u_bytes(numbers.x.to_bytes(32, "big")), "y": b64u_bytes(numbers.y.to_bytes(32, "big"))} with open(JWKS_FILE, "w") as f: json.dump({"keys": [jwk]}, f, indent=2) print(f"записан {JWKS_FILE} — отдайте его по адресу из auth_backend {{ url … }}") # ── 2. токен ───────────────────────────────────────────────────────────────── now = int(time.time()) payload = {"sub": "viewer-42", "iat": now, "exp": now + MINUTES * 60} if ISSUER: payload["iss"] = ISSUER if AUDIENCE: payload["aud"] = AUDIENCE if PRESET == "live": payload[PERMS_CLAIM] = [{"action": "read", "path": STREAM, "protocols": ["hls"]}] check_path = f"/{STREAM}/index.m3u8" elif PRESET == "ip": payload["ip"] = CLIENT_IP # ровно один адрес payload["net"] = CLIENT_NET # либо список CIDR, не более 32 payload[PERMS_CLAIM] = [{"action": "read", "path": STREAM, "protocols": ["hls"]}] check_path = f"/{STREAM}/index.m3u8" elif PRESET == "label": # Метка сравнивается ТОЧНОЙ строкой. uuid из конфигурации сервер привёл к 22-символьной # форме — используйте label_from_uuid(), а не запись с дефисами. payload[PERMS_CLAIM] = [{"action": "read", "labels": LABELS, "protocols": ["hls"]}] check_path = f"/{STREAM}/index.m3u8" elif PRESET == "archive": payload[PERMS_CLAIM] = [{"action": "playback", "path": STREAM, "protocols": ["hls"]}] check_path = f"/{STREAM}/timeshift-{now - 30}.m3u8" elif PRESET == "publish": payload[PERMS_CLAIM] = [{"action": "publish", "path": STREAM, "protocols": ["rtmp", "whip"]}] check_path = None else: raise SystemExit(f"неизвестный PRESET: {PRESET}") # `kid` в заголовке на практике обязателен: без него сервер переберёт все ключи подходящего # алгоритма, а неизвестный kid дополнительно вызывает досрочное обновление набора. token = pyjwt.encode(payload, private_pem, algorithm=ALG, headers={"kid": KID}) print("payload:", json.dumps(payload, ensure_ascii=False)) print("token :", token) print() if check_path: print("Готовая ссылка на просмотр:") print(f"{BASE_URL}{check_path}?token={token}") print() print("Проверка (ожидается 200):") print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' \"{BASE_URL}{check_path}?token={token}\"") else: rtmp_host = BASE_URL.split("//", 1)[-1].split(":", 1)[0] print("Адрес для кодировщика (порт — из директивы `rtmp`):") print(f" rtmp://{rtmp_host}:1935/{STREAM}?token={token}")
Как убедиться, что токен принят#
python3 -m http.server 18999 --bind 127.0.0.1 & # раздаём jwks.json python3 jwks_token.py curl -s -o /dev/null -w '%{http_code}\n' "<напечатанная ссылка>"
200
В журнале сервера обязана появиться строка о загрузке набора ключей:
journalctl -u hastreamer | grep 'jwks:'
[authio] jwks: loaded 1 key(s) from https://idp.example
Если её нет, сервер отвергнет любой токен: на пустом кэше ключей действует отказ по умолчанию.
http — минимальная служба авторизации#
Здесь генератор не нужен: формат токена придумываете вы, сервер лишь пересылает предъявленное. Ниже — служба-заглушка на стандартной библиотеке, на которой снят точный формат запроса.
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Минимальная служба авторизации для `auth_backend { type http; url … }`. Только стандартная библиотека. Запуск: python3 authd.py """ import http.server import json LISTEN = ("127.0.0.1", 9099) class Handler(http.server.BaseHTTPRequestHandler): def do_POST(self): size = int(self.headers.get("content-length", "0")) req = json.loads(self.rfile.read(size) or b"{}") # req содержит: ip, user, password, token, action, privilege, path, # labels, protocol, query, user_agent allowed = req.get("token") == "secret-string" and req.get("path") == "cam" if not allowed: # 403 — «авторизация отвергнута». 401 означало бы «учётные данные не приняты» # и кормило бы счётчик неудачных попыток по адресу клиента. self.send_response(403) self.send_header("content-length", "0") self.end_headers() return # Пустое тело с кодом 2xx = «разрешить ровно запрошенную пару действие + путь». # JSON уточняет грант; ttl_secs становится сроком жизни билета сессии. body = json.dumps({"sub": "viewer-42", "ttl_secs": 120}).encode() self.send_response(200) self.send_header("content-type", "application/json") self.send_header("content-length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt, *args): pass # запросов много — не засоряем консоль http.server.HTTPServer(LISTEN, Handler).serve_forever()
Подключение:
auth_backend { type http; url http://127.0.0.1:9099/authorize; }
Тело, которое служба получит на живой просмотр и на приём публикации по RTMP (снято с этой самой заглушки):
{"ip":"203.0.113.7","user":"","password":"","token":"secret-string","action":"read", "privilege":"live","path":"cam","labels":["site-north"],"protocol":"hls", "query":"token=secret-string","user_agent":"curl/8.18.0"}
{"ip":"203.0.113.7","user":"","password":"","token":"secret-string","action":"publish", "privilege":"publish","path":"cam","labels":[],"protocol":"rtmp", "query":"token=secret-string","user_agent":""}
Как убедиться, что служба работает#
curl -s -o /dev/null -w '%{http_code}\n' \ "http://media.example.com:8080/cam/index.m3u8?token=secret-string" curl -s -o /dev/null -w '%{http_code}\n' \ "http://media.example.com:8080/cam/index.m3u8?token=nope" # теперь остановите службу и повторите первый запрос curl -s -o /dev/null -w '%{http_code}\n' \ "http://media.example.com:8080/cam/index.m3u8?token=secret-string"
200 403 503
Все три исхода проверены. 503 — это отказ по недоступности службы: недоступная служба
авторизации закрывает выдачу, а не открывает её. Закладывайте это в план обслуживания.
internal — генератора нет, есть конфигурация#
auth_backend { type internal; } principal svc { token s3cr3t-service-token-value; permission read cam hls; } principal alice { pass hunter2; permission read cam; }
curl -s -o /dev/null -w '%{http_code}\n' \ "http://media.example.com:8080/cam/index.m3u8?token=s3cr3t-service-token-value" curl -s -o /dev/null -w '%{http_code}\n' -u alice:hunter2 \ http://media.example.com:8080/cam/index.m3u8
200 200
Пароль можно хранить хешем: pass sha256:<hex>. Срока действия у принципала нет — отзыв только
правкой конфигурации.
Что делать дальше#
Выпущенный токен подставляется в плейлист, а не в сегмент. Дальше плеер идёт по ссылкам из
плейлиста, в которые сервер уже вплёл ?ssid= — подробности в Модели доступа и
Авторизации просмотра.