66 KiB
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-00…arch-04, module-08, Compose и .env.example настоящий документ имеет приоритет только как спецификация нового модуля, но не изменяет действующий mock-only контур.
1. Разделение ответственности
| Зона | Модуль | Что хранит / делает |
|---|---|---|
| Доставка сообщений | module-11 (sms-service) | Шаблоны, журнал отправок (кому/что/когда/статусы), вызов провайдера, callback доставки |
| Auth OTP | module-08 (Keycloak) | Генерация и локальная проверка кода, challenge, лимиты, результат verify, контекст устройства, ссылка на sms_message_id |
Жёсткие правила:
- Keycloak не вызывает i-Digital напрямую и не хранит полный журнал SMS (текст, delivery status провайдера, шаблоны).
- sms-service не генерирует OTP, не проверяет код и не знает, верно ли пользователь ввёл код.
- Связка: Keycloak получает от sms-service
sms_message_idи сохраняет его в своём challenge/событиях. - 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_otp — 1 |
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) whereis_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/templateauth_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.
Поведение:
- Проверить service token и allowlist caller/process/template/provider.
- Нормализовать и повторно проверить E.164;
phone_digitsдолжен однозначно соответствоватьphone_e164. - Проверить
message_ttl_secв диапазоне Direct60..86400, длины полей и строгий набор substitutions; неизвестные/пропущенные placeholder →422. - Рассчитать
request_fingerprintпо каноническому значимому payload. - Если
(requester_service,idempotency_key)уже есть:- fingerprint совпадает → вернуть сохранённый результат без нового внешнего вызова;
- fingerprint отличается →
409 idempotency_key_reused. Конкурентная вставка разрешается UNIQUE constraint: проигравшая transaction перечитывает существующую запись и применяет те же правила fingerprint.
- Найти единственный active
sms_templateпоtemplate_code+channel+locale; locale fallback в v1 отсутствует. - Срендерить
body_rendered; проверить лимит длины, UTF-8 без BOM и ожидаемое число SMS-частей. - В одной DB transaction вставить
sms_outbound_message(send_status=pending,delivery_status=unknown,next_attempt_at=now). - Commit гарантирует, что заказ на отправку сохранён.
- Немедленно вернуть
sms_message_id; внешний API Direct в обработчике этого запроса не вызывается. - Фоновый 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 и совпадающий externalMessageId → send_status=accepted.
Маппинг остальных результатов:
- HTTP
401/402/403/422→rejected, без retry; сохранить provider error code и безопасный класс ошибки; - HTTP 200 с
errors=true, отсутствующим item,item.code!=201, невернымexternalMessageIdили невалиднымmessageUuid→rejectedи 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после HTTP200/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.
Механизм передачи зафиксирован:
- Frontend добавляет в OIDC authorization request необязательные параметры
han_device_id,han_fingerprint,han_platform,han_os_name,han_os_version,han_app_version. PhoneIdentityAuthenticator.authenticate()читает их только на первом шаге, валидирует и сохраняет в auth session notes. Это недоверенные audit metadata, а не auth-фактор.- Ограничения:
device_id/fingerprint≤ 256 символов; OS/app version ≤ 64;platformтолькоweb/ios/android; control characters запрещены. - Для web при отсутствии
han_device_idtheme создаёт random UUID, хранит его вlocalStorageи отправляет hidden field формы телефона; native-клиент передаёт свой stable installation id. client_ipберётся сервером из trusted proxy chain,user_agent— из текущего HTTP-запроса на каждой send/verify попытке; клиент их не задаёт.- Snapshot device fields копируется в
otp_sendи каждоеotp_verifyevent. Новые значения hidden fields могут обновить snapshot перед verify. - Nginx/Keycloak access logs для
/authиспользуют path без query string либо редактируютhan_*, чтобы device identifiers не размножались в технических логах. phone.ftlиotp.ftlполучают hidden fields/атрибуты через SPI;otp.ftlстроит число digit inputs изchallenge.otp_code_length, countdown — изexpires_at, без hardcoded6/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
- Пользователь вводит телефон (+ device context попадает в Keycloak session).
- Keycloak применяет уже реализованные send limits/cooldown/counters.
- В короткой transaction Keycloak:
- помечает прежний
active/orderingchallenge этого телефона какsuperseded; - генерирует новый криптографически случайный numeric OTP длиной
settings_snapshot.otp_code_length; - сохраняет только HMAC;
- создаёт новый challenge со статусом
ordering; - резервирует одну send attempt по действующим правилам counters.
- помечает прежний
- Keycloak формирует
idempotency_key=keycloak:challenge:{challenge_id}и вызываетPOST /internal/sms/v1/sendвне DB transaction. - sms-service валидирует запрос, сохраняет journal row и сразу возвращает
sms_message_id(202; при идемпотентном повторе —200). Direct ещё может не быть вызван. - Keycloak сохраняет
sms_message_id,ordered_at=now,expires_at=ordered_at+challenge.otp_ttl_sec, переводит challenge вactive, пишет событиеotp_send/orderedи показывает форму кода. - Background worker sms-service отправляет SMS в Direct и обновляет журнал. Результаты отправки/доставки не передаются в Keycloak и не меняют challenge.
- Пользователь вводит код (+ тот же/обновлённый device context).
- Keycloak проверяет только
challenge_status=active, TTL, verify limits и локальный HMAC:- верный код →
consumed, событие success, завершение OIDC flow; - неверный → increment verify attempts и failure event;
- attempts exhausted →
limited; now >= expires_at→expired.
- верный код →
- Если пользователь запрашивает новую SMS, поток повторяется с шага 2; прежний challenge становится
superseded, поэтому его код больше не принимается. - Periodic expiry job помечает оставшиеся
activechallenges какexpiredпослеexpires_at; verify также выполняет этот переход лениво, если job ещё не успел. Изменение текущегоotp.phone.ttl_secondsне пересчитываетexpires_atсуществующих challenges. - 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_atKeycloak его не принимает. - 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, без hostports;- 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. Общие интеграционные правила
- Единственный заказчик SMS в v1 — Keycloak SPI.
- Frontend,
api-backendи другие сервисы не вызываютsms-serviceи Direct для OTP. - Keycloak ждёт только durable order (
200/202+sms_message_id) и не ждёт вызова Direct. send_status,delivery_status, callback и provider errors используются только журналом/ops и никогда не меняют результат verify.- OTP генерируется и проверяется только Keycloak; raw OTP передаётся только в закрытом HTTP-запросе Keycloak → sms-service и не логируется.
- Во всех вызовах передаются
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_idUUID nullable;delivery_mode varchar(16)с CHECKmock|sms;challenge_status varchar(16)с CHECKordering|active|consumed|superseded|expired|limited|order_failed;ordered_at timestamptznullable;otp_ttl_sec integerс CHECK60..900и кратностью 60;otp_code_length smallintс CHECK4..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_lengthbackfill текущими seed изapp_settings; исторические challenges уже не проверяются;- старые
provider_id/provider_statusсначала сделать nullable и перестать использовать; удалить отдельной backward-incompatible migration после стабилизации.
Расширить han_otp_security_event:
sms_message_id uuidnullable;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/orderingchallenge в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права только на schemasms; доступа к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: networksbackend,egress,observability,expose: 8080, безports;- отдельный
sms-worker: networksegress,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_onDirect; 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/sms→sms-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:
- применить App DB seed новых OTP settings;
- создать schema/role
sms, применить migrations и seed; - в test environment deploy
sms-service/worker сIDGTL_SMS_BASE_URLлокального mock Direct и выполнить contract/E2E; - выпустить/установить production Direct TOKEN_1, sender и callback credentials;
- deploy production
sms-service/worker, проверить health/migrations, оставив Keycloak в mock mode; - применить Keycloak migration и deploy SPI с
KEYCLOAK_OTP_MOCK_ENABLED=true; - выполнить provider smoke отдельной ops-командой на контролируемом номере;
- проверить реальный callback, журнал и redaction;
- переключить Keycloak в real mode;
- проверить 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, schemasms, поток 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через snapshototp.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 делает прежний challengesuperseded, 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, processauth_otp, requesterkeycloak. - 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, а не параметр приложения или открытое архитектурное решение.