Лицензирование и авторизация: как мы закрывали SCADA-систему от посторонних и наступили на все грабли подряд
Путь от «давайте просто захардкодим ключ» до offline-валидации лицензий на RSA-2048, ролевой модели на 4 уровня и 5 эпичных багов, которые мы собрали по дороге. Реальный код из нашего репозитория включён.
для тех, кто не любит лонгриды
Мы довели до продакшена две вещи, без которых систему нельзя продавать:
- Систему лицензирования — offline-валидация файлов
.licна RSA-2048, 4 типа лицензий, 3 дня льготного периода и лимит одновременных сессий. - Авторизацию — 4 роли (
admin/engineer/operator/boss), JWT-токены и bcrypt-хэши паролей.
Внутри:
- Почему именно RSA, а не симметричный ключ
- Как устроены сессии
start / heartbeat / endи зачем они вообще нужны - Ролевая модель и матрица доступов
- 5 эпичных граблей, включая ту, где мы несколько часов искали причину ошибки 422, а виноват оказался… потерянный заголовок
Content-Typeв HTTP-клиенте
Ниже — полный разбор с реальным кодом из нашего репозитория.
Часть 1. Проблема, которую откладывали до последнего
Представьте: у вас готов продукт. Модули работают, графики красивые, ИИ-ассистент отвечает на вопросы оператора. Всё хорошо. И тут вы понимаете, что систему надо продавать.
А для этого нужно ответить на два неудобных вопроса:
Вопрос первый: как защитить продукт?
Сейчас система работает как есть — поставил и пользуешься. Но клиент купит её на 5 рабочих мест, а поставит на 50. Или вообще скопирует и раздаст. Нужен механизм контроля.
Вопрос второй: кто кому что может?
В реальности на объекте работают разные люди:
- Администратор, который настраивает систему
- Инженер, который копается в анализах
- Оператор, который просто смотрит на показания
- Руководитель, который хочет видеть сводку и ничего не трогать
Давать всем всё — нельзя. Оператор не должен случайно выключить модуль. Руководитель не должен видеть чужие логины.
Классический ответ: «прикрутим какую-нибудь готовую систему». Но у нас было жёсткое требование:
Система должна работать в закрытом контуре, без интернета, с минимумом внешних зависимостей.
То есть никаких «а давайте запросим ключ на наш сервер» или «поднимем отдельную базу для лицензий». Всё должно быть самодостаточно и работать офлайн.
Из этого выросли два подпроекта:
- Лицензирование — проверка подписанного файла лицензии прямо на бэкенде.
- Авторизация — пользователи, роли, токены.
Часть 2. Архитектура лицензирования
Первая версия была наивной: файл с ключом, который просто читается и сравнивается. Проработала она ровно до момента, когда мы задались вопросом: «А что мешает любому человеку открыть этот файл и поменять значение?».
Ответ: ничего. Значит, нужен механизм, где создатель лицензии и потребитель не делят один секрет.
Почему RSA-2048, а не симметричный ключ
В симметричной схеме (один общий ключ) этот ключ нужно хранить на клиенте. А значит, его можно достать и подделать лицензию.
В асимметричной схеме ключей два:
- Приватный ключ — только у нас. Им мы подписываем лицензии.
- Публичный ключ — вшивается в систему. Им она проверяет подпись.
Клиент может проверить, что лицензия настоящая. Но подделать её не может, потому что у него нет приватного ключа.
Схема:
[Наши сервера] [Система клиента]
приватный ключ публичный ключ
| |
| подписываем .lic | проверяем подпись
v v
license.lic -----------------------> валидно / не валидно Это стандартная схема подписи. Мы взяли RSA-2048 — достаточно стойкий и повсеместно поддерживаемый алгоритм.
Типы лицензий и грейс-период
Мы выделили 4 уровня:
| Тип | Модули | Макс. пользователей |
|---|---|---|
trial | hello, health | 1 |
basic | + logs, energy_* | 3 |
standard | + analytics, deep_analysis | 10 |
enterprise | все модули | 100+ |
И добавили грас-период: если лицензия истекла, система не падает мгновенно, а даёт 3 дня на продление, показывая предупреждения в интерфейсе. Это человеческое решение — не наказывать оператора за то, что бухгалтерия не успела оплатить.
После грас-периода все запросы начинают возвращать 402 (забавный статус: «требуется оплата» — он как будто создан для этого случая).
Файловая структура
backend/
├── core/
│ ├── license/
│ │ ├── models.py # License, LicenseType (Enum)
│ │ ├── validator.py # RSA-2048 валидация подписи
│ │ ├── manager.py # LicenseManager (загрузка, проверка)
│ │ └── session_tracker.py # SessionTracker (in-memory сессии)
│ ├── middleware/
│ │ └── license.py # LicenseMiddleware
│ └── auth/ # про него — ниже
│
└── scripts/
├── generate_keys.py # генерация пары ключей
└── generate_license.py # генерация файлов .lic Часть 3. Криптография под капотом
Генерация лицензии
Приватным ключом мы подписываем полезную нагрузку лицензии. Внутри — тип лицензии, сроки, лимиты, имя клиента.
# scripts/generate_license.py (упрощённо)
payload = {
"license_type": "standard",
"customer": "ООО "Промздание"",
"issued_at": datetime.now().isoformat(),
"expires_at": (datetime.now() + timedelta(days=365)).isoformat(),
"max_concurrent_users": 10,
"modules": ["hello", "health", "analytics", "deep_analysis"],
}
token = sign_with_private_key(payload) # RSA-2048 подпись
Path("license.lic").write_text(token) Валидация на клиенте
Система при старте и на каждом запросе проверяет подпись публичным ключом и разбирает токен.
# core/license/validator.py (упрощённо)
def validate_license(token: str, public_key: str) -> License | None:
try:
payload = verify_signature(token, public_key) # RSA-2048
if not payload:
return None
return License(**payload)
except Exception:
# Любая ошибка = невалидная лицензия. Никогда не падаем.
return None Обратите внимание на философию: валидатор никогда не бросает исключение наружу. Любая ошибка превращается в None. Это осознанный приём — мы уже обожглись на том, что необработанный эксепшен в середине проверки лицензии ронял весь запрос.
JWT для пользователей
Для сессий пользователей мы взяли JWT. Токен содержит минимум: кто это и какая у него роль.
access_token = create_access_token(
data={"sub": user.username, "role": user.role.value}
) Пароли, естественно, не храним в открытом виде — только bcrypt-хэши в users.json.
Часть 4. Сессии: зачем они нужны
«Постойте, — спросите вы, — зачем какие-то сессии, если у нас уже есть лицензия?»
Лицензия говорит: «одновременно не больше 10 пользователей». Но чтобы это проверить, нужно знать, сколько пользователей сейчас активно.
Отсюда растут сессии. Схема классическая:
start— пользователь вошёл, создаём сессию, отдаёмsession_idheartbeat— каждые 30 секунд фронтенд стучится и говорит «я ещё здесь»end— пользователь вышел, сессию закрываем
[Фронтенд] [Бэкенд]
| |
|--- session/start ------------>| +1 активная сессия
|<-- session_id ----------------|
| |
|--- heartbeat (каждые 30с) --->| продлить сессию
| |
|--- session/end -------------->| -1 активная сессия Хранение сделали in-memory — простой словарь в памяти процесса. Да, мы знаем про его ограничения (об этом в граблях ниже), но для начала это было правильное решение: ноль внешних зависимостей, мгновенная работа.
Сессии в коде
# core/license/session_tracker.py (упрощённо)
class SessionTracker:
def __init__(self):
self.sessions = {} # session_id -> метаданные
self.inactivity_timeout = 30 * 60 # 30 минут тишины = сброс
def start_session(self) -> str:
if len(self.sessions) >= self.max_concurrent_users:
raise LicenseLimitError("Лимит одновременных подключений исчерпан")
sid = str(uuid4())
self.sessions[sid] = {"last_seen": now()}
return sid
def heartbeat(self, sid: str) -> None:
if sid in self.sessions:
self.sessions[sid]["last_seen"] = now()
def end_session(self, sid: str) -> None:
self.sessions.pop(sid, None) Таймаут неактивности — страховка на случай, если пользователь закрыл вкладку без end. Через 30 минут молчания сессия считается мёртвой.
Часть 5. Грабли. Самая сочная часть
А теперь — то, ради чего написана эта статья. Мы собрали 5 граблей, каждая из которых съела от часа до целого рабочего дня.
Грабли #1: «Значение — это объект». Антипаттерн чтения из стора
Симптом. Фронтенд вызывает endSession() при закрытии вкладки. В консоли появляется странное:
🔍 [endSession] Значение sid: null | Тип: object
⚠️ endSession: session_id отсутствует. Пропускаем. То есть session_id вроде бы должен быть строкой, а по факту туда прилетает что-то не то. Сессия не закрывается.
Расследование. В первом варианте кода мы читали значение из Svelte-стора через антипаттерн — обёртку Promise вокруг subscribe:
// БЫЛО (антипаттерн):
const sid = await new Promise((resolve) => {
sessionId.subscribe(resolve)()
}) Это выглядело хитро, но вело себя нестабильно: в отдельных сценариях в resolve прилетала не сама строка, а мусор. Плюс добавлялся лишний микрозадачный цикл в момент, когда вкладка уже закрывается.
Решение. Выбросили самописную обёртку и взяли штатный синхронный get:
// СТАЛО (правильно):
import { get } from 'svelte/store'
export async function endSession() {
const sid = get(sessionId)
if (!sid || typeof sid !== 'string') {
return
}
await api.post('api/v1/license/session/end', { json: { session_id: sid } })
sessionId.set(null)
} Урок. Не изобретайте обёртки там, где у фреймворка уже есть готовый примитив. Если вам хочется «улучшить» API фреймворка обёрткой из Promise — скорее всего, вы идёте не туда.
Грабли #2: Ошибка 422. Самая эпичная история
Это был самый долгий дебаг в этой фиче. Мы потратили на него несколько часов и пару раз успели решить, что проблема «где-то в бэкенде».
Симптом. Запрос на завершение сессии улетает, а в ответ прилетает:
POST /api/v1/license/session/end → 422 Unprocessable Content 422 — это «я понял твой запрос, но не могу его распарсить». При этом тело запроса было простейшим: { "session_id": "..." }. В чём проблема?!
Ложный след. Сначала грешили на бэкенд. Проверили модель запроса — там обычный session_id: str. Проверили роутер — всё штатно. Данные уходили корректные. Запрос в curl с тем же телом проходил. Значит, дело не в данных.
Настоящая причина. Виноват оказался наш кастомный HTTP-клиент. Мы написали обёртку поверх ky, чтобы автоматически подставлять Authorization: Bearer. И сделали это так:
// БЫЛО (ломало запросы):
const customFetch = async (input, init) => {
const newHeaders = new Headers() // ← ПУСТЫЕ заголовки!
if (token) {
newHeaders.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers: newHeaders })
} В чём подвох. Когда вы передаёте вторым аргументом объект с headers, он полностью перезаписывает заголовки запроса. Мы передавали пустой Headers, в который добавили только Authorization. И тем самым затёрли заголовок Content-Type: application/json, который до нас проставил ky.
В итоге бэкенд получал тело запроса без Content-Type, не понимал, что это за формат, и честно отвечал 422.
Мы искали проблему в модели данных и в роутере. А нужно было просто посмотреть, какие заголовки реально уходят.
Решение. Заголовки нужно не создавать с нуля, а клонировать из исходного запроса и уже поверх добавлять своё.
// СТАЛО (правильно):
const customFetch: typeof fetch = async (input, init) => {
const token = localStorage.getItem('scada_ai_token')
// 1. Берём заголовки из Request, если input — это Request
const existingHeaders = input instanceof Request
? input.headers
: (init?.headers ? new Headers(init.headers) : new Headers())
// 2. Клонируем, чтобы не мутировать оригинал
const newHeaders = new Headers(existingHeaders)
// 3. Добавляем своё поверх чужого
if (token) {
newHeaders.set('Authorization', `Bearer ${token}`)
}
// 4. На всякий случай гарантируем Content-Type для не-GET
if (input instanceof Request) {
if (input.method !== 'GET' && input.method !== 'HEAD'
&& !newHeaders.has('Content-Type')) {
newHeaders.set('Content-Type', 'application/json')
}
}
// 5. Собираем финальный запрос
let finalInput: RequestInfo | URL
let finalInit: RequestInit | undefined = init
if (input instanceof Request) {
finalInput = new Request(input, { headers: newHeaders })
} else {
finalInit = { ...init, headers: newHeaders }
finalInput = input
}
return fetch(finalInput, finalInit)
} После этого 422 исчез навсегда.
Урок. Если что-то падает с 422/415 «из ниоткуда» — первым делом откройте инструменты разработчика и посмотрите реально ушедшие заголовки. Мы потратили часы на код, а надо было один раз посмотреть во вкладку Network.
Грабли #3: Сессии-зомби и «лимит исчерпан»
Симптом. Через несколько часов тестирования система переставала пускать новых пользователей. Фронтенд показывал 403 с текстом «Лимит одновременных подключений исчерпан». При том что лицензия была на 10 мест, а за компьютером сидел один человек.
Корень. Это было прямое следствие Граблей #2. Помните, endSession падал с 422 и сессия не закрывалась? Так вот, каждая такая «незакрытая» сессия оставалась висеть в памяти. Мы заходили и выходили десятки раз за день. И каждая «мёртвая» сессия съедала одно место в лимите.
заход → сессия создана
выход → endSession упал с 422 → сессия НЕ закрылась
... 10 раз ...
лимит исчерпан, хотя реально никого нет Решение было двойным:
- Починили Грабли #2 — сессии стали корректно закрываться.
- Добавили автоматическую очистку по таймауту: сессия, которая 30 минут не присылала
heartbeat, выкидывается сама.
Таймаут — это страховка. Даже если фронтенд упал, выключился свет или пользователь выдернул шнур, сессия всё равно освободится.
Урок. Любой ресурс, который можно «занять», должен уметь освобождаться сам. Не рассчитывайте на вежливость клиента — всегда имейте таймаут.
Грабли #4: Роли добавили на роуты, но забыли про публичные пути
Симптом. После добавления ролевой защиты перестал работать… сам логин. И страница загрузки лицензии. И все «внутренние» запросы.
Корень. AuthMiddleware честно проверял токен на каждом запросе. Но есть запросы, которые обязаны работать до входа в систему:
/api/v1/auth/login— собственно вход/api/v1/license/status— статус лицензии для баннера/health— проверка, что бэкенд живOPTIONS-запросы — префлайты CORS
Если проверять токен на них, получается замкнутый круг: чтобы войти, нужен токен, а чтобы получить токен, нужно войти.
Решение. В AuthMiddleware завели явный список публичных путей, которые пропускаются без проверки:
# core/auth/middleware.py (упрощённо)
PUBLIC_PATHS = {
"/",
"/health",
"/api/v1/auth/login",
"/api/v1/license/status",
"/system/info",
}
async def dispatch(self, request, call_next):
# CORS-префлайты всегда пропускаем
if request.method == "OPTIONS":
return await call_next(request)
# Публичные пути — без проверки токена
if request.url.path in PUBLIC_PATHS:
return await call_next(request)
# Всё остальное — строго через токен
token = extract_bearer(request)
if not token:
return JSONResponse(status_code=401, content={"detail": "..."})
user = verify_token(token)
if not user:
return JSONResponse(status_code=401, content={"detail": "..."})
request.state.user = user # ← передаём дальше в роуты
return await call_next(request) Урок. Когда вводите глобальную проверку — сразу составьте список исключений. Иначе в первый же день обнаружите, что система не даёт сама в себя войти.
Грабли #5: Скрытые кнопки ≠ защита
Симптом. Нам хотелось «быстро» сделать ролевую модель на фронтенде: просто не рисовать кнопку «Конфигуратор», если ты оператор. Выглядело это так:
{#if $currentUser?.role === 'admin'}
<button>Управление пользователями</button>
{/if} И мы почти посчитали задачу закрытой. Почти.
Почему это ловушка. Скрытие кнопки на фронтенде защищает интерфейс от случайного клика. Но оно не защищает данные. Любой человек откроет инструменты разработчика, скопирует запрос из вкладки Network и отправит его напрямую:
DELETE /api/v1/auth/users/admin Если бэкенд не проверяет роль — пользователь удалён. Кнопки не было, а действие совершилось.
Решение. Настоящая защита должна быть на бэкенде. Мы сделали ролевую зависимость и повесили её на каждый защищённый роут:
# core/auth/dependencies.py
def require_role(*roles: UserRole):
async def role_checker(request: Request) -> User:
user = getattr(request.state, "user", None)
if user is None:
raise HTTPException(status_code=401, detail="Требуется аутентификация")
if user.role not in roles:
raise HTTPException(status_code=403, detail="Доступ запрещён")
return user
return role_checker
# использование:
@router.delete("/users/{username}")
async def delete_user(
username: str,
current_user: User = Depends(require_role(UserRole.ADMIN))
):
if username == current_user.username:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
... Скрытие кнопок на фронтенде мы оставили — но уже как удобство, а не как безопасность.
Урок. Фронтенд — это про удобство. Бэкенд — это про безопасность. Никогда не доверяйте клиенту проверку прав.
Часть 6. Ролевая модель
Теперь о том, что получилось в итоге.
Роли
| Роль | Описание | Доступ |
|---|---|---|
admin | Полный доступ | Всё, включая пользователей и лицензии |
engineer | Инженер | Конфигуратор, DDA, логи, чат |
operator | Оператор | Чат, базовые функции |
boss | Руководитель | Только просмотр |
Матрица доступов
| Функция | admin | engineer | operator | boss |
|---|---|---|---|---|
| Чат с AI | ✅ | ✅ | ✅ | ✅ |
| Просмотр логов | ✅ | ✅ | ✅ | ❌ |
| DDA | ✅ | ✅ | ❌ | ❌ |
| Конфигуратор | ✅ | ✅ | ❌ | ❌ |
| Управление пользователями | ✅ | ❌ | ❌ | ❌ |
| Загрузка лицензии | ✅ | ❌ | ❌ | ❌ |
Защита на двух уровнях
Как мы уже выяснили в Граблях #5, защита работает на двух уровнях:
- Бэкенд (настоящая защита) —
require_roleна каждом роуте. Возвращает 403, если прав нет. - Фронтенд (удобство) — условный рендеринг, чтобы не показывать то, что всё равно нельзя.
На фронтенде проверка роли выглядит так:
// stores/auth.ts
export function hasRole(allowedRoles: string[]): boolean {
const user = get(currentUser)
return user ? allowedRoles.includes(user.role) : false
} И ещё одна деталь, которую легко упустить: нельзя дать админу удалить самого себя. Иначе он случайно заблокирует систему. Мы вернули 400 на эту операцию.
Часть 7. Итоги
Что получили
- ✅ Лицензирование: 4 типа лицензий, грас-период 3 дня, лимит сессий
- ✅ Оффлайн-валидация на RSA-2048 — работает без интернета
- ✅ Авторизация: 4 роли, JWT, bcrypt
- ✅ Ролевая защита на уровне бэкенда для каждого роута
- ✅ Сессии с
heartbeatи автоматической очисткой
Метрики
| Операция | Время |
|---|---|
| Валидация лицензии (при старте) | ~10 мс |
| Проверка токена (на запрос) | ~1 мс |
| Создание сессии | ~1 мс |
| Лимит дефолт | 10 одновременных |
| Грас-период | 3 дня |
Уроки, которые мы вынесли
Урок 1: Не создавайте заголовки с нуля — клонируйте. Если вы оборачиваете fetch и хотите добавить свой заголовок, клонируйте существующие. Иначе затрёте Content-Type и получите 422 «из ниоткуда».
Урок 2: Сначала смотрите во вкладку Network. Если запрос падает с 400/415/422, откройте инструменты разработчика и посмотрите реально ушедшие заголовки и тело. Это экономит часы.
Урок 3: Любой ресурс должен освобождаться сам. Сессии, соединения, лимиты — у всего должен быть таймаут. Не надейтесь, что клиент вежливо всё закроет.
Урок 4: Глобальная проверка требует списка исключений. Когда добавляете проверку на каждый запрос, сразу решите, какие запросы живут без неё. Иначе система не даст сама в себя войти.
Урок 5: Фронтенд — удобство, бэкенд — безопасность. Скрытая кнопка не защищает данные. Настоящая проверка прав — только на сервере.
Урок 6: Валидатор не должен бросать исключения наружу. Любая ошибка проверки лицензии превращается в None. Падение проверки не должно ронять запрос.
Урок 7: Не давайте админу удалить самого себя. Мелочь, которая спасает от блокировки системы.
Что дальше?
В планах на следующие релизы:
- Хранение сессий в Redis — текущий in-memory не переживает перезапуск бэкенда. Это осознанный компромисс для начала, но для продакшена нужен отказоустойчивый слой.
- Пользователи в базе, а не в JSON —
users.jsonхорош для старта, но для продакшена нужна нормальная таблица. - Аудит действий — кто и когда менял настройки, удалял пользователей, загружал лицензии.
- Двухфакторная аутентификация для администраторов.
- Отзыв токенов — сейчас токен живёт до истечения срока, даже если пользователя заблокировали.
Заключение
Лицензирование и авторизация — это не «фича для галочки». Это то, что превращает «скрипт, который работает» в «продукт, который можно продавать».
Мы прошли путь от наивного файла с ключом до офлайн-валидации на RSA-2048 и полноценной ролевой модели. По дороге собрали 5 граблей — от потерянного заголовка Content-Type до сессий-зомби, съедающих лимит подключений.
И знаете что? Каждая грабля нас чему-то научила. Потерянный заголовок научил смотреть в Network. Сессии-зомби научили делать таймауты. Скрытые кнопки научили не доверять клиенту.
Если вы делаете систему, которую планируете продавать — не откладывайте лицензирование и авторизацию на потом. Потом будет больнее. Делайте сразу. И закладывайте время на грабли — они будут.
P.S. Если вы тоже писали свою систему лицензирования и наступили на граблю, которую мы пропустили — напишите. Дополним список. Граблей много не бывает.