Files
han-app/modules/module-11-idgtl-sms.md
T

66 KiB
Raw Blame History

module-11. Сервис доставки SMS (i-Digital Direct)

Статус: целевая проектная спецификация post-MVP (закрывает K-TBD8 / бэклог «интеграция с SMS-провайдером»).
Реализация отсутствует. Документ задаёт обязательные контракты для разработки sms-service и доработки Keycloak.
Источники провайдера: Отправка SMS, Авторизация, Callback.
Смежные: module-08-keycloak.md, arch-01, arch-02, arch-04.

Критерий применимости: до синхронизации arch-00arch-04, module-08, Compose и .env.example настоящий документ имеет приоритет только как спецификация нового модуля, но не изменяет действующий mock-only контур.

1. Разделение ответственности

Зона Модуль Что хранит / делает
Доставка сообщений module-11 (sms-service) Шаблоны, журнал отправок (кому/что/когда/статусы), вызов провайдера, callback доставки
Auth OTP module-08 (Keycloak) Генерация и локальная проверка кода, challenge, лимиты, результат verify, контекст устройства, ссылка на sms_message_id

Жёсткие правила:

  1. Keycloak не вызывает i-Digital напрямую и не хранит полный журнал SMS (текст, delivery status провайдера, шаблоны).
  2. sms-service не генерирует OTP, не проверяет код и не знает, верно ли пользователь ввёл код.
  3. Связка: Keycloak получает от sms-service sms_message_id и сохраняет его в своём challenge/событиях.
  4. API верификации телефона (/verifier/send, /verifier/check) не используется.
User → nginx → Keycloak
                 │ 1. generate OTP, create challenge (+ device context)
                 │ 2. POST /internal/sms/v1/send  →  sms-service
                 │                                 ├─ render template
                 │                                 ├─ INSERT sms_outbound_message
                 │                                 └─ return sms_message_id
                 │ 3. сохранить sms_message_id в challenge
                 │ 4. user enters code → local verify
                 │ 5. записать verify outcome (+ device) в Keycloak DB
                 └─ OIDC code

sms-service worker → POST Direct /api/v1/message → update send_status
Direct callback → sms-service only → update delivery_status

Текущий заказчик: keycloak. Процесс: auth_otp. Канал: SMS. Провайдер: idgtl (резервный канал — будущее расширение той же модели).


2. Границы module-11

В scope

  • отдельный сервис sms-service (Compose-модуль);
  • схема БД: шаблоны + журнал исходящих сообщений;
  • internal API для заказчиков (сейчас Keycloak);
  • адаптер провайдера idgtl (POST /api/v1/message, TOKEN_1);
  • асинхронная отправка worker-ом и обновление статусов отправки/доставки;
  • секреты провайдера, health/metrics.
  • OpenAPI 3.1 для internal send/read API и JSON Schema callback;
  • бессрочный журнал отправок и reconciliation зависших pending/uncertain.

Вне scope

  • генерация/проверка OTP;
  • product limits otp.phone.* (остаются в Keycloak);
  • каскады VK/WhatsApp, FLASHCALL, рассылки;
  • публичный API для frontend;
  • решение «пользователь авторизован» / выдача токенов.

3. Модель данных module-11

Схема: отдельная managed PostgreSQL schema, например sms (роль sms_user). App DB han_app и schema keycloak не используются для журнала SMS.

3.1. sms_template — шаблоны

Шаблон не хранится в env. Env только credentials/timeouts провайдера.

Поле Тип Описание
id UUID PK Идентификатор версии шаблона
code varchar Стабильный код, напр. auth_otp
channel enum SMS (расширяемо)
locale varchar напр. ru
version int Монотонная версия внутри code+channel+locale
body_template text Текст с плейсхолдерами, напр. Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.
placeholders jsonb Описание обязательных ключей: ["code","ttl_min"]
sender_name varchar Имя отправителя для этого шаблона (или null → default провайдера)
max_parts int Максимально допустимое число SMS-частей; для auth_otp1
is_active bool Активная версия для code (ровно одна active на code+channel+locale)
approved_at timestamptz Согласование с оператором/провайдером
created_at / updated_at timestamptz Аудит
created_by varchar ops/system

Seed первой версии: code=auth_otp, channel=SMS, locale=ru.

3.2. Настройки SMS и OTP

Параметры, изменение которых не требует изменения Compose, секретов или сетевой топологии, в .env не хранятся.

OTP-настройки в han_app.app_settings (владелец продукта, потребитель — Keycloak через settings bridge):

Ключ Тип Seed Назначение
otp.phone.code_length integer 6 Длина numeric OTP
otp.phone.ttl_seconds integer 60 Срок жизни OTP от ordered_at; диапазон 60..900, значение кратно 60
otp.phone.sms_order_timeout_ms integer 3000 Timeout Keycloak → sms-service только на durable order

Эти ключи возвращаются существующим GET /internal/settings/v1/otp вместе с лимитами и version. Keycloak сохраняет snapshot otp_ttl_sec, otp_code_length и settings_version в challenge. Изменение settings действует только на новые challenges.

Технические настройки в sms.sms_setting (владелец — sms-service):

Ключ Тип Seed Назначение
provider.idgtl.default_sender_name string согласованное имя Default, если sender отсутствует в шаблоне
provider.idgtl.connect_timeout_ms integer 3000 Connect timeout worker → Direct
provider.idgtl.request_timeout_ms integer 70000 Total/read timeout worker → Direct
provider.idgtl.callback_enabled boolean true Включение callback в production
worker.poll_interval_ms integer 500 Интервал поиска pending-заказов
worker.lease_seconds integer 90 Lease записи на время внешнего вызова

Минимальные поля sms_setting: setting_key PK, setting_value, value_type, description, updated_at. Seed выполняется versioned migration. sms-service валидирует обязательные ключи при startup, кэширует их и периодически перечитывает по updated_at; некорректное значение не применяется и вызывает alert.

3.3. sms_outbound_message — журнал отправок

Каждый заказ Keycloak на новую SMS — одна строка. Повторные HTTP-попытки worker по тому же заказу увеличивают attempt_count, но не создают новую строку. Resend создаёт новый challenge и новую строку. Это источник истины «когда, кому и какой текст заказали, что произошло при отправке и доставке».

Поле Тип Обязательность Описание
id UUID PK да sms_message_id — то, на что ссылается Keycloak
created_at timestamptz да Создание записи (до/в момент вызова провайдера)
requested_at timestamptz да Время запроса от заказчика
accepted_at timestamptz нет Провайдер принял сообщение
sent_at timestamptz нет Статус sent от провайдера/callback
delivered_at timestamptz нет delivered
updated_at timestamptz да Последнее изменение статусов
requester_service varchar да Заказчик: сейчас keycloak; позже др. сервисы
process varchar да Бизнес-процесс: сейчас auth_otp
channel varchar да SMS
provider varchar да Сервис доставки: сейчас idgtl; резерв — новый код
phone_e164 varchar да Кому: E.164 (+79001234567)
phone_digits varchar да Как у провайдера: 79001234567
phone_masked varchar да Для UI/ops без полного номера
template_id UUID FK да Ссылка на sms_template.id
template_code varchar да Денормализация auth_otp
body_rendered text да Итоговый текст, ушедший провайдеру
substitutions jsonb да Подстановки (code, ttl_min, …)
send_status enum да Статус отправки (наш/accept)
delivery_status enum да Статус доставки (провайдер)
provider_message_id varchar нет messageUuid Direct
provider_external_id varchar нет externalMessageId, отправленный в Direct
customer_ref varchar нет Корреляция заказчика (напр. Keycloak challenge_id)
idempotency_key varchar да Уникальный ключ от заказчика; защита от дублей
request_fingerprint varchar да SHA-256 канонического значимого payload для обнаружения повторного ключа с другим запросом
request_id varchar нет X-Request-ID / trace
provider_http_status int нет HTTP ответа Direct
provider_error_code varchar нет Код ошибки провайдера
provider_error_message varchar нет Краткий класс/текст ошибки (без секретов)
sender_name varchar да Фактически использованное имя
message_ttl_sec int нет TTL у провайдера
attempt_count int да Число HTTP-попыток к провайдеру
last_attempt_at timestamptz нет Время последней попытки worker
next_attempt_at timestamptz нет Когда разрешена следующая однозначно безопасная попытка
worker_locked_until timestamptz нет Lease фонового worker для защиты от параллельной обработки
parts / price / currency нет Из price-callback, если включён
callback_last_at timestamptz нет Последний callback

Enum send_status (отправка)

Значение Смысл
pending Запись создана, вызов провайдера ещё не завершён
accepted Провайдер принял (errors=false, success item code)
rejected Провайдер отклонил (4xx бизнес)
failed Однозначный технический сбой до передачи запроса провайдеру
uncertain Результат внешнего вызова неизвестен: запрос мог быть принят, но подтверждение не получено
skipped Не вызывали провайдера (напр. dry-run/dev)

Enum delivery_status (доставка)

Значение Смысл
unknown Ещё нет данных о доставке
sent Отправлено оператору
delivered Доставлено
undelivered Не доставлено за TTL
unsent Не отправлено

send_status и delivery_statusразные оси и относятся только к журналу sms-service. Keycloak не читает их, не ждёт и не использует при проверке OTP. Безопасность обеспечивается тем, что корректный код известен только Keycloak и получателю SMS.

3.4. Дополнительные поля (рекомендации)

Имеет смысл заложить сразу:

Поле Зачем
idempotency_key UNIQUE Повтор Keycloak при timeout не создаёт вторую SMS
customer_ref Связь с challenge без join через другие БД
phone_masked Ops-выборки без полного MSISDN
attempt_count + timestamps Диагностика retry
provider как код Переключение/failover без смены схемы
template_id + template_code Аудит «какой текст был согласован»
request_id Сквозная трассировка
архивирование/партиционирование Журнал хранится бессрочно; при росте объёма используются месячные partition и перенос старых partition в архивный storage без удаления данных

Хранение журнала:

  • application-level encryption текста и substitutions не применяется: после истечения OTP они не дают возможности авторизоваться, а отдельный контур ключей несоразмерно усложняет реализацию;
  • используется штатное encryption at rest managed PostgreSQL и backups;
  • OTP действует challenge.otp_ttl_sec от ordered_at; snapshot берётся из app_settings["otp.phone.ttl_seconds"], после истечения код не принимается независимо от состояния SMS;
  • автоматическое удаление, очистка или обезличивание строк журнала запрещены;
  • текст, substitutions, телефон, provider IDs, статусы и timestamps сохраняются бессрочно для будущего аудита и аналитики;
  • при росте объёма допускаются PostgreSQL partitioning, сжатие backup и перенос старых partition в архивное хранилище при сохранении возможности восстановления/выборки;
  • удаление возможно только отдельной утверждённой процедурой по юридическому требованию или запросу субъекта данных, с audit события;
  • hash итогового текста/OTP отдельно не хранится;
  • полный телефон доступен только роли sms_user; ops/read API по умолчанию возвращает mask;
  • доступ к raw body_rendered/substitutions разрешён только sms_user; internal read API их не возвращает.

В логах/метриках текст, OTP, полный телефон, callback credentials и Authorization запрещены.

3.5. Индексы

  • UNIQUE(requester_service, idempotency_key);
  • UNIQUE(provider, provider_message_id) where not null;
  • (phone_e164, created_at DESC);
  • (requester_service, process, created_at DESC);
  • (customer_ref);
  • (send_status, created_at);
  • (delivery_status, updated_at).
  • UNIQUE(code, channel, locale, version) для шаблонов;
  • UNIQUE partial (code, channel, locale) where is_active=true.

Все enum/check constraints и индексы создаются versioned-миграциями. DDL-on-start запрещён.


4. Internal API module-11 (для заказчиков)

Только закрытая Docker-сеть backend. Auth: Authorization: Bearer <token>.

  • KEYCLOAK_SMS_SERVICE_TOKEN передаёт Keycloak; значение равно SMS_SERVICE_TOKEN, который проверяет sms-service;
  • токен — random secret не менее 32 bytes, constant-time compare, без вывода в логи;
  • в v1 разрешён только caller keycloak и только process/template auth_otp;
  • requester_service, process, channel и provider не считаются доверенными данными запроса: сервис сверяет их с allowlist токена либо подставляет серверные значения;
  • X-Request-ID и traceparent передаются сквозным образом;
  • rate limit по caller + destination HMAC обязателен как дополнительная защита при компрометации service token.

4.1. POST /internal/sms/v1/send

Запрос:

{
  "idempotency_key": "keycloak:challenge:01JABCDEF",
  "template_code": "auth_otp",
  "locale": "ru",
  "phone_e164": "+79001234567",
  "substitutions": {
    "code": "482193",
    "ttl_min": "<challenge.otp_ttl_sec / 60>"
  },
  "customer_ref": "01JABCDEF",
  "message_ttl_sec": <challenge.otp_ttl_sec>
}

message_ttl_sec равен snapshot app_settings["otp.phone.ttl_seconds"] для challenge. ttl_min вычисляется из того же snapshot; настройка обязана быть кратна 60.

requester_service=keycloak, process=auth_otp, channel=SMS, provider=idgtl определяются сервером по service token/route. request_id передаётся только заголовком X-Request-ID и не входит в idempotency fingerprint.

Поведение:

  1. Проверить service token и allowlist caller/process/template/provider.
  2. Нормализовать и повторно проверить E.164; phone_digits должен однозначно соответствовать phone_e164.
  3. Проверить message_ttl_sec в диапазоне Direct 60..86400, длины полей и строгий набор substitutions; неизвестные/пропущенные placeholder → 422.
  4. Рассчитать request_fingerprint по каноническому значимому payload.
  5. Если (requester_service,idempotency_key) уже есть:
    • fingerprint совпадает → вернуть сохранённый результат без нового внешнего вызова;
    • fingerprint отличается → 409 idempotency_key_reused. Конкурентная вставка разрешается UNIQUE constraint: проигравшая transaction перечитывает существующую запись и применяет те же правила fingerprint.
  6. Найти единственный active sms_template по template_code+channel+locale; locale fallback в v1 отсутствует.
  7. Срендерить body_rendered; проверить лимит длины, UTF-8 без BOM и ожидаемое число SMS-частей.
  8. В одной DB transaction вставить sms_outbound_message (send_status=pending, delivery_status=unknown, next_attempt_at=now).
  9. Commit гарантирует, что заказ на отправку сохранён.
  10. Немедленно вернуть sms_message_id; внешний API Direct в обработчике этого запроса не вызывается.
  11. Фоновый worker выбирает готовые pending через lease/FOR UPDATE SKIP LOCKED, вызывает адаптер idgtl и обновляет journal row.

Ответ 202 Accepted для нового заказа:

{
  "sms_message_id": "9f3c…",
  "ordered_at": "2026-07-22T13:00:00Z"
}

Ошибки используют envelope из arch-02: 401 unauthorized, 409 idempotency_key_reused, 422 sms_request_invalid, 429 rate_limit_exceeded, 503 sms_service_unavailable.

Правило ответа:

  • 202 означает только «заказ надёжно записан в БД sms-service», но не подтверждает отправку или доставку;
  • идемпотентный повтор с тем же fingerprint возвращает 200 и тот же sms_message_id независимо от текущего provider status;
  • ошибки до commit journal row возвращаются соответствующим 4xx/5xx;
  • Keycloak считает задачу «заказать SMS» выполненной при 200/202 и наличии sms_message_id;
  • Keycloak не анализирует и не запрашивает send_status, delivery_status или provider_message_id.

4.2. GET /internal/sms/v1/messages/{sms_message_id}

Для диагностики заказчика. Доступ Keycloak разрешён только к сообщениям requester_service=keycloak. Endpoint никогда не отдаёт OTP, substitutions или полный итоговый текст, в том числе через privileged flag. Телефон всегда masked.

4.3. Callback от Direct

Публичный endpoint: POST /callbacks/idgtl/sms через root nginx. Префикс /internal/* для callback запрещён.

Защита:

  • только HTTPS;
  • nginx allowlist source IP 185.203.96.7; изменение IP требует сверки с актуальной документацией Direct;
  • Basic auth callback (IDGTL_SMS_CALLBACK_USERNAME / IDGTL_SMS_CALLBACK_PASSWORD), который Direct поддерживает через credentials в callbackUrl;
  • URL с credentials и Authorization редактируются во всех логах/traces;
  • service дополнительно проверяет channel_type=SMS, известный message_uuid и соответствие external_message_id.

Обработка:

  • callback body — массив; каждый item валидируется и обрабатывается независимо;
  • дедупликация по (message_uuid, callback_event, status, status_time);
  • повторы ожидаемы: при отсутствии 2xx Direct повторяет callback каждые 5 минут в течение суток;
  • status_time провайдера сохраняется как время статуса; callback_last_at — время получения;
  • переходы монотонны: поздний sent не понижает delivered/undelivered/unsent;
  • неизвестный/противоречивый item пишется в security log без PII и не изменяет запись;
  • 2xx возвращается только после успешной фиксации всех валидных items; transient DB failure → 5xx для повтора.

Callback обновляет только delivery_status, timestamps, error code и price. Не уведомляет Keycloak и не влияет на verify.


5. Адаптер провайдера idgtl

5.1. Вызов

POST https://direct.i-dgtl.ru/api/v1/message
Authorization: Basic {TOKEN_1}
Content-Type: application/json
[
  {
    "channelType": "SMS",
    "senderName": "<from template or default>",
    "destination": "79001234567",
    "content": "<body_rendered>",
    "externalMessageId": "<sms_message_id>",
    "ttl": <message_ttl_sec>,
    "callbackUrl": "https://<basic-credentials>@tohin.ru/callbacks/idgtl/sms",
    "callbackEvents": ["delivered", "sent"]
  }
]

Успех: только HTTP 200, errors=false, ровно один response item, item.code=201, валидный messageUuid и совпадающий externalMessageIdsend_status=accepted.

Маппинг остальных результатов:

  • HTTP 401/402/403/422rejected, без retry; сохранить provider error code и безопасный класс ошибки;
  • HTTP 200 с errors=true, отсутствующим item, item.code!=201, неверным externalMessageId или невалидным messageUuidrejected и alert о нарушении provider contract;
  • connect failure до установления соединения → failed; допускается ограниченный retry с jitter;
  • полученный явный 503 до такого подтверждения → uncertain; retry разрешается только после письменного подтверждения Direct, что сообщение не создано;
  • read timeout, connection reset после отправки body, 502/504 и любой ответ, при котором неизвестно, создал ли Direct сообщение, → uncertain, без автоматического retry.

externalMessageId всегда равен sms_message_id и не использует customer_ref.

5.2. Таймауты и защита от дублей

Direct рекомендует ожидание ответа до 70 секунд. Фактические значения берутся из settings:

  • connect timeout worker → Direct — sms_setting["provider.idgtl.connect_timeout_ms"];
  • total/read timeout worker → Direct — sms_setting["provider.idgtl.request_timeout_ms"];
  • timeout Keycloak → sms-service для записи заказа — snapshot app_settings["otp.phone.sms_order_timeout_ms"];
  • ожидание Direct происходит только в background worker и не удерживает Keycloak auth request;
  • при превышении provider request timeout результат считается uncertain; новый вызов Direct с тем же или другим externalMessageId автоматически не выполняется.

Local idempotency защищает только от повторного запроса Keycloak к sms-service. Она не доказывает идемпотентность Direct. До письменного подтверждения провайдера externalMessageId считается корреляцией, а не idempotency key.

5.3. Env (только infra, не шаблоны)

KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
SMS_SERVICE_TOKEN=<secret checked by sms-service>
KEYCLOAK_SMS_SERVICE_TOKEN=<same secret used by Keycloak>
SMS_DATABASE_URL=postgresql://sms_user:...@<managed-pg>/<db>?...
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=<TOKEN_1>
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://tohin.ru/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=<random>
IDGTL_SMS_CALLBACK_PASSWORD=<random>

Здесь намеренно отсутствуют OTP TTL/length/order timeout, sender default, provider timeouts, callback flag и worker intervals: они хранятся в app_settings или sms.sms_setting согласно §3.2.

KEYCLOAK_OTP_MOCK_ENABLED=true — Keycloak не вызывает sms-service (текущий MVP).
false + sms-service down/unconfigured — новый заказ SMS завершается generic unavailable; уже созданные active challenges продолжают локальную проверку до TTL.

IDGTL_SMS_API_KEY содержит выданный Direct готовый API key для Basic (TOKEN_1); повторно Base64-кодировать его запрещено. При возможности у Direct включается outbound IP allowlist на egress IP VM.

senderName обязателен у Direct. Если он отсутствует и в active template, и в sms_setting["provider.idgtl.default_sender_name"], readiness=false и отправка запрещена.

5.4. Запрещено

Метод Почему
/api/v1/verifier/send код генерирует провайдер
/api/v1/verifier/check проверка у провайдера
вызов Direct из Keycloak нарушает границу module-11

6. Что хранит Keycloak (module-08) — отдельно

Keycloak остаётся владельцем auth-факта. Расширить provider-owned таблицы в schema keycloak (не копировать журнал SMS).

Текущая реализация mock-only должна быть изменена: Config больше не запрещает startup при KEYCLOAK_OTP_MOCK_ENABLED=false, а OtpStore.reserve() не должен хешировать постоянный KEYCLOAK_OTP_MOCK_CODE в real mode.

6.1. Challenge + ссылка на SMS

han_otp_challenge (расширение):

Поле Описание
существующие id, phone_hmac, destination_masked, otp_hash, TTL, verify_attempts, consumed_at, …
sms_message_id UUID из module-11; логическая ссылка (FK между БД нет)
delivery_mode mock / sms — snapshot режима challenge
challenge_status ordering / active / consumed / superseded / expired / limited / order_failed
ordered_at Когда sms-service надёжно принял заказ; с этого момента challenge active
otp_ttl_sec Snapshot app_settings["otp.phone.ttl_seconds"]
otp_code_length Snapshot app_settings["otp.phone.code_length"]
settings_version Версия набора OTP settings из bridge

Raw OTP и полный текст SMS в Keycloak не хранятся (только otp_hash).

Keycloak не хранит provider send/delivery status. В real mode expires_at = ordered_at + otp_ttl_sec. sms_message_id обязателен для active real-mode challenge и nullable для mock/order_failed.

Переходы:

  • ordering → active после HTTP 200/202 от sms-service;
  • ordering → order_failed при невозможности надёжно записать заказ;
  • active → consumed после верного кода;
  • active → superseded при запросе новой SMS;
  • active → expired после expires_at;
  • active → limited после исчерпания verify attempts.

Никакой переход не зависит от send_status или delivery_status в sms-service.

6.2. Результат ввода кода пользователем

Источник истины verify — Keycloak.

A. Агрегат на challenge (текущее + уточнение):

  • challenge_status, verify_attempts, consumed_at, expires_at;
  • итоговый outcome определяется только состоянием challenge и результатом локального сравнения OTP.

B. Append-only события han_otp_security_event (обязательно на каждую попытку ввода):

Поле Описание
id UUID события
occurred_at Когда пользователь отправил код
event_type otp_verify
challenge_id Ссылка на challenge
sms_message_id Копия ссылки на отправленное SMS (денормализация для выборок)
phone_hmac Без raw phone
outcome success / failure / limited / expired / already_used
details invalid / attempt_limit / …
device-поля см. §6.3

Так отвечаем на вопрос «верно/неверно ввёл»: только в Keycloak (han_otp_security_event + состояние challenge), со ссылкой на sms_message_id.

Событие otp_send при успехе заказа SMS тоже пишет sms_message_id.

6.3. Контекст устройства (на send и на каждую verify-попытку)

Фиксировать в событии (и/или snapshot на challenge при send):

Поле Источник Описание
client_ip trusted proxy (X-Forwarded-For от nginx) IP
user_agent заголовок UA строка
device_id клиент (theme/form/auth note) Стабильный id устройства приложения
fingerprint клиент Browser/device fingerprint (не секрет auth)
os_name / os_version клиент ОС
platform клиент web / ios / android
app_version клиент Версия приложения (если есть)

Правила:

  • device metadata не заменяет phone OTP;
  • IP только из trusted hop nginx;
  • в логах fingerprint/device_id допустимы; не логировать OTP.

Механизм передачи зафиксирован:

  1. Frontend добавляет в OIDC authorization request необязательные параметры han_device_id, han_fingerprint, han_platform, han_os_name, han_os_version, han_app_version.
  2. PhoneIdentityAuthenticator.authenticate() читает их только на первом шаге, валидирует и сохраняет в auth session notes. Это недоверенные audit metadata, а не auth-фактор.
  3. Ограничения: device_id/fingerprint ≤ 256 символов; OS/app version ≤ 64; platform только web/ios/android; control characters запрещены.
  4. Для web при отсутствии han_device_id theme создаёт random UUID, хранит его в localStorage и отправляет hidden field формы телефона; native-клиент передаёт свой stable installation id.
  5. client_ip берётся сервером из trusted proxy chain, user_agent — из текущего HTTP-запроса на каждой send/verify попытке; клиент их не задаёт.
  6. Snapshot device fields копируется в otp_send и каждое otp_verify event. Новые значения hidden fields могут обновить snapshot перед verify.
  7. Nginx/Keycloak access logs для /auth используют path без query string либо редактируют han_*, чтобы device identifiers не размножались в технических логах.
  8. phone.ftl и otp.ftl получают hidden fields/атрибуты через SPI; otp.ftl строит число digit inputs из challenge.otp_code_length, countdown — из expires_at, без hardcoded 6/0:59.

После успешного OTP те же device metadata по-прежнему уходят в POST /auth/bootstrap (arch-02) для App DB — это другой контур (продуктовая сессия), не замена Keycloak OTP audit.

6.4. Чего Keycloak не делает

  • не пишет body_rendered / delivery callback;
  • не держит шаблоны;
  • не вызывает Direct.

7. Поток end-to-end

  1. Пользователь вводит телефон (+ device context попадает в Keycloak session).
  2. Keycloak применяет уже реализованные send limits/cooldown/counters.
  3. В короткой transaction Keycloak:
    • помечает прежний active/ordering challenge этого телефона как superseded;
    • генерирует новый криптографически случайный numeric OTP длиной settings_snapshot.otp_code_length;
    • сохраняет только HMAC;
    • создаёт новый challenge со статусом ordering;
    • резервирует одну send attempt по действующим правилам counters.
  4. Keycloak формирует idempotency_key=keycloak:challenge:{challenge_id} и вызывает POST /internal/sms/v1/send вне DB transaction.
  5. sms-service валидирует запрос, сохраняет journal row и сразу возвращает sms_message_id (202; при идемпотентном повторе — 200). Direct ещё может не быть вызван.
  6. Keycloak сохраняет sms_message_id, ordered_at=now, expires_at=ordered_at+challenge.otp_ttl_sec, переводит challenge в active, пишет событие otp_send/ordered и показывает форму кода.
  7. Background worker sms-service отправляет SMS в Direct и обновляет журнал. Результаты отправки/доставки не передаются в Keycloak и не меняют challenge.
  8. Пользователь вводит код (+ тот же/обновлённый device context).
  9. Keycloak проверяет только challenge_status=active, TTL, verify limits и локальный HMAC:
    • верный код → consumed, событие success, завершение OIDC flow;
    • неверный → increment verify attempts и failure event;
    • attempts exhausted → limited;
    • now >= expires_atexpired.
  10. Если пользователь запрашивает новую SMS, поток повторяется с шага 2; прежний challenge становится superseded, поэтому его код больше не принимается.
  11. Periodic expiry job помечает оставшиеся active challenges как expired после expires_at; verify также выполняет этот переход лениво, если job ещё не успел. Изменение текущего otp.phone.ttl_seconds не пересчитывает expires_at существующих challenges.
  12. Direct callback обновляет только журнал sms-service.

Если sms-service не подтвердил durable order (200/202), новый challenge становится order_failed; прежний уже остаётся superseded. Frontend получает generic unavailable и может начать новый resend с учётом counters.

Mock-режим: внешний заказ не создаётся; challenge сразу получает active, sms_message_id=null, а остальные TTL/verify/resend/counter rules идентичны real mode.

Граница транзакций Keycloak: HTTP-вызов sms-service не выполняется внутри transaction с блокировкой counters/challenge. Создание ordering и перевод в active/order_failed — отдельные короткие transaction. Повтор после потерянного HTTP-ответа использует тот же challenge/idempotency key и не создаёт вторую SMS.


8. Безопасность

  • Direct credentials только в sms-service.
  • Internal SMS API недоступен из публичной сети.
  • OTP в substitutions/body_rendered хранится как часть закрытого журнала, но никогда не попадает в logs/traces/read API; после challenge.expires_at Keycloak его не принимает.
  • Keycloak хранит только hash OTP и sms_message_id.
  • Enumeration: ошибки send/verify наружу generic + request id.
  • Service token Keycloak→sms-service и callback credentials различны; ротация через secret store.
  • TLS certificate Direct проверяется стандартным trust store; verify=false запрещён.
  • Шаблоны редактируются только controlled migration/ops-процедурой; active version требует approved_at.
  • API key Direct ограничивается типом TOKEN_1 и, если поддержано, egress IP.

9. Наблюдаемость

sms-service: sms_send_total{provider,send_status}, provider latency, sms_uncertain_total, callback counters/lag, pending age, journal size/partition age; логи: sms_message_id, provider_message_id, requester_service, process — без phone plaintext/OTP/body.

Keycloak: существующие OTP metrics + verify outcomes; в audit events — sms_message_id, device fields.

Alerting: 401/402 у Direct, contract violation, любой uncertain, рост failed, callback lag, зависшие pending, аномальный рост журнала, sms-service not-ready.

/health/live проверяет процесс. /health/ready проверяет DB/schema, active approved template, sender/API key configuration; кратковременная недоступность Direct отражается отдельным dependency status и метрикой, но не вызывает restart loop.


10. Совместимость документов

Документ Изменение при внедрении
module-08 OtpDeliveryProvider вызывает sms-service, не Direct; challenge + events + device (§6)
arch-01/02 Новый internal сервис; направление Keycloak → sms-service → Direct
arch-03 Compose-сервис sms-service, schema sms, сеть backend
arch-04 SMS_SERVICE_*, IDGTL_SMS_*; шаблоны — в БД, не env
arch-00 Термины sms_message_id, sms_outbound_message, sms_template

10.1. Compose и сети

Добавить sms-service в backend/infra/compose/application.yml:

  • networks: backend, egress, observability;
  • expose: 8080, без host ports;
  • managed PostgreSQL schema sms, роль только sms_user;
  • Keycloak остаётся без egress: он видит только sms-service по сети backend;
  • root nginx маршрутизирует только точный публичный POST /callbacks/idgtl/sms в sms-service; /internal/sms/* наружу блокируется;
  • callback location: HTTPS, IP allowlist, request body limit, без access-log Authorization;
  • зависимости запуска не должны образовывать цикл: Keycloak может стартовать при недоступном sms-service; недоступность блокирует только создание нового real-mode заказа, но не verify уже активного challenge.

10.2. Артефакты реализации

backend/sms-service/
  app/
  migrations/
  tests/
  openapi.yaml
  Dockerfile
  pyproject.toml

Отдельный docker-compose.yml не обязателен: действующий репозиторий использует агрегированный infra/compose/application.yml.

10.3. ТЗ на доработку смежных модулей

Ниже перечислены обязательные изменения вне sms-service, без которых end-to-end использование нового сервиса не считается реализованным.

10.3.1. Общие интеграционные правила

  1. Единственный заказчик SMS в v1 — Keycloak SPI.
  2. Frontend, api-backend и другие сервисы не вызывают sms-service и Direct для OTP.
  3. Keycloak ждёт только durable order (200/202 + sms_message_id) и не ждёт вызова Direct.
  4. send_status, delivery_status, callback и provider errors используются только журналом/ops и никогда не меняют результат verify.
  5. OTP генерируется и проверяется только Keycloak; raw OTP передаётся только в закрытом HTTP-запросе Keycloak → sms-service и не логируется.
  6. Во всех вызовах передаются X-Request-ID и traceparent; idempotency_key=keycloak:challenge:{challenge_id}.

10.3.2. module-08-keycloak

Settings bridge

  • расширить DTO GET /internal/settings/v1/otp: code_length, ttl_seconds, sms_order_timeout_ms;
  • валидировать диапазоны и сохранять единый immutable settings snapshot на новый challenge;
  • убрать чтение KEYCLOAK_OTP_TTL_SEC и других перенесённых runtime-параметров из env;
  • last-known-good/cache semantics оставить как для существующих OTP limits.

Миграция provider-owned таблиц

Добавить в han_otp_challenge:

  • sms_message_id UUID nullable;
  • delivery_mode varchar(16) с CHECK mock|sms;
  • challenge_status varchar(16) с CHECK ordering|active|consumed|superseded|expired|limited|order_failed;
  • ordered_at timestamptz nullable;
  • otp_ttl_sec integer с CHECK 60..900 и кратностью 60;
  • otp_code_length smallint с CHECK 4..10;
  • существующий settings_version varchar(128) переиспользовать, новую колонку не создавать.

Миграция существующих mock-записей:

  • delivery_mode=mock, sms_message_id=null;
  • перед migration дождаться прежнего max OTP TTL либо в maintenance transaction пометить все неиспользованные challenges как expired;
  • ordered_at=created_at;
  • challenge_status=consumed, если consumed_at заполнен; иначе expired;
  • otp_ttl_sec и otp_code_length backfill текущими seed из app_settings; исторические challenges уже не проверяются;
  • старые provider_id/provider_status сначала сделать nullable и перестать использовать; удалить отдельной backward-incompatible migration после стабилизации.

Расширить han_otp_security_event:

  • sms_message_id uuid nullable;
  • client_ip inet, user_agent text;
  • device_id varchar(256), fingerprint varchar(256);
  • os_name varchar(64), os_version varchar(64);
  • platform varchar(16), app_version varchar(64).

Добавить индексы han_otp_challenge(challenge_status, expires_at), han_otp_challenge(sms_message_id) where not null и han_otp_security_event(sms_message_id) where not null. Обновить JPA entities и Liquibase changelog; migration должна быть повторяемо проверена на копии production schema.

Клиент sms-service

  • реализовать SmsOrderClient, который вызывает POST /internal/sms/v1/send;
  • URL и service token — env; timeout — settings snapshot;
  • успех заказа: только HTTP 200/202, валидный sms_message_id;
  • HTTP timeout/5xx: повторить один раз с тем же challenge/idempotency key; новый challenge и новый OTP не создавать;
  • не реализовывать GET/poll provider status в auth flow.

Challenge lifecycle

  • перед новым заказом после успешной проверки limits перевести прежний active/ordering challenge в superseded;
  • создать новый ordering, сгенерировать numeric OTP по snapshot length, сохранить только HMAC;
  • после durable order перевести в active, установить ordered_at/expires_at, записать otp_send/ordered;
  • при невозможности durable order перевести в order_failed;
  • verify допускается только для active и зависит только от HMAC, TTL и verify counters;
  • верный код → consumed; resend → superseded; TTL → expired; attempts → limited;
  • periodic expiry job и lazy expiry на verify обязательны;
  • повтор одного auth action использует тот же challenge и idempotency key.

Counters и mock

  • существующие send/verify limits, cooldown, phone HMAC и locking сохраняются;
  • один новый challenge резервирует одну send attempt; HTTP retry того же заказа повторно counter не увеличивает;
  • mock mode не вызывает sms-service, но использует те же statuses, TTL, resend и verify rules;
  • недоступность Direct не влияет на Keycloak; недоступность sms-service блокирует только создание нового real-mode заказа;
  • общая readiness Keycloak не должна зависеть от Direct или provider status. Допускается отдельный degraded dependency indicator для sms-service.

Тесты Keycloak

  • migration/backfill существующих challenges;
  • durable order → форма OTP до ответа Direct;
  • resend отклоняет старый код;
  • expiry и attempts transitions;
  • provider rejected/timeout не меняет active challenge;
  • идемпотентный повтор не создаёт второй challenge и не увеличивает counter;
  • отсутствие OTP/phone/service token в logs/traces.

10.3.3. module-01-api-backend и App DB settings

  • добавить migration/seed app_settings:
    • otp.phone.code_length;
    • otp.phone.ttl_seconds;
    • otp.phone.sms_order_timeout_ms;
  • расширить строгий DTO /internal/settings/v1/otp согласно arch-02;
  • возвращать все OTP settings одной версией, чтобы Keycloak не смешивал значения разных revisions;
  • добавить валидацию: code length в разрешённом диапазоне; TTL 60..900 и кратен 60; timeout положительный и bounded;
  • не добавлять отправку/проверку OTP в api-backend;
  • покрыть endpoint contract tests, cache/ETag и отсутствие новых ключей в public config, если они явно не разрешены.

10.3.4. Managed PostgreSQL и deployment jobs

  • в init-managed-postgres создать schema sms и роль sms_user;
  • выдать sms_user права только на schema sms; доступа к han_app и keycloak нет;
  • sms-service применяет собственные versioned migrations для sms_template, sms_setting, sms_outbound_message;
  • добавить idempotent seed active template auth_otp и sms_setting;
  • добавить pre-deploy migration job и проверку schema version;
  • backup/PITR должны включать schema sms; автоматическое удаление журнала запрещено;
  • restore test обязан подтверждать сохранность journal rows, templates, settings и provider IDs.

10.3.5. Root Compose и конфигурация

Добавить в backend/infra/compose/application.yml:

  • sms-service — internal HTTP API/callback receiver;
  • sms-worker — background sender из того же image либо обязательный worker process внутри sms-service;
  • sms-service: networks backend, egress, observability, expose: 8080, без ports;
  • отдельный sms-worker: networks egress, observability, без published/exposed port;
  • оба процесса используют SMS_DATABASE_URL; только worker получает IDGTL_SMS_API_KEY;
  • callback credentials получают sms-service для проверки и sms-worker для формирования callback URL в запросе Direct; Keycloak получает только KEYCLOAK_SMS_SERVICE_TOKEN;
  • healthchecks, graceful shutdown, lease recovery, read-only rootfs, non-root и resource limits;
  • startup не строится на depends_on Direct; provider outage не вызывает restart loop.

Обновить:

  • root .env.example только URL/DB/secrets;
  • scripts/validate-env и config tests;
  • image/build/release manifests;
  • secret generation и rotation runbook.

10.3.6. module-03-nginx

  • добавить точный public route POST /callbacks/idgtl/smssms-service:8080;
  • остальные методы на callback path отклонять;
  • source IP allowlist Direct, учитывая только trusted proxy chain;
  • передавать Basic Authorization в sms-service, но не писать его в access/error logs;
  • ограничить размер body, отключить cache, задать отдельный callback rate limit без блокировки легитимных повторов;
  • /internal/sms/* и порт sms-service наружу не публиковать;
  • добавить config/route tests: allowed callback, wrong IP, wrong method, internal path denied.

10.3.7. module-02-frontend-test-site и Keycloak theme

  • frontend не вызывает sms-service;
  • resend запускает новый Keycloak action; двойной click блокируется на время запроса;
  • после resend UI явно сообщает, что предыдущий код недействителен;
  • countdown берётся из challenge/settings snapshot, а не из hardcoded значения;
  • корректно отображать invalid, expired, superseded, limited и generic order unavailable;
  • raw OTP, service URLs/tokens и provider status не попадают в frontend config/analytics.

10.3.8. module-09-observability

  • добавить metrics/alerts из §9 для sms-service и sms-worker;
  • dashboard: pending age, send outcomes, provider latency, callback lag, uncertain, journal growth;
  • traces: Keycloak order span → sms-service DB commit; worker → Direct отдельным trace/span с correlation через sms_message_id;
  • настроить redaction OTP, body, phone, Authorization, API key и callback credentials;
  • alert routing/runbook для Direct 401/402, uncertain, stuck pending и callback failures.

10.3.9. module-10-deployment-runbook и deploy-steps.md

Зафиксировать rollout:

  1. применить App DB seed новых OTP settings;
  2. создать schema/role sms, применить migrations и seed;
  3. в test environment deploy sms-service/worker с IDGTL_SMS_BASE_URL локального mock Direct и выполнить contract/E2E;
  4. выпустить/установить production Direct TOKEN_1, sender и callback credentials;
  5. deploy production sms-service/worker, проверить health/migrations, оставив Keycloak в mock mode;
  6. применить Keycloak migration и deploy SPI с KEYCLOAK_OTP_MOCK_ENABLED=true;
  7. выполнить provider smoke отдельной ops-командой на контролируемом номере;
  8. проверить реальный callback, журнал и redaction;
  9. переключить Keycloak в real mode;
  10. проверить resend/expiry/limits и сохранить release evidence.

Rollback:

  • вернуть Keycloak в mock mode без удаления schema/journal;
  • остановить создание новых real orders, дать worker завершить/зафиксировать in-flight;
  • migrations откатывать только при доказанной backward compatibility; иначе forward-fix.

10.3.10. Архитектурные документы

До merge реализации синхронизировать:

  • arch-00: сервис/сущности/ID/settings/env, send_status, delivery_status, challenge_status;
  • arch-01: компонент sms-service, schema sms, поток Keycloak → durable order → worker → Direct, отсутствие зависимости verify от provider status;
  • arch-02: полный POST/GET /internal/sms/v1/*, callback, service-token pair, HTTP-коды и OpenAPI registry;
  • arch-03: sms-service/worker, networks, schema/role, nginx callback route, startup/health;
  • arch-04: разделение env / app_settings / sms.sms_setting;
  • architectory/README.md: убрать формулировку о неоформленной интеграции после начала реализации и добавить ссылки на новый контракт;
  • module-01, module-02, module-03, module-08, module-09, module-10 — добавить перечисленные требования в профильные DoD/test matrix;
  • backlog.md: переводить интеграцию из backlog только после выполнения общего DoD;
  • deploy-steps.md: добавить rollout/rollback и smoke-команды.

module-04-redis, module-05-message-safety, module-06-bitrix-local-app, module-07-bitrix-sync изменений для SMS не требуют.

10.4. Общие критерии приёмки смежных изменений

  • новый OTP-заказ возвращается до начала/завершения внешнего HTTP-вызова Direct;
  • Keycloak не содержит кода чтения provider send/delivery status;
  • provider failure после durable order не деактивирует challenge;
  • resend делает старый challenge и код superseded;
  • challenge становится expired по сохранённому settings snapshot;
  • повтор с тем же idempotency key не создаёт вторую SMS и не увеличивает counters;
  • internal SMS API недоступен извне; callback доступен только по установленным правилам;
  • журнал содержит заказ, provider result и callback и сохраняется бессрочно;
  • OTP, body, телефон и секреты отсутствуют в logs/traces/metrics;
  • все изменённые OpenAPI/DTO/migrations/docs проходят contract, migration и E2E tests;
  • поиск по документации не находит старого прямого потока Keycloak → Direct или зависимости verify от provider status.

11. Тест-план (будущая реализация)

  • unit: strict template render, E.164/TTL, request fingerprint, idempotency conflict, status transitions;
  • contract: локальный mock/WireMock Direct + callback fixtures; существование отдельного sandbox Direct не предполагается;
  • provider smoke: выделенный test account/sender sms_promo только по отдельному ops-runbook, чтобы тест не отправлял SMS случайным адресатам;
  • integration: Keycloak → durable order в sms-service → background worker → mock Direct;
  • E2E: форма OTP открывается после durable order и до ответа Direct; provider reject/timeout не меняет Keycloak challenge;
  • E2E: wrong code → success verify; sms_message_id совпадает в обеих БД;
  • E2E: resend переводит прежний challenge в superseded, старый код отклоняется, новый принимается;
  • E2E: active challenge без ввода кода становится expired через snapshot otp.phone.ttl_seconds;
  • E2E: изменение otp.phone.ttl_seconds/code_length влияет только на новые challenges;
  • E2E: counters/cooldown применяются до создания нового заказа; идемпотентный HTTP-повтор не увеличивает counters повторно;
  • resilience: connect failure, 401/402/403/422, errors=true, malformed 200, 503, read timeout → uncertain, crash после INSERT и после provider accept;
  • callback: массив, duplicate, out-of-order sent after delivered, unknown UUID, Basic auth/IP reject, retry после DB failure;
  • security: нет OTP/phone/token/callback credentials в logs/traces; internal API без token → 401; provider TLS verification;
  • migration: upgrade существующих Keycloak tables и rollback compatibility;
  • persistence: записи и полный состав журнала сохраняются после архивирования/ротации partition и восстановления backup.

12. Definition of Done

  • Журнал SMS целиком в module-11 (sms_template + sms_outbound_message);
  • Verify outcomes + device — в Keycloak с sms_message_id;
  • Keycloak не ходит в Direct; Direct не проверяет код;
  • mock XOR real; отсутствие durable order блокирует только новый challenge;
  • Keycloak не читает и не проверяет provider send/delivery statuses;
  • sms-service возвращает durable order до фонового вызова Direct;
  • ambiguous provider result → uncertain без автоматической повторной SMS;
  • callback защищён HTTPS + IP allowlist + Basic auth и обрабатывается идемпотентно;
  • TTL OTP задаётся app_settings["otp.phone.ttl_seconds"] и считается от ordered_at; resend делает прежний challenge superseded, expiry job — expired;
  • журнал SMS хранится бессрочно без автоматической очистки;
  • OpenAPI, migrations, Compose, env validation, health/metrics и runbook готовы;
  • arch-* и module-08 синхронизированы.

13. Решения, допущения и внешние предпосылки

Решения:

  • S1: module-11 — единственный владелец отправки SMS и журнала.
  • S2: шаблоны в БД (sms_template), не в env.
  • S3: OTP generate/verify — Keycloak; связь через sms_message_id.
  • S4: первый provider idgtl, канал SMS, process auth_otp, requester keycloak.
  • S5: delivery callback только в sms-service.
  • S6: устройство (IP, UA, device_id, fingerprint, OS) — в Keycloak verify/send events.
  • S7: Keycloak зависит только от durable order (sms_message_id) и не зависит от provider send/delivery status.
  • S8: отправка в Direct выполняется background worker-ом после ответа Keycloak.
  • S9: resend всегда делает прежний challenge superseded; неиспользованный challenge после TTL становится expired.
  • S10: externalMessageId в v1 считается только корреляцией, не idempotency key; ambiguous provider call не повторяется независимо от будущего ответа Direct.
  • S11: failover-провайдер не входит в v1; поле provider остаётся для аудита и будущего расширения.
  • S12: device metadata передаётся через custom OIDC han_* параметры/auth notes и hidden fields theme по §6.3.
  • S13: точная миграция Keycloak фиксируется §10.3.2; все прежние незавершённые challenges истекают при rollout.

Допущения:

  • A1: отдельная schema sms на том же managed PostgreSQL допустима.
  • A2: sender/template согласуются с i-Digital до prod.
  • A3: Direct отправляет callback с IP 185.203.96.7; адрес повторно подтверждается перед production.
  • A4: Direct поддерживает Basic auth callback через credentials в callback URL согласно опубликованной документации.

Внешняя production-предпосылка:

  • Перед production rollout ops определяет фактический статический egress IP из контейнера sms-worker, фиксирует его в deployment inventory и передаёт Direct для API-key allowlist. Если egress IP не статичен, production-включение real mode запрещено до настройки NAT/static IP. Это deployment value, а не параметр приложения или открытое архитектурное решение.