Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
| Тема | Где зафиксировано | | Тема | Где зафиксировано |
|---|---| |---|---|
| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync``api-backend`, уведомление клиента) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9; arch-01 — заглушка UI «Документы» | | Доставка документов компании из Bitrix24 в приложение (`bitrix-sync``api-backend`, уведомление клиента) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9; arch-01 — заглушка UI «Документы» |
| Интеграция с SMS-провайдерами (отправка OTP, отключение `KEYCLOAK_OTP_MOCK_*`) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 10 | | Интеграция с SMS-провайдерами (отправка OTP, отключение `KEYCLOAK_OTP_MOCK_*`) | Спецификация: [`module-11-idgtl-sms.md`](../modules/module-11-idgtl-sms.md) (доставка через Direct SMS API; проверка OTP — локально в Keycloak) |
| Изоляция `bitrix-sync` на отдельную VM | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 8 | | Изоляция `bitrix-sync` на отдельную VM | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 8 |
+22 -1
View File
@@ -34,6 +34,9 @@
| `text_resources` | `han_app` | Тексты UI по мнемоникам | | `text_resources` | `han_app` | Тексты UI по мнемоникам |
| `popular_questions` | `han_app` | Популярные вопросы главного экрана | | `popular_questions` | `han_app` | Популярные вопросы главного экрана |
| `dialog_sessions` | `bitrix_local` | Маппинг чата Open Lines | | `dialog_sessions` | `bitrix_local` | Маппинг чата Open Lines |
| `sms_template` | `sms` | Версионируемый согласованный SMS-шаблон; active-версия уникальна для `code`+`channel`+`locale` |
| `sms_setting` | `sms` | Технические runtime-настройки `sms-service`, не секреты и не OTP product settings |
| `sms_outbound_message` | `sms` | Бессрочный журнал заказа, отправки и доставки SMS; источник истины provider status |
## Идентификаторы ## Идентификаторы
@@ -50,6 +53,9 @@
| `session_id` | ID сессии Open Lines (поле `dialog_sessions`; не путать с `ux_session_id`) | | `session_id` | ID сессии Open Lines (поле `dialog_sessions`; не путать с `ux_session_id`) |
| `task_id` | ID async-проверки Message Safety | | `task_id` | ID async-проверки Message Safety |
| `request_id` | Корреляция HTTP-запроса (заголовок `X-Request-ID`) | | `request_id` | Корреляция HTTP-запроса (заголовок `X-Request-ID`) |
| `sms_message_id` | UUID `sms.sms_outbound_message.id`; логическая ссылка из Keycloak challenge/event, межсхемного FK нет |
| `provider_message_id` | `messageUuid` i-Digital Direct; хранится только в `sms-service` |
| `provider_external_id` | `externalMessageId`; в v1 равен `sms_message_id` и является корреляцией, а не доказанной идемпотентностью Direct |
Публичные id сущностей — **UUID**. Публичные id сущностей — **UUID**.
@@ -127,9 +133,16 @@ Realtime-событие `message.status` передаёт актуальные `
## Строковые enum vs справочники ## Строковые enum vs справочники
- **Строковые enum** (значения в API/контрактах): `Dialog.status`, `Message.sender_type`, `Message.safety_status`, `Message.delivery_status`, `MessageAttachment.scan_status`, `content_kind`, `start_reason`. - **Строковые enum** (значения в API/контрактах): `Dialog.status`, `Message.sender_type`, `Message.safety_status`, `Message.delivery_status`, `MessageAttachment.scan_status`, `content_kind`, `start_reason`, SMS `send_status`, SMS `delivery_status`, OTP `challenge_status`.
- **Справочники (sequence ID)** — для больших/изменяемых списков UI и доменных классификаторов (типы документов post-MVP, причины и т.п.); правило — [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md). - **Справочники (sequence ID)** — для больших/изменяемых списков UI и доменных классификаторов (типы документов post-MVP, причины и т.п.); правило — [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md).
### SMS и OTP статусы
- `sms_outbound_message.send_status`: `pending`, `accepted`, `rejected`, `failed`, `uncertain`, `skipped`.
- `sms_outbound_message.delivery_status`: `unknown`, `sent`, `delivered`, `undelivered`, `unsent`.
- `han_otp_challenge.challenge_status`: `ordering`, `active`, `consumed`, `superseded`, `expired`, `limited`, `order_failed`.
- Provider statuses принадлежат только `sms-service`: Keycloak не читает их и не использует для verify.
## Мнемоники internal API ## Мнемоники internal API
Префикс: **`/internal/{service_mnemonic}/v1/`**. Health: **`/health/*`**. Префикс: **`/internal/{service_mnemonic}/v1/`**. Health: **`/health/*`**.
@@ -140,6 +153,14 @@ Realtime-событие `message.status` передаёт актуальные `
| `openlines` | `bitrix-local-app`, приёмник inbox на `api-backend` | | `openlines` | `bitrix-local-app`, приёмник inbox на `api-backend` |
| `sync` | `bitrix-sync` | | `sync` | `bitrix-sync` |
| `settings` | internal settings bridge на `api-backend` для Keycloak SPI | | `settings` | internal settings bridge на `api-backend` для Keycloak SPI |
| `sms` | `sms-service`; durable order/read API во внутренней сети |
## SMS-конфигурация
- Product OTP settings: `otp.phone.code_length`, `otp.phone.ttl_seconds`, `otp.phone.sms_order_timeout_ms` и лимиты — `han_app.app_settings`, выдаются Keycloak через settings bridge.
- Runtime SMS settings: `provider.idgtl.*` и `worker.*``sms.sms_setting`.
- Infra/secrets env: `KEYCLOAK_SMS_SERVICE_URL`, `SMS_DATABASE_URL`, парные `KEYCLOAK_SMS_SERVICE_TOKEN`/`SMS_SERVICE_TOKEN`, `IDGTL_SMS_BASE_URL`, `IDGTL_SMS_API_KEY`, `IDGTL_SMS_CALLBACK_PUBLIC_URL`, `IDGTL_SMS_CALLBACK_USERNAME`, `IDGTL_SMS_CALLBACK_PASSWORD`.
- Текст, placeholders и sender template не хранятся в env: они принадлежат `sms_template`; default sender — `sms_setting`.
## Bitrix24 Open Lines ## Bitrix24 Open Lines
+28 -10
View File
@@ -19,7 +19,7 @@ HAN Chat - приложение для мигрантов, где стартов
- Мультиязычность в первом релизе не нужна, но тексты должны храниться по мнемоникам для будущих переводов. - Мультиязычность в первом релизе не нужна, но тексты должны храниться по мнемоникам для будущих переводов.
- Среда на первом этапе одна и проектируется как боевая. - Среда на первом этапе одна и проектируется как боевая.
- Вложения чата MVP: **только изображения и PDF** — см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), «Разрешённые типы файлов чата». - Вложения чата MVP: **только изображения и PDF** — см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), «Разрешённые типы файлов чата».
- SMS OTP на старте: **заглушка** — пользователь вводит фиксированный код из `.env` (`KEYCLOAK_OTP_MOCK_CODE`); SMS не отправляется. Интеграция с SMS-провайдерами — в бэклоге (см. [`!Backlog.md`](../../HAN_chat/!Backlog.md)). - SMS OTP вводится поэтапно: до production rollout действует явный mock (`KEYCLOAK_OTP_MOCK_ENABLED=true`); целевой real mode — Keycloak генерирует/локально проверяет OTP и создаёт durable order в `sms-service`, а worker асинхронно вызывает i-Digital Direct. Контракт и gates — [`module-11-idgtl-sms.md`](../modules/module-11-idgtl-sms.md).
- Популярный вопрос при выборе **автоматически отправляется как сообщение**; если пользователь не авторизован — сначала согласия и OTP, затем отправка. - Популярный вопрос при выборе **автоматически отправляется как сообщение**; если пользователь не авторизован — сначала согласия и OTP, затем отправка.
- Перечень таблиц и миграций App DB проектирует модуль `database` (и владельцы схем других сервисов); arch фиксирует только **разделение схем** PostgreSQL и контракты между сервисами. - Перечень таблиц и миграций App DB проектирует модуль `database` (и владельцы схем других сервисов); arch фиксирует только **разделение схем** PostgreSQL и контракты между сервисами.
@@ -42,12 +42,13 @@ HAN Chat - приложение для мигрантов, где стартов
- Expo App: единая frontend-кодовая база для iOS, Android и web. - Expo App: единая frontend-кодовая база для iOS, Android и web.
- Keycloak: identity provider, OTP-only авторизация по номеру телефона. - Keycloak: identity provider, OTP-only авторизация по номеру телефона.
- SMS Service: internal durable order API, шаблоны и бессрочный журнал SMS; отдельный worker вызывает i-Digital Direct, callback обновляет только журнал.
- api-backend: Python-приложение с REST API, realtime-доставкой сообщений и бизнес-логикой. - api-backend: Python-приложение с REST API, realtime-доставкой сообщений и бизнес-логикой.
- Nginx Reverse Proxy: единая публичная точка входа, HTTPS termination и маршрутизация на Keycloak/API/frontend web/Bitrix24. - Nginx Reverse Proxy: единая публичная точка входа, HTTPS termination и маршрутизация на Keycloak/API/frontend web/Bitrix24.
- Message Safety Service: отдельный сервис проверки входящих сообщений; вызов из API → `200 allow` | `403 deny` | `203 pending` + `task_id` (при `203` api-backend синхронно поллит task до финального вердикта, без очереди анализа на api-backend). - Message Safety Service: отдельный сервис проверки входящих сообщений; вызов из API → `200 allow` | `403 deny` | `203 pending` + `task_id` (при `203` api-backend синхронно поллит task до финального вердикта, без очереди анализа на api-backend).
- Bitrix24 Local App: локальное приложение, custom connector `han_mobile_app` для Bitrix24 Open Lines: чат, OAuth, webhook-события, маппинг `dialog_id``bitrix_chat_id`. - Bitrix24 Local App: локальное приложение, custom connector `han_mobile_app` для Bitrix24 Open Lines: чат, OAuth, webhook-события, маппинг `dialog_id``bitrix_chat_id`.
- Bitrix24 sync service: двусторонняя синхронизация App DB ↔ Битрикс24 CRM (Contact на MVP; маппинг ID, очередь через триггеры, webhook от роботов Bitrix24). - Bitrix24 sync service: двусторонняя синхронизация App DB ↔ Битрикс24 CRM (Contact на MVP; маппинг ID, очередь через триггеры, webhook от роботов Bitrix24).
- Managed PostgreSQL (приватная сеть, одна база): схемы `han_app`, `bitrix_sync`, `bitrix_local`, `keycloak`, `message_safety` — отдельный DB-user на схему. - Managed PostgreSQL (приватная сеть, одна база): схемы `han_app`, `bitrix_sync`, `bitrix_local`, `keycloak`, `message_safety`, `sms` — отдельный DB-user на схему.
- Redis: rate limits API и realtime/service coordination (**не** OTP counters — они в Keycloak/SPI); - Redis: rate limits API и realtime/service coordination (**не** OTP counters — они в Keycloak/SPI);
- S3-data: production-хранилище проверенных файлов чата (`han-chat-attachments`) и документов компании (`han-chat-documents`). - S3-data: production-хранилище проверенных файлов чата (`han-chat-attachments`) и документов компании (`han-chat-documents`).
- S3-quarantine: временное хранилище загруженных файлов до вердикта Message Safety Service (`han-chat-quarantine`); read-only для `message-safety`. - S3-quarantine: временное хранилище загруженных файлов до вердикта Message Safety Service (`han-chat-quarantine`); read-only для `message-safety`.
@@ -57,7 +58,7 @@ HAN Chat - приложение для мигрантов, где стартов
На первом этапе весь backend-контур работает на **одной VM** в облаке провайдера: На первом этапе весь backend-контур работает на **одной VM** в облаке провайдера:
- `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` — в Docker Compose на VM; - `nginx`, `api-backend`, `message-safety`, `keycloak`, `sms-service`/worker, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` — в Docker Compose на VM;
- публичный доступ из интернета только через `nginx` (порты 80/443); - публичный доступ из интернета только через `nginx` (порты 80/443);
- внутренние сервисы общаются по Docker-сети на localhost VM. - внутренние сервисы общаются по Docker-сети на localhost VM.
@@ -72,6 +73,7 @@ HAN Chat - приложение для мигрантов, где стартов
| одна база / `message_safety` | `message-safety` | verdict cache, safety_task, rule config | | одна база / `message_safety` | `message-safety` | verdict cache, safety_task, rule config |
| одна база / `bitrix_local` | `bitrix-local-app` | OAuth, inbox, `dialog_sessions` | | одна база / `bitrix_local` | `bitrix-local-app` | OAuth, inbox, `dialog_sessions` |
| одна база / `keycloak` | Keycloak | учётные записи, realm, сессии IdP | | одна база / `keycloak` | Keycloak | учётные записи, realm, сессии IdP |
| одна база / `sms` | `sms-service`, `sms-worker` | шаблоны, runtime settings, бессрочный журнал отправки/доставки SMS |
Redis на первом этапе остаётся на VM в Docker (ephemeral/coordination). Selectel S3 — внешнее object storage: три бакета (`han-chat-quarantine`, `han-chat-attachments`, `han-chat-documents`); см. [`arch-00-glossary.md`](arch-00-glossary.md). Redis на первом этапе остаётся на VM в Docker (ephemeral/coordination). Selectel S3 — внешнее object storage: три бакета (`han-chat-quarantine`, `han-chat-attachments`, `han-chat-documents`); см. [`arch-00-glossary.md`](arch-00-glossary.md).
@@ -82,6 +84,9 @@ flowchart LR
Client[Expo Mobile/Web App] Client[Expo Mobile/Web App]
Nginx[Nginx Reverse Proxy] Nginx[Nginx Reverse Proxy]
Keycloak[Keycloak OTP] Keycloak[Keycloak OTP]
SMS[SMS Service]
SMSWorker[SMS Worker]
Direct[i-Digital Direct]
API[Python api-backend] API[Python api-backend]
Safety[Message Safety Service] Safety[Message Safety Service]
DB[(PostgreSQL)] DB[(PostgreSQL)]
@@ -95,8 +100,14 @@ flowchart LR
Client -->|HTTPS REST + Realtime| Nginx Client -->|HTTPS REST + Realtime| Nginx
Nginx -->|/auth| Keycloak Nginx -->|/auth| Keycloak
Nginx -->|exact POST /callbacks/idgtl/sms| SMS
Nginx -->|"/api REST + WS realtime"| API Nginx -->|"/api REST + WS realtime"| API
Keycloak --> DB Keycloak --> DB
Keycloak -->|durable SMS order| SMS
SMS --> DB
SMSWorker --> DB
SMSWorker -->|HTTPS POST /api/v1/message| Direct
Direct -->|delivery callback| Nginx
API --> DB API --> DB
API --> Redis API --> Redis
Client -->|presigned PUT| S3Q Client -->|presigned PUT| S3Q
@@ -222,8 +233,9 @@ Frontend не должен:
Отвечает за: Отвечает за:
- OTP-only регистрацию и вход; - OTP-only регистрацию и вход;
- OTP по номеру телефона; проверка кода — в Keycloak (заглушка `KEYCLOAK_OTP_MOCK_*` или SMS-провайдер, см. arch-04 и «Поток авторизации»); - OTP по номеру телефона; генерация и локальная проверка кода, challenge lifecycle, limits и verify audit — в Keycloak;
- продуктовые лимиты OTP (`otp.phone.*` из `app_settings`) через authenticator/SPI и settings bridge `api-backend` (см. arch-04); счётчики попыток — в зоне Keycloak (Redis DB Keycloak/SPI или in-memory Keycloak), **не** в `api-backend`; - в real mode — заказ в `sms-service` по закрытому `POST /internal/sms/v1/send`; Keycloak ждёт только `200/202` + `sms_message_id`, не вызывает Direct и не читает provider statuses;
- продуктовые лимиты OTP (`otp.phone.*` из `app_settings`) через authenticator/SPI и settings bridge `api-backend` (см. arch-04); durable counters/challenges/events — в provider-owned таблицах schema `keycloak`, **не** в Redis и не в `api-backend`;
- хранение учетных записей; - хранение учетных записей;
- выдачу и обновление токенов (access + refresh); - выдачу и обновление токенов (access + refresh);
- настройку realm, clients, roles, policies; - настройку realm, clients, roles, policies;
@@ -238,7 +250,7 @@ Frontend не должен:
| Expo frontend | Frontend → Keycloak (`/auth/*` через nginx) | OTP login (Authorization Code + PKCE), Refresh Token Grant, logout | | Expo frontend | Frontend → Keycloak (`/auth/*` через nginx) | OTP login (Authorization Code + PKCE), Refresh Token Grant, logout |
| `api-backend` | api-backend → Keycloak JWKS/discovery | Валидация access token (issuer, audience, подпись); **не** вызывает Admin API в hot path | | `api-backend` | api-backend → Keycloak JWKS/discovery | Валидация access token (issuer, audience, подпись); **не** вызывает Admin API в hot path |
| Managed PostgreSQL | Keycloak → схема `keycloak` | Пользователи IdP, сессии, realm | | Managed PostgreSQL | Keycloak → схема `keycloak` | Пользователи IdP, сессии, realm |
| SMS-провайдер | Keycloak → SMS (post-MVP) | Доставка OTP; на MVP — mock code из `.env` | | `sms-service` | Keycloak → `sms-service` (real mode) | Durable order; service token, idempotency key и `sms_message_id` |
Confidential **backend client** Keycloak (client credentials) в MVP **не обязателен**: S2S между нашими сервисами идёт по service tokens, не через Keycloak. Client можно завести заранее в realm как optional для будущих admin/ops сценариев. Confidential **backend client** Keycloak (client credentials) в MVP **не обязателен**: S2S между нашими сервисами идёт по service tokens, не через Keycloak. Client можно завести заранее в realm как optional для будущих admin/ops сценариев.
@@ -253,6 +265,7 @@ Confidential **backend client** Keycloak (client credentials) в MVP **не об
- маршрутизацию `/auth/*` или выделенного auth-домена в Keycloak; - маршрутизацию `/auth/*` или выделенного auth-домена в Keycloak;
- маршрутизацию публичных `/bitrix/*` endpoint в `bitrix-local-app`; - маршрутизацию публичных `/bitrix/*` endpoint в `bitrix-local-app`;
- маршрутизацию `/bitrix/sync/*` webhook endpoint в `bitrix-sync`; - маршрутизацию `/bitrix/sync/*` webhook endpoint в `bitrix-sync`;
- маршрутизацию только exact `POST /callbacks/idgtl/sms` в `sms-service` по HTTPS, с allowlist актуального IP Direct и без логирования Basic Authorization;
- защиту internal endpoint `bitrix-local-app` через private network или `nginx allowlist`; - защиту internal endpoint `bitrix-local-app` через private network или `nginx allowlist`;
- отсутствие публичной маршрутизации к `message-safety` — сервис доступен только из внутренней Docker-сети; - отсутствие публичной маршрутизации к `message-safety` — сервис доступен только из внутренней Docker-сети;
- передачу `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-ID` (если клиент не прислал `X-Request-ID` — nginx **генерирует** UUID и прокидывает upstream); - передачу `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-ID` (если клиент не прислал `X-Request-ID` — nginx **генерирует** UUID и прокидывает upstream);
@@ -381,12 +394,12 @@ api-backend не решает, sync или async нужна проверка в
5. Клиент может опционально согласиться на рекламные коммуникации. 5. Клиент может опционально согласиться на рекламные коммуникации.
6. Если обязательные согласия не даны, отправка блокируется. 6. Если обязательные согласия не даны, отправка блокируется.
7. Frontend запрашивает публичные настройки и показывает форму ввода номера телефона (единственный канал MVP). 7. Frontend запрашивает публичные настройки и показывает форму ввода номера телефона (единственный канал MVP).
8. Keycloak запускает OTP-flow по телефону: клиент вводит номер, инициируется «отправка» OTP (при заглушке SMS фактически не уходит — см. arch-04). 8. Keycloak запускает OTP-flow: в mock mode challenge сразу активен без SMS; в real mode Keycloak создаёт `ordering`, генерирует OTP, заказывает SMS в `sms-service` и активирует challenge только после durable order.
9. Лимиты OTP на **edge**`nginx` (`NGINX_RATE_LIMIT_AUTH`); продуктовые лимиты `otp.phone.*` из `app_settings` применяются на стороне **Keycloak authenticator / SPI** (или обёртки OTP), не в `api-backend`. До интеграции SMS (mock OTP) достаточно edge + mock code. 9. Лимиты OTP на **edge**`nginx`; продуктовые `otp.phone.*` применяет Keycloak. HTTP retry одного durable order использует прежние challenge/idempotency key и не увеличивает send counter.
10. Клиент вводит OTP и отправляет его в Keycloak. 10. Клиент вводит OTP и отправляет его в Keycloak.
11. **Keycloak проверяет корректность введённого OTP**: 11. **Keycloak проверяет корректность введённого OTP**:
- при **`KEYCLOAK_OTP_MOCK_ENABLED=true`** (MVP и любой режим с включённой заглушкой): введённое значение должно **совпадать** с `KEYCLOAK_OTP_MOCK_CODE` из `.env`; - при **`KEYCLOAK_OTP_MOCK_ENABLED=true`** (MVP и любой режим с включённой заглушкой): введённое значение должно **совпадать** с `KEYCLOAK_OTP_MOCK_CODE` из `.env`;
- при **`KEYCLOAK_OTP_MOCK_ENABLED=false`** (после интеграции с SMS-провайдером, см. бэклог): введённое значение должно **совпадать** с одноразовым OTP, сгенерированным Keycloak и отправленным провайдером на телефон клиента (с учётом TTL и лимита попыток). - при **`KEYCLOAK_OTP_MOCK_ENABLED=false`**: значение сверяется локально с HMAC OTP, сгенерированного Keycloak и переданного в закрытом заказе `sms-service`; статусы Direct и callback на verify не влияют.
- при неверном коде Keycloak возвращает ошибку; frontend не получает tokens, шаг 12 не выполняется. - при неверном коде Keycloak возвращает ошибку; frontend не получает tokens, шаг 12 не выполняется.
12. При успешной проверке frontend получает tokens через OIDC Authorization Code Flow with PKCE. 12. При успешной проверке frontend получает tokens через OIDC Authorization Code Flow with PKCE.
13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` → минимальный профиль. 13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` → минимальный профиль.
@@ -539,7 +552,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
### Состав backend-контура ### Состав backend-контура
Минимальный production-like контур на одной VM: `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector`. Managed PostgreSQL и Selectel S3 находятся вне Docker Compose. Минимальный целевой real-SMS контур на одной VM: `nginx`, `api-backend`, `message-safety`, `keycloak`, `sms-service`/worker, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector`. До SMS rollout сервисы SMS могут отсутствовать, но Keycloak обязан оставаться в mock mode.
### Предлагаемая структура backend-репозитория ### Предлагаемая структура backend-репозитория
@@ -583,6 +596,11 @@ backend/
realm/ realm/
themes/ themes/
providers/ providers/
sms-service/
app/
migrations/
openapi.yaml
Dockerfile
redis/ redis/
docker-compose.yml docker-compose.yml
observability/ observability/
+56 -4
View File
@@ -29,11 +29,13 @@
| `BITRIX_API_FORWARD_TOKEN` | — | `bitrix-local-app` (исходящий) | то же | `Authorization: Bearer` | | `BITRIX_API_FORWARD_TOKEN` | — | `bitrix-local-app` (исходящий) | то же | `Authorization: Bearer` |
| `BITRIX_SYNC_SERVICE_TOKEN` | `bitrix-sync` | ops / мониторинг | `GET /internal/sync/v1/*` | `Authorization: Bearer` или `X-Service-Token` | | `BITRIX_SYNC_SERVICE_TOKEN` | `bitrix-sync` | ops / мониторинг | `GET /internal/sync/v1/*` | `Authorization: Bearer` или `X-Service-Token` |
| `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | `api-backend` | Keycloak SPI | `GET /internal/settings/v1/otp` | `Authorization: Bearer` | | `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | `api-backend` | Keycloak SPI | `GET /internal/settings/v1/otp` | `Authorization: Bearer` |
| `SMS_SERVICE_TOKEN` | `sms-service` | Keycloak SPI | `POST/GET /internal/sms/v1/*` | `Authorization: Bearer` |
Пары значений (должны совпадать): Пары значений (должны совпадать):
- `BITRIX_LOCAL_APP_INTERNAL_TOKEN` (api-backend) = `BITRIX_INTERNAL_API_TOKEN` (bitrix-local-app) - `BITRIX_LOCAL_APP_INTERNAL_TOKEN` (api-backend) = `BITRIX_INTERNAL_API_TOKEN` (bitrix-local-app)
- `BITRIX_API_FORWARD_TOKEN` (bitrix-local-app) = `BITRIX_API_INBOX_TOKEN` (api-backend) - `BITRIX_API_FORWARD_TOKEN` (bitrix-local-app) = `BITRIX_API_INBOX_TOKEN` (api-backend)
- `KEYCLOAK_SMS_SERVICE_TOKEN` (Keycloak) = `SMS_SERVICE_TOKEN` (`sms-service`)
Генерация: `openssl rand -hex 32`. Секреты не коммитить. Генерация: `openssl rand -hex 32`. Секреты не коммитить.
@@ -340,6 +342,7 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
| `message-safety` | `message-safety/openapi.yaml` | нет (internal) | | `message-safety` | `message-safety/openapi.yaml` | нет (internal) |
| `bitrix-local-app` | `bitrix-local-app/openapi.yaml` | частично (`/bitrix/*`, health) | | `bitrix-local-app` | `bitrix-local-app/openapi.yaml` | частично (`/bitrix/*`, health) |
| `bitrix-sync` | `bitrix-sync/openapi.yaml` | нет (internal + webhook) | | `bitrix-sync` | `bitrix-sync/openapi.yaml` | нет (internal + webhook) |
| `sms-service` | `sms-service/openapi.yaml` + callback JSON Schema | internal send/read; публичен только exact callback |
Правила: Правила:
@@ -353,9 +356,58 @@ Keycloak SPI получает product limits OTP из `app_settings` через
| Контракт | Владелец | Потребитель | Назначение | Защита | | Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---| |---|---|---|---|---|
| `GET /internal/settings/v1/otp` | `api-backend` | Keycloak SPI | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | | `GET /internal/settings/v1/otp` | `api-backend` | Keycloak SPI | OTP limits + `code_length`, `ttl_seconds`, `sms_order_timeout_ms`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` |
Ответ не содержит секретов и PII. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи OTP. Ответ:
```json
{
"max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "2026-07-22T14:00:00Z",
"cache_ttl_seconds": 60
}
```
Ответ не содержит секретов и PII. Challenge сохраняет snapshot `code_length`, `ttl_seconds` и `version`. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи нового OTP.
## Keycloak SPI ↔ `sms-service`
Контракт действует в real mode; в mock mode Keycloak не вызывает `sms-service`. API доступен только в закрытой сети `backend`, Bearer token — парные `KEYCLOAK_SMS_SERVICE_TOKEN`/`SMS_SERVICE_TOKEN`. Caller v1 фиксирован как `keycloak`, process/template — `auth_otp`, channel — `SMS`, provider — `idgtl`; эти поля не доверяются request body.
### `POST /internal/sms/v1/send`
```json
{
"idempotency_key": "keycloak:challenge:<CHALLENGE_ID>",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": "+79001234567",
"substitutions": {"code": "<OTP>", "ttl_min": "<TTL_MIN>"},
"customer_ref": "<CHALLENGE_ID>",
"message_ttl_sec": 60
}
```
- Строгая проверка E.164, TTL Direct `60..86400`, locale и точного набора placeholders; неизвестный/пропущенный placeholder → `422 sms_request_invalid`.
- В одной transaction рендерится active approved `sms_template` и создаётся `sms_outbound_message` (`pending`/`unknown`); внешний Direct API в request handler не вызывается.
- Новый durable order → `202` с `sms_message_id`, `ordered_at`; идемпотентный повтор с тем же fingerprint → `200` и тот же id; тот же key с другим payload → `409 idempotency_key_reused`.
- Остальные коды: `401 unauthorized`, `429 rate_limit_exceeded`, `503 sms_service_unavailable`; envelope общий для arch-02.
- Keycloak считает заказ успешным только при `200/202` и валидном `sms_message_id`, сохраняет его в challenge/event и не запрашивает provider status.
### `GET /internal/sms/v1/messages/{sms_message_id}`
Диагностический read для Keycloak только по собственному `requester_service`. Телефон всегда masked; OTP, substitutions и `body_rendered` не возвращаются.
### `POST /callbacks/idgtl/sms`
Единственный публичный SMS endpoint. Только HTTPS и POST через root nginx; source IP `185.203.96.7` повторно сверяется перед production, применяется allowlist. Direct передаёт Basic auth, проверяемый `sms-service` по `IDGTL_SMS_CALLBACK_USERNAME`/`IDGTL_SMS_CALLBACK_PASSWORD`; credentials/Authorization не логируются.
Callback body — массив; items валидируются и дедуплицируются по `(message_uuid, callback_event, status, status_time)`. Повторы и out-of-order события ожидаемы. Callback обновляет только delivery fields журнала после DB commit, не уведомляет Keycloak и не влияет на OTP verify. Transient DB failure → 5xx для повтора Direct.
## Frontend ↔ Keycloak ## Frontend ↔ Keycloak
@@ -368,14 +420,14 @@ Keycloak **обязателен** в production-like контуре с перв
| OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens | | OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens |
| OIDC Discovery (`/.well-known/openid-configuration`) | Keycloak | Expo frontend, `api-backend` | issuer, token/jwks endpoints | | OIDC Discovery (`/.well-known/openid-configuration`) | Keycloak | Expo frontend, `api-backend` | issuer, token/jwks endpoints |
| JWKS | Keycloak | `api-backend` | Проверка подписи access token (issuer, audience, exp) | | JWKS | Keycloak | `api-backend` | Проверка подписи access token (issuer, audience, exp) |
| OTP authenticator / SPI | Keycloak | — | Проверка OTP; product limits `otp.phone.*`; mock или SMS | | OTP authenticator / SPI | Keycloak | — | Генерация/локальная проверка OTP, product limits, challenge lifecycle и вызов `sms-service` в real mode |
| PostgreSQL schema `keycloak` | Keycloak | Managed PostgreSQL | Учётные записи IdP | | PostgreSQL schema `keycloak` | Keycloak | Managed PostgreSQL | Учётные записи IdP |
Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена. Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена.
**`api-backend` ↔ Keycloak:** только **валидация JWT** по JWKS/discovery (кэш ключей). Admin REST / User API Keycloak в hot path **не** используются. Телефон и `sub` для `bootstrap` берутся из claims access token. **`api-backend` ↔ Keycloak:** только **валидация JWT** по JWKS/discovery (кэш ключей). Admin REST / User API Keycloak в hot path **не** используются. Телефон и `sub` для `bootstrap` берутся из claims access token.
**OTP (Keycloak):** единственный канал первичной авторизации — телефон. OTP-flow нужен, когда refresh token отсутствует или истёк. При действующем refresh token frontend использует **Refresh Token Grant** и не показывает OTP. После ввода кода **Keycloak проверяет OTP**: при `KEYCLOAK_OTP_MOCK_ENABLED=true` — сверка с `KEYCLOAK_OTP_MOCK_CODE` (`.env`); при `false` — сверка с OTP от SMS-провайдера (post-MVP, [`!Backlog.md`](../../HAN_chat/!Backlog.md)). Счётчики и product limits OTP — **только** Keycloak/SPI (+ nginx edge); `api-backend` OTP **не** проверяет и **не** ведёт OTP counters в Redis. **OTP (Keycloak):** единственный канал первичной авторизации — телефон. При действующем refresh token OTP не показывается. Keycloak всегда является источником истины verify: mock сравнивает secret-код, real mode — локальный HMAC случайного OTP. `sms-service` только принимает durable order, рендерит шаблон, отправляет через Direct worker и ведёт provider journal. API верификации Direct `/verifier/send` и `/verifier/check` запрещён. Счётчики и product limits только Keycloak/SPI (+ nginx edge).
**Clients в realm (MVP):** **Clients в realm (MVP):**
@@ -21,8 +21,9 @@
- `/auth/*``keycloak`; - `/auth/*``keycloak`;
- `/bitrix/*` (public: `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement`, `/health/*` для `bitrix-local-app`) → `bitrix-local-app`; - `/bitrix/*` (public: `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement`, `/health/*` для `bitrix-local-app`) → `bitrix-local-app`;
- `/bitrix/sync/*` (public: webhook CRM sync для `bitrix-sync`) → `bitrix-sync`; - `/bitrix/sync/*` (public: webhook CRM sync для `bitrix-sync`) → `bitrix-sync`;
- exact `POST /callbacks/idgtl/sms``sms-service`; остальные методы и SMS paths не публикуются;
- web-сборка frontend или прокси на dev-сервер; - web-сборка frontend или прокси на dev-сервер;
- `/internal/openlines/*`, `/internal/safety/*`, `/internal/sync/*` **не публикуются** наружу — доступны только из внутренней Docker-сети. - `/internal/openlines/*`, `/internal/safety/*`, `/internal/sync/*`, `/internal/sms/*` **не публикуются** наружу — доступны только из внутренней Docker-сети.
- Никакой другой `nginx` (ни в контейнере сервиса, ни на хосте) не терминирует внешний HTTPS для backend-контура. Site-конфиг `tohin.ru` на хосте, если используется, должен проксировать весь трафик на корневой `nginx` контейнера, а не на порты отдельных сервисов напрямую. - Никакой другой `nginx` (ни в контейнере сервиса, ни на хосте) не терминирует внешний HTTPS для backend-контура. Site-конфиг `tohin.ru` на хосте, если используется, должен проксировать весь трафик на корневой `nginx` контейнера, а не на порты отдельных сервисов напрямую.
### Структура compose через `include` ### Структура compose через `include`
@@ -65,12 +66,14 @@ include:
- bitrix-sync/docker-compose.yml - bitrix-sync/docker-compose.yml
- bitrix-local-app/docker-compose.yml - bitrix-local-app/docker-compose.yml
- keycloak/docker-compose.yml - keycloak/docker-compose.yml
- sms-service/docker-compose.yml
- redis/docker-compose.yml - redis/docker-compose.yml
- observability/docker-compose.yml - observability/docker-compose.yml
networks: networks:
public: public:
backend: backend:
egress:
observability: observability:
volumes: volumes:
@@ -122,6 +125,7 @@ Reverse proxy и единственная публичная точка вход
- маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен; - маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен;
- маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`; - маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`;
- маршрутизирует `/bitrix/sync/*` webhook endpoint в `bitrix-sync`; - маршрутизирует `/bitrix/sync/*` webhook endpoint в `bitrix-sync`;
- маршрутизирует только exact `POST /callbacks/idgtl/sms` в `sms-service:8080`; применяет HTTPS, подтверждённый allowlist source IP Direct, body/rate limits и redaction Basic Authorization;
- закрывает `/internal/*` (в т.ч. `bitrix-local-app`, `message-safety`, `bitrix-sync` ops) от публичного доступа — только private network Docker/VPC; - закрывает `/internal/*` (в т.ч. `bitrix-local-app`, `message-safety`, `bitrix-sync` ops) от публичного доступа — только private network Docker/VPC;
- **не публикует** `message-safety` наружу; - **не публикует** `message-safety` наружу;
- **production-like / production**: отдаёт **статическую сборку Expo web** из volume или каталога (`/usr/share/nginx/html` или аналог); `index.html` + assets, SPA fallback `try_files $uri /index.html`; - **production-like / production**: отдаёт **статическую сборку Expo web** из volume или каталога (`/usr/share/nginx/html` или аналог); `index.html` + assets, SPA fallback `try_files $uri /index.html`;
@@ -217,7 +221,7 @@ Python worker/service **двусторонней** синхронизации Ap
Требования: Требования:
- подключение только из приватной сети VPC (VM → managed PostgreSQL); - подключение только из приватной сети VPC (VM → managed PostgreSQL);
- одна managed база: схемы `han_app`, `bitrix_sync`, `message_safety`, `bitrix_local`, `keycloak`; - одна managed база: схемы `han_app`, `bitrix_sync`, `message_safety`, `bitrix_local`, `keycloak`, `sms`;
- отдельные DB-пользователи с доступом только к своей схеме; исключение: `bitrix_sync_user` дополнительно имеет ограниченный GRANT на `han_app` (`sync_queue`, `entity_external_mapping`, tracked columns профиля — детали схемы TBD в спецификации database); - отдельные DB-пользователи с доступом только к своей схеме; исключение: `bitrix_sync_user` дополнительно имеет ограниченный GRANT на `han_app` (`sync_queue`, `entity_external_mapping`, tracked columns профиля — детали схемы TBD в спецификации database);
- TLS к managed PostgreSQL обязателен; - TLS к managed PostgreSQL обязателен;
- миграции Alembic выполняются отдельной командой при деплое; - миграции Alembic выполняются отдельной командой при деплое;
@@ -236,10 +240,19 @@ Identity provider. **Обязателен** в compose-контуре с пер
- включены proxy settings для работы за `nginx`; - включены proxy settings для работы за `nginx`;
- импорт realm в local/dev; - импорт realm в local/dev;
- использует managed PostgreSQL, схема `keycloak` (см. раздел «Managed PostgreSQL» выше); - использует managed PostgreSQL, схема `keycloak` (см. раздел «Managed PostgreSQL» выше);
- OTP mock / SMS SPI — см. arch-04; - OTP mock / SMS SPI — см. arch-04; real mode вызывает только `sms-service` по сети `backend`, сам Keycloak к Direct/`egress` не подключён;
- healthcheck; - healthcheck;
- взаимодействия — [`arch-02-api-contracts.md`](arch-02-api-contracts.md), «Frontend ↔ Keycloak», и [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Keycloak». - взаимодействия — [`arch-02-api-contracts.md`](arch-02-api-contracts.md), «Frontend ↔ Keycloak», и [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Keycloak».
### sms-service и sms-worker
- `sms-service`: networks `backend`, `observability` и `egress` только если тот же process принимает callback и выполняет worker; `expose: 8080`, без host `ports`.
- При отдельном `sms-worker`: networks только `egress`, `observability` и доступ к managed PG; HTTP port не exposed/published.
- Оба используют `SMS_DATABASE_URL` к schema `sms`; только worker получает `IDGTL_SMS_API_KEY`.
- Callback credentials получает receiver для проверки и worker для формирования callback URL; Keycloak получает только `KEYCLOAK_SMS_SERVICE_TOKEN`.
- `sms-service` применяет собственные versioned migrations/seed; DDL-on-start запрещён. Readiness проверяет DB/schema, active approved `auth_otp` template, sender и API-key configuration.
- Ожидание Direct до 70 секунд происходит только в worker. `uncertain` не retry-ится автоматически; provider outage не создаёт restart loop и не отменяет active Keycloak challenge.
### redis ### redis
Кэш, rate limiting, coordination (не единственное хранилище бизнес-событий). Кэш, rate limiting, coordination (не единственное хранилище бизнес-событий).
@@ -269,6 +282,7 @@ Identity provider. **Обязателен** в compose-контуре с пер
- `public`: `nginx`, `keycloak` (для прокси `/auth/*`), frontend static/dev access, внешний HTTPS entrypoint. - `public`: `nginx`, `keycloak` (для прокси `/auth/*`), frontend static/dev access, внешний HTTPS entrypoint.
- `backend`: `api-backend`, `message-safety`, `bitrix-sync`, `bitrix-local-app`, `keycloak`, `redis` (managed PostgreSQL — вне compose, в VPC). - `backend`: `api-backend`, `message-safety`, `bitrix-sync`, `bitrix-local-app`, `keycloak`, `redis` (managed PostgreSQL — вне compose, в VPC).
- `egress`: только сервисы с утверждёнными исходящими интеграциями; для SMS — `sms-worker`, но не Keycloak. Production real mode требует фактический статический egress IP/NAT, записанный в inventory и переданный Direct для allowlist.
- `observability`: `otel-collector` + сервисы, экспортирующие telemetry. - `observability`: `otel-collector` + сервисы, экспортирующие telemetry.
Базы данных, Redis, Keycloak internal port и API internal port не должны публиковаться наружу. `message-safety` доступен только внутри сети `backend`. Основной пользовательский путь должен идти через `nginx` и HTTPS. Базы данных, Redis, Keycloak internal port и API internal port не должны публиковаться наружу. `message-safety` доступен только внутри сети `backend`. Основной пользовательский путь должен идти через `nginx` и HTTPS.
@@ -406,6 +420,7 @@ WAF не заменяет обязательные лимиты, валидац
- `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL, доступ к `sync_queue`, worker state и CRM webhook config; при `BITRIX_SYNC_ENABLED=false` ready возвращает degraded/not-ready с причиной `sync_disabled`; - `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL, доступ к `sync_queue`, worker state и CRM webhook config; при `BITRIX_SYNC_ENABLED=false` ready возвращает degraded/not-ready с причиной `sync_disabled`;
- `bitrix-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`; - `bitrix-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`;
- `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL; - `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL;
- `sms-service`: live — процесс; ready — schema/migrations, active approved template, sender/API key; Direct доступность — отдельный dependency status, не причина restart loop;
- `redis`: `redis-cli ping`; - `redis`: `redis-cli ping`;
Наружу через `nginx` публикуются только health endpoint, которые нужны Bitrix24 install/callback validation или внешнему мониторингу. Internal services (`message-safety`, internal `bitrix-sync`, Redis, otel) проверяются только из Docker/VPC-сети. Наружу через `nginx` публикуются только health endpoint, которые нужны Bitrix24 install/callback validation или внешнему мониторингу. Internal services (`message-safety`, internal `bitrix-sync`, Redis, otel) проверяются только из Docker/VPC-сети.
@@ -413,13 +428,16 @@ WAF не заменяет обязательные лимиты, валидац
## Порядок запуска ## Порядок запуска
1. `redis` (managed PostgreSQL должна быть доступна до старта зависимых сервисов). 1. `redis` (managed PostgreSQL должна быть доступна до старта зависимых сервисов).
2. `keycloak`. 2. `otel-collector`.
3. `otel-collector`. 3. `api-backend` и seed OTP settings.
4. `message-safety`. 4. `sms-service`/worker после migrations/seed (Keycloak пока mock).
5. `api-backend`. 5. `keycloak`.
6. `bitrix-local-app`. 6. `message-safety`.
7. `bitrix-sync`. 7. `bitrix-local-app`.
8. `nginx`. 8. `bitrix-sync`.
9. `nginx`.
Порядок rollout SMS подробнее задаёт module-11/module-10. Зависимости запуска не образуют цикл: Keycloak стартует при недоступном `sms-service`; это блокирует только новые real-mode orders, а verify уже active challenges продолжается по snapshot.
`depends_on` не заменяет проверку готовности. Сервисы должны уметь ждать зависимости или корректно завершаться с понятной ошибкой. `api-backend` должен ждать готовности `message-safety` (healthcheck), т.к. отправка сообщения синхронно зависит от `POST /internal/safety/v1/messages/check`. `depends_on` не заменяет проверку готовности. Сервисы должны уметь ждать зависимости или корректно завершаться с понятной ошибкой. `api-backend` должен ждать готовности `message-safety` (healthcheck), т.к. отправка сообщения синхронно зависит от `POST /internal/safety/v1/messages/check`.
+47 -7
View File
@@ -10,6 +10,7 @@
|---|---|---| |---|---|---|
| **Инфраструктура** | `.env` | подключения, URL, секреты, nginx/TLS, service tokens | | **Инфраструктура** | `.env` | подключения, URL, секреты, nginx/TLS, service tokens |
| **Бизнес-логика** | таблица **`app_settings`** | лимиты, флаги, телефоны, типы файлов, CORS, consent URLs | | **Бизнес-логика** | таблица **`app_settings`** | лимиты, флаги, телефоны, типы файлов, CORS, consent URLs |
| **Настройки SMS runtime** | таблица **`sms.sms_setting`** | sender default, provider timeouts, callback flag, worker intervals |
| **Контент** | `text_resources`, `popular_questions` | тексты UI | | **Контент** | `text_resources`, `popular_questions` | тексты UI |
Managed PostgreSQL **поднимается до** развёртывания приложения. Бизнес-настройки **не дублируются** в `.env`: seed в `app_settings` выполняется миграцией/скриптом модуля `database` **до** первого запуска `api-backend`. Managed PostgreSQL **поднимается до** развёртывания приложения. Бизнес-настройки **не дублируются** в `.env`: seed в `app_settings` выполняется миграцией/скриптом модуля `database` **до** первого запуска `api-backend`.
@@ -27,8 +28,8 @@ Managed PostgreSQL **поднимается до** развёртывания п
- секреты: S3, Bitrix OAuth, service tokens, webhook-тokens; - секреты: S3, Bitrix OAuth, service tokens, webhook-тokens;
- параметры **nginx/TLS** и edge rate limits (`NGINX_RATE_LIMIT_*`); - параметры **nginx/TLS** и edge rate limits (`NGINX_RATE_LIMIT_*`);
- идентификация Keycloak: realm, audience, public/internal URL; - идентификация Keycloak: realm, audience, public/internal URL;
- **OTP-заглушка MVP** (`KEYCLOAK_OTP_MOCK_*`) — infra/dev-секрет, не бизнес-настройка; - переключатель и секрет временного OTP mock (`KEYCLOAK_OTP_MOCK_*`); mock обязателен до прохождения real-SMS rollout gates и запрещён как незаявленный fallback;
- технические таймауты worker-ов (`MESSAGE_SAFETY_*`, интервалы `bitrix-sync`). - технические параметры сервисов, пока профильная спецификация не определила service-owned settings; для `sms-service` runtime-параметры уже вынесены в `sms.sms_setting`.
**Запрещено в `.env` (→ только `app_settings`):** **Запрещено в `.env` (→ только `app_settings`):**
@@ -85,7 +86,7 @@ Managed PostgreSQL **поднимается до** развёртывания п
| Группа | Ключи | | Группа | Ключи |
|---|---| |---|---|
| Auth | `auth.phone.enabled`, `auth.password.enabled` | | Auth | `auth.phone.enabled`, `auth.password.enabled` |
| OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts` | | OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts`, `otp.phone.code_length`, `otp.phone.ttl_seconds`, `otp.phone.sms_order_timeout_ms` |
| Оператор | `operator.call.phone` | | Оператор | `operator.call.phone` |
| Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` | | Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` |
| Файлы чата | `chat.attachments.*` | | Файлы чата | `chat.attachments.*` |
@@ -102,6 +103,9 @@ auth.password.enabled=false
otp.phone.max_send_attempts_per_24h=3 otp.phone.max_send_attempts_per_24h=3
otp.phone.min_seconds_between_attempts=30 otp.phone.min_seconds_between_attempts=30
otp.phone.max_verify_attempts=5 otp.phone.max_verify_attempts=5
otp.phone.code_length=6
otp.phone.ttl_seconds=60
otp.phone.sms_order_timeout_ms=3000
operator.call.phone=+74999591007 operator.call.phone=+74999591007
@@ -137,6 +141,27 @@ security.public_cache.max_age_seconds=3600
--- ---
## Service-owned настройки `sms-service`
Параметры, изменение которых не меняет Compose, секреты, URL или сетевую топологию, хранятся в `sms.sms_setting`, а не в `.env`.
Ключи и seed:
```text
provider.idgtl.default_sender_name=<approved>
provider.idgtl.connect_timeout_ms=3000
provider.idgtl.request_timeout_ms=70000
provider.idgtl.callback_enabled=true
worker.poll_interval_ms=500
worker.lease_seconds=90
```
В `.env` остаются только `SMS_DATABASE_URL`, URL внутренних/внешних сервисов, service tokens, Direct API key и callback credentials. Детальный контракт — `module-11-idgtl-sms.md`.
`<approved>` — обязательный deployment placeholder, а не допустимое production-значение. Перед real mode должны существовать active approved template `auth_otp` с точными placeholders `code`/`ttl_min` и согласованный `senderName`. Отсутствие template/sender делает readiness false.
---
## Пример `.env.example` ## Пример `.env.example`
Только инфраструктура. Бизнес-параметры — в seed `app_settings`. Только инфраструктура. Бизнес-параметры — в seed `app_settings`.
@@ -161,6 +186,7 @@ BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@<HAN_PG_HOST>:<HAN_P
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE> BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE> BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE> MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
SMS_DATABASE_URL=postgresql://sms_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
KEYCLOAK_DB_URL=jdbc:postgresql://<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?user=keycloak_user&password=change-me&currentSchema=keycloak KEYCLOAK_DB_URL=jdbc:postgresql://<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?user=keycloak_user&password=change-me&currentSchema=keycloak
KC_DB_URL_PROPERTIES=currentSchema=keycloak KC_DB_URL_PROPERTIES=currentSchema=keycloak
# Selectel PgBouncer 5433: pool_mode=session; search_path задаётся на уровне ролей. # Selectel PgBouncer 5433: pool_mode=session; search_path задаётся на уровне ролей.
@@ -190,7 +216,7 @@ NGINX_RATE_LIMIT_PUBLIC=60r/m
NGINX_RATE_LIMIT_POLLING=60r/m NGINX_RATE_LIMIT_POLLING=60r/m
# ============================================================================= # =============================================================================
# Keycloak (infra; OTP-заглушка — dev/MVP) # Keycloak (mock остаётся true до controlled SMS cutover)
# ============================================================================= # =============================================================================
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
KEYCLOAK_INTERNAL_URL=http://keycloak:8080 KEYCLOAK_INTERNAL_URL=http://keycloak:8080
@@ -198,6 +224,7 @@ KEYCLOAK_REALM=han-chat
KEYCLOAK_AUDIENCE=han-chat-api KEYCLOAK_AUDIENCE=han-chat-api
KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=1234 KEYCLOAK_OTP_MOCK_CODE=1234
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
# ============================================================================= # =============================================================================
# Redis (I4: раздельные DB index) # Redis (I4: раздельные DB index)
@@ -219,6 +246,17 @@ BITRIX_INTERNAL_API_TOKEN=change-me
BITRIX_API_FORWARD_TOKEN=change-me BITRIX_API_FORWARD_TOKEN=change-me
BITRIX_SYNC_SERVICE_TOKEN=change-me BITRIX_SYNC_SERVICE_TOKEN=change-me
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
SMS_SERVICE_TOKEN=change-me
KEYCLOAK_SMS_SERVICE_TOKEN=change-me
# =============================================================================
# SMS provider (URL и секреты; runtime-параметры — sms.sms_setting)
# =============================================================================
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=change-me
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://tohin.ru/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=change-me
IDGTL_SMS_CALLBACK_PASSWORD=change-me
# ============================================================================= # =============================================================================
# api-backend (интеграции + resilience I2) # api-backend (интеграции + resilience I2)
@@ -297,6 +335,8 @@ presigned URL и CORS Selectel; path-style адресация не поддер
Все переменные — **только** в `backend/.env`. Отдельного хранилища нет. Все переменные — **только** в `backend/.env`. Отдельного хранилища нет.
Для production `change-me`, `<...>`, примерные sender/template/API key/callback credentials отклоняются `validate-env`. `IDGTL_SMS_API_KEY` — выданный Direct готовый `TOKEN_1` для Basic, без повторного Base64. Реальный статический egress IP хранится в deployment inventory, а не env; если он не обеспечен NAT/сетевой конфигурацией, `KEYCLOAK_OTP_MOCK_ENABLED=false` запрещён.
**Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`. **Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`.
## Namespace переменных Bitrix ## Namespace переменных Bitrix
@@ -317,16 +357,16 @@ presigned URL и CORS Selectel; path-style адресация не поддер
## Keycloak settings bridge для OTP ## Keycloak settings bridge для OTP
Product limits OTP (`otp.phone.*`) хранятся в `app_settings`, но Keycloak не получает прямой доступ к схеме `han_app`. OTP settings (`otp.phone.*`) хранятся в `app_settings`, но Keycloak не получает прямой доступ к схеме `han_app`.
MVP-механизм: MVP-механизм:
1. `api-backend` читает публичные/служебные настройки из `app_settings` и кэширует их. 1. `api-backend` читает публичные/служебные настройки из `app_settings` и кэширует их.
2. Для Keycloak SPI доступен internal endpoint `GET /internal/settings/v1/otp` в Docker/VPC-сети, защищённый service token. 2. Для Keycloak SPI доступен internal endpoint `GET /internal/settings/v1/otp` в Docker/VPC-сети, защищённый service token.
3. Keycloak SPI читает `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts` и `otp.phone.max_verify_attempts` через этот endpoint с локальным cache TTL. 3. Keycloak SPI читает limits, `otp.phone.code_length`, `otp.phone.ttl_seconds` и `otp.phone.sms_order_timeout_ms` через этот endpoint с локальным cache.
4. При недоступности settings bridge SPI использует последнее валидное cache-значение; если cache пустой — fail-closed и не выдаёт OTP. 4. При недоступности settings bridge SPI использует последнее валидное cache-значение; если cache пустой — fail-closed и не выдаёт OTP.
Счётчики попыток OTP остаются в зоне Keycloak/SPI, не в `api-backend`. Challenge сохраняет snapshot TTL, длины кода и `settings_version`; изменение settings влияет только на новые challenges. Счётчики попыток OTP остаются в зоне Keycloak/SPI, не в `api-backend`.
## Разрешённые типы файлов чата (MVP) ## Разрешённые типы файлов чата (MVP)
+12 -3
View File
@@ -10,12 +10,21 @@
9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007) 9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts). 10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
11. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. 11. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
12. 12. Яндекс.капчу добавить
13. На экране профиля в гостевом режиме добавить "Авторизоваться"
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
16. Сделать тестового пользователя с фиксированным СМС-входом
17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
На будущее (после доработки отдельных функциональностей): На будущее (после доработки отдельных функциональностей):
1. Разработка message-safety 1. Разработка message-safety
2. Разработка sync-service 2. Разработка sync-service
3. Интеграция с СМС-провайдером 3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?) 3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
4. Моделирование профиля клиента/ 4. Моделирование профиля клиента/
5. Моделирование уведомлений.
На анализ:
debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
+16 -2
View File
@@ -10,6 +10,7 @@ MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
KEYCLOAK_IMAGE=han-chat-keycloak:local KEYCLOAK_IMAGE=han-chat-keycloak:local
SMS_SERVICE_IMAGE=han-chat-sms-service:local
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS. # Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
HAN_PG_HOST=managed-pg.private.example HAN_PG_HOST=managed-pg.private.example
@@ -22,6 +23,7 @@ BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.e
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
SMS_DATABASE_URL=postgresql+asyncpg://sms_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_SCHEMA=keycloak KEYCLOAK_DB_SCHEMA=keycloak
KEYCLOAK_DB_USERNAME=keycloak_user KEYCLOAK_DB_USERNAME=keycloak_user
@@ -40,11 +42,12 @@ NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
NGINX_HSTS_MAX_AGE=0 NGINX_HSTS_MAX_AGE=0
NGINX_CLIENT_MAX_BODY_SIZE=8m NGINX_CLIENT_MAX_BODY_SIZE=8m
NGINX_RATE_LIMIT_API=60r/m NGINX_RATE_LIMIT_API=60r/m
NGINX_RATE_LIMIT_AUTH=10r/m NGINX_RATE_LIMIT_AUTH=60r/m
NGINX_RATE_LIMIT_PUBLIC=60r/m NGINX_RATE_LIMIT_PUBLIC=60r/m
NGINX_RATE_LIMIT_POLLING=60r/m NGINX_RATE_LIMIT_POLLING=60r/m
NGINX_RATE_LIMIT_DOWNLOADS=30r/m NGINX_RATE_LIMIT_DOWNLOADS=30r/m
NGINX_RATE_LIMIT_BITRIX=120r/m NGINX_RATE_LIMIT_BITRIX=120r/m
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
NGINX_RATE_LIMIT_WS=30r/m NGINX_RATE_LIMIT_WS=30r/m
NGINX_MESSAGE_READ_TIMEOUT_SEC=330 NGINX_MESSAGE_READ_TIMEOUT_SEC=330
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32 NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
@@ -67,9 +70,11 @@ KEYCLOAK_OTP_MOCK_CODE=change-me
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
# (openssl rand -hex 32) # (openssl rand -hex 32)
KEYCLOAK_OTP_HMAC_KEY=change-me KEYCLOAK_OTP_HMAC_KEY=change-me
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300 KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
# Должен совпадать с SMS_SERVICE_TOKEN.
KEYCLOAK_SMS_SERVICE_TOKEN=change-me
KEYCLOAK_ADMIN=bootstrap-admin KEYCLOAK_ADMIN=bootstrap-admin
# (openssl rand -hex 32) # (openssl rand -hex 32)
KEYCLOAK_ADMIN_PASSWORD=change-me KEYCLOAK_ADMIN_PASSWORD=change-me
@@ -101,6 +106,15 @@ BITRIX_API_INBOX_TOKEN=change-me
BITRIX_SYNC_SERVICE_TOKEN=change-me BITRIX_SYNC_SERVICE_TOKEN=change-me
#token5 (openssl rand -hex 32) #token5 (openssl rand -hex 32)
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
#token6 (openssl rand -hex 32), должен совпадать с KEYCLOAK_SMS_SERVICE_TOKEN
SMS_SERVICE_TOKEN=change-me
# i-Digital Direct. Перед production заменить placeholders согласованными значениями.
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=change-me
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=change-me
IDGTL_SMS_CALLBACK_PASSWORD=change-me
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080 BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
@@ -0,0 +1,37 @@
"""Seed runtime OTP settings.
Revision ID: 0005_otp_settings
Revises: 0004_device_otp
Create Date: 2026-07-22
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0005_otp_settings"
down_revision: str | None = "0004_device_otp"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('otp.phone.code_length', '6', 'integer', false,
'Length of the numeric phone OTP', 'A', now()),
('otp.phone.ttl_seconds', '60', 'integer', false,
'Phone OTP lifetime from durable order time', 'A', now()),
('otp.phone.sms_order_timeout_ms', '3000', 'integer', false,
'Keycloak timeout for durable SMS order creation', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("OTP runtime settings migration is forward-only")
@@ -10,6 +10,7 @@ from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from app.db import AppSetting, Database from app.db import AppSetting, Database
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings from app.settings import get_settings
VALUE_TYPES = {"boolean", "integer", "string", "string_list"} VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
@@ -32,8 +33,12 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
value_type = raw.get("type") value_type = raw.get("type")
if value_type not in VALUE_TYPES: if value_type not in VALUE_TYPES:
raise ValueError(f"{key}: unsupported type {value_type!r}") raise ValueError(f"{key}: unsupported type {value_type!r}")
if key in OTP_SETTING_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if not isinstance(raw.get("public"), bool): if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean") raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
description = raw.get("description") description = raw.get("description")
if description is not None and not isinstance(description, str): if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string") raise ValueError(f"{key}: description must be a string")
@@ -47,6 +52,7 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
"record_status": "A", "record_status": "A",
} }
) )
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
return rows return rows
+11 -2
View File
@@ -58,6 +58,7 @@ from app.schemas import (
ConsentsRequest, ConsentsRequest,
MessageRequest, MessageRequest,
OpenLinesInbox, OpenLinesInbox,
OtpSettingsResponse,
SessionStartRequest, SessionStartRequest,
decode_cursor, decode_cursor,
encode_cursor, encode_cursor,
@@ -415,7 +416,7 @@ async def ready(request: Request, db: Session):
try: try:
await db.execute(text("SELECT 1")) await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1")) revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0004_device_otp": if revision != "0005_otp_settings":
raise RuntimeError("unexpected database revision") raise RuntimeError("unexpected database revision")
await load_settings(db) await load_settings(db)
components["postgres"] = "ok" components["postgres"] = "ok"
@@ -963,7 +964,12 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
return JSONResponse(body, status_code=status) return JSONResponse(body, status_code=status)
@app.get("/internal/settings/v1/otp", tags=["internal"]) @app.get(
"/internal/settings/v1/otp",
tags=["internal"],
response_model=OtpSettingsResponse,
responses={304: {"description": "Cached settings are still current"}},
)
async def otp_settings( async def otp_settings(
request: Request, request: Request,
settings: SnapshotDep, settings: SnapshotDep,
@@ -980,6 +986,9 @@ async def otp_settings(
"max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"), "max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"),
"min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"), "min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"),
"max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"), "max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"),
"code_length": settings.integer("otp.phone.code_length"),
"ttl_seconds": settings.integer("otp.phone.ttl_seconds"),
"sms_order_timeout_ms": settings.integer("otp.phone.sms_order_timeout_ms"),
"version": settings.version, "version": settings.version,
"cache_ttl_seconds": 60, "cache_ttl_seconds": 60,
}, headers=headers) }, headers=headers)
@@ -0,0 +1,44 @@
from collections.abc import Mapping
OTP_SETTING_KEYS = {
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"otp.phone.max_verify_attempts",
"otp.phone.code_length",
"otp.phone.ttl_seconds",
"otp.phone.sms_order_timeout_ms",
}
def validate_otp_settings(values: Mapping[str, str]) -> None:
parsed: dict[str, int] = {}
for key in OTP_SETTING_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{key}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{key}: canonical integer value expected")
parsed[key] = value
positive = OTP_SETTING_KEYS - {"otp.phone.min_seconds_between_attempts"}
for key in positive:
if key in parsed and parsed[key] <= 0:
raise ValueError(f"{key}: value must be positive")
if parsed.get("otp.phone.min_seconds_between_attempts", 0) < 0:
raise ValueError("otp.phone.min_seconds_between_attempts: value must be non-negative")
code_length = parsed.get("otp.phone.code_length")
if code_length is not None and not 4 <= code_length <= 10:
raise ValueError("otp.phone.code_length: value must be between 4 and 10")
ttl_seconds = parsed.get("otp.phone.ttl_seconds")
if ttl_seconds is not None and (
not 60 <= ttl_seconds <= 900 or ttl_seconds % 60 != 0
):
raise ValueError(
"otp.phone.ttl_seconds: value must be between 60 and 900 and divisible by 60"
)
@@ -14,6 +14,17 @@ class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
class OtpSettingsResponse(StrictModel):
max_send_attempts_per_24h: int = Field(strict=True, gt=0)
min_seconds_between_attempts: int = Field(strict=True, ge=0)
max_verify_attempts: int = Field(strict=True, gt=0)
code_length: int = Field(strict=True, ge=4, le=10)
ttl_seconds: int = Field(strict=True, ge=60, le=900, multiple_of=60)
sms_order_timeout_ms: int = Field(strict=True, gt=0)
version: str = Field(min_length=1, max_length=64)
cache_ttl_seconds: int = Field(strict=True, gt=0)
class Device(StrictModel): class Device(StrictModel):
platform: Literal["ios", "android", "web"] platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64) app_version: str = Field(min_length=1, max_length=64)
+26 -4
View File
@@ -37,6 +37,7 @@ from app.integrations import (
SafetyClient, SafetyClient,
fresh_openlines_payload, fresh_openlines_payload,
) )
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.realtime import RealtimeFanout from app.realtime import RealtimeFanout
from app.schemas import ( from app.schemas import (
AttachmentCompleteRequest, AttachmentCompleteRequest,
@@ -84,7 +85,7 @@ REQUIRED_SETTINGS = {
"ux.session.idle_timeout_minutes", "ux.session.idle_timeout_minutes",
"security.cors.allowed_origins", "security.cors.allowed_origins",
"security.public_cache.max_age_seconds", "security.public_cache.max_age_seconds",
} } | OTP_SETTING_KEYS
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -126,9 +127,11 @@ class AuditContext:
async def load_settings(session: AsyncSession) -> SettingsSnapshot: async def load_settings(session: AsyncSession) -> SettingsSnapshot:
rows = ( rows = list(
await session.execute(select(AppSetting).where(AppSetting.record_status == "A")) (
).scalars() await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
)
values = {row.setting_key: row.setting_value for row in rows} values = {row.setting_key: row.setting_value for row in rows}
missing = REQUIRED_SETTINGS - values.keys() missing = REQUIRED_SETTINGS - values.keys()
if missing: if missing:
@@ -138,6 +141,25 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
"Required settings are unavailable", "Required settings are unavailable",
{"missing": sorted(missing)}, {"missing": sorted(missing)},
) )
try:
invalid_metadata = sorted(
row.setting_key
for row in rows
if row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
)
if invalid_metadata:
raise ValueError(
f"OTP settings must have integer type and be private: {invalid_metadata}"
)
validate_otp_settings(values)
except ValueError as error:
raise DomainError(
"dependency_unavailable",
503,
"OTP settings are invalid",
{"reason": str(error)},
) from error
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24] version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
return SettingsSnapshot(values, version) return SettingsSnapshot(values, version)
+27 -1
View File
@@ -196,7 +196,12 @@ paths:
operationId: getOtpSettings operationId: getOtpSettings
security: [{serviceBearer: []}] security: [{serviceBearer: []}]
responses: responses:
"200": {description: Product OTP limits and cache metadata} "200":
description: Product OTP limits and cache metadata
content:
application/json:
schema: {$ref: "#/components/schemas/OtpSettingsResponse"}
"304": {description: Cached settings are still current}
"503": {$ref: "#/components/responses/DependencyUnavailable"} "503": {$ref: "#/components/responses/DependencyUnavailable"}
components: components:
securitySchemes: securitySchemes:
@@ -218,6 +223,27 @@ components:
description: Required dependency is unavailable description: Required dependency is unavailable
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}} content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
schemas: schemas:
OtpSettingsResponse:
type: object
additionalProperties: false
required:
- max_send_attempts_per_24h
- min_seconds_between_attempts
- max_verify_attempts
- code_length
- ttl_seconds
- sms_order_timeout_ms
- version
- cache_ttl_seconds
properties:
max_send_attempts_per_24h: {type: integer, minimum: 1}
min_seconds_between_attempts: {type: integer, minimum: 0}
max_verify_attempts: {type: integer, minimum: 1}
code_length: {type: integer, minimum: 4, maximum: 10}
ttl_seconds: {type: integer, minimum: 60, maximum: 900, multipleOf: 60}
sms_order_timeout_ms: {type: integer, minimum: 1}
version: {type: string, minLength: 1, maxLength: 64}
cache_ttl_seconds: {type: integer, minimum: 1}
ErrorEnvelope: ErrorEnvelope:
type: object type: object
required: [error] required: [error]
@@ -1,10 +1,13 @@
import base64 import base64
import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import yaml import yaml
from pydantic import SecretStr
from app.main import app, websocket_token from app.main import app, otp_settings, websocket_token
from app.services import SettingsSnapshot
EXPECTED_PATHS = { EXPECTED_PATHS = {
"/health/live", "/health/live",
@@ -56,3 +59,68 @@ def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
def test_committed_openapi_server_does_not_double_api_prefix() -> None: def test_committed_openapi_server_does_not_double_api_prefix() -> None:
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8")) committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
assert committed["servers"] == [{"url": "/"}] assert committed["servers"] == [{"url": "/"}]
def test_otp_settings_contract_is_strict_and_complete() -> None:
generated = app.openapi()
response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"]
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
assert set(schema["required"]) == {
"max_send_attempts_per_24h",
"min_seconds_between_attempts",
"max_verify_attempts",
"code_length",
"ttl_seconds",
"sms_order_timeout_ms",
"version",
"cache_ttl_seconds",
}
assert schema["additionalProperties"] is False
assert schema["properties"]["code_length"] == {
"type": "integer",
"maximum": 10.0,
"minimum": 4.0,
"title": "Code Length",
}
assert schema["properties"]["ttl_seconds"]["multipleOf"] == 60
async def test_otp_settings_returns_runtime_values_and_supports_etag() -> None:
request = SimpleNamespace(
headers={"Authorization": "Bearer bridge-token"},
app=SimpleNamespace(
state=SimpleNamespace(
settings=SimpleNamespace(
keycloak_settings_bridge_token=SecretStr("bridge-token")
)
)
),
)
settings = SettingsSnapshot(
{
"otp.phone.max_send_attempts_per_24h": "3",
"otp.phone.min_seconds_between_attempts": "30",
"otp.phone.max_verify_attempts": "5",
"otp.phone.code_length": "6",
"otp.phone.ttl_seconds": "60",
"otp.phone.sms_order_timeout_ms": "3000",
},
"settings-version",
)
response = await otp_settings(request, settings)
assert json.loads(response.body) == {
"max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "settings-version",
"cache_ttl_seconds": 60,
}
cached = await otp_settings(request, settings, response.headers["etag"])
assert cached.status_code == 304
@@ -12,6 +12,10 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows} assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
assert all(row["record_status"] == "A" for row in rows) assert all(row["record_status"] == "A" for row in rows)
values = {row["setting_key"]: row["setting_value"] for row in rows}
assert values["otp.phone.code_length"] == "6"
assert values["otp.phone.ttl_seconds"] == "60"
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None: def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
@@ -24,3 +28,37 @@ def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="integer value expected"): with pytest.raises(ValueError, match="integer value expected"):
load_seed(path) load_seed(path)
@pytest.mark.parametrize(
("key", "value", "message"),
[
("otp.phone.code_length", 3, "between 4 and 10"),
("otp.phone.ttl_seconds", 61, "divisible by 60"),
("otp.phone.sms_order_timeout_ms", 0, "must be positive"),
],
)
def test_seed_rejects_invalid_otp_settings(
tmp_path: Path, key: str, value: int, message: str
) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
f" {key}: {{type: integer, value: {value}, public: false}}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match=message):
load_seed(path)
def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" otp.phone.code_length: {type: integer, value: 6, public: true}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="must not be public"):
load_seed(path)
@@ -372,7 +372,7 @@ MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@redis:63
### 8.5. Mock OTP ### 8.5. Mock OTP
В MVP реализован только mock OTP. Для запуска: В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout:
```dotenv ```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_ENABLED=true
@@ -383,6 +383,10 @@ KEYCLOAK_OTP_MOCK_CODE=<ТЕСТОВЫЙ_КОД_НЕ_КОРОЧЕ_16_СИМВО
Этот код будет вводиться пользователем при тестовой авторизации. Не используйте Этот код будет вводиться пользователем при тестовой авторизации. Не используйте
его как production-механизм доставки OTP. его как production-механизм доставки OTP.
Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
До переключения необходимы: schema/role `sms` и migrations/seed, active approved `auth_otp` (`code`, `ttl_min`), согласованный sender, Direct `TOKEN_1`, парные service tokens, отдельные callback credentials, exact nginx callback route, подтверждённый source IP Direct и статический egress IP worker. Сначала deploy при mock=true, затем provider smoke/callback/redaction evidence и только после этого cutover. Rollback возвращает mock без удаления SMS schema/journal.
### 8.6. S3 ### 8.6. S3
```dotenv ```dotenv
+6
View File
@@ -211,6 +211,12 @@ SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
``` ```
## Real SMS rollout addendum
This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP.
Order: App DB OTP seed → SMS schema/migrations/seed → mock Direct tests → production SMS deployment while Keycloak remains in mock mode → Keycloak expand migration/SPI → controlled provider smoke plus callback/redaction evidence → real mode. Roll back by restoring mock mode without deleting the journal/schema; stop new real orders and drain or record in-flight/`uncertain` rows. Downgrade only with proven schema compatibility.
Never run Alembic downgrade. After a backward-incompatible migration choose a Never run Alembic downgrade. After a backward-incompatible migration choose a
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance. forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice. Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
@@ -216,6 +216,12 @@ SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
``` ```
## Дополнение: rollout реальной SMS-авторизации
Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker.
Порядок: App DB OTP seed → SMS schema/migrations/seed → test с mock Direct → production SMS deploy при `KEYCLOAK_OTP_MOCK_ENABLED=true` → Keycloak expand migration/SPI → provider smoke и callback/redaction evidence → real mode. Rollback: вернуть mock, не удалять journal/schema, остановить новые real orders и зафиксировать in-flight/`uncertain`; downgrade только при доказанной совместимости.
Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте
исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время
технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение
@@ -5,6 +5,9 @@ settings:
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false} otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false} otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
otp.phone.max_verify_attempts: {type: integer, value: 5, public: false} otp.phone.max_verify_attempts: {type: integer, value: 5, public: false}
otp.phone.code_length: {type: integer, value: 6, public: false}
otp.phone.ttl_seconds: {type: integer, value: 60, public: false}
otp.phone.sms_order_timeout_ms: {type: integer, value: 3000, public: false}
operator.call.phone: {type: string, value: "+74999591007", public: true} operator.call.phone: {type: string, value: "+74999591007", public: true}
consent.personal_data.required: {type: boolean, value: true, public: true} consent.personal_data.required: {type: boolean, value: true, public: true}
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true} consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
@@ -1,3 +1,11 @@
x-no-sms-secrets: &no-sms-secrets
SMS_DATABASE_URL: ""
SMS_SERVICE_TOKEN: ""
KEYCLOAK_SMS_SERVICE_TOKEN: ""
IDGTL_SMS_API_KEY: ""
IDGTL_SMS_CALLBACK_USERNAME: ""
IDGTL_SMS_CALLBACK_PASSWORD: ""
services: services:
migrate-api: migrate-api:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local} image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
@@ -5,6 +13,7 @@ services:
env_file: env_file:
- path: ../.env - path: ../.env
required: false required: false
environment: *no-sms-secrets
entrypoint: [] entrypoint: []
command: ["alembic", "upgrade", "head"] command: ["alembic", "upgrade", "head"]
volumes: volumes:
@@ -19,6 +28,7 @@ services:
env_file: env_file:
- path: ../.env - path: ../.env
required: false required: false
environment: *no-sms-secrets
entrypoint: [] entrypoint: []
command: ["alembic", "upgrade", "head"] command: ["alembic", "upgrade", "head"]
volumes: volumes:
@@ -33,6 +43,25 @@ services:
env_file: env_file:
- path: ../.env - path: ../.env
required: false required: false
environment: *no-sms-secrets
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
migrate-sms:
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
profiles: ["ops"]
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
entrypoint: [] entrypoint: []
command: ["alembic", "upgrade", "head"] command: ["alembic", "upgrade", "head"]
volumes: volumes:
@@ -47,6 +76,7 @@ services:
env_file: env_file:
- path: ../.env - path: ../.env
required: false required: false
environment: *no-sms-secrets
entrypoint: [] entrypoint: []
command: command:
- /bin/sh - /bin/sh
@@ -12,7 +12,9 @@ docker compose --env-file "${ENV_FILE:-.env}" config --quiet
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-sms alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-sms
echo "Migrations completed; record revisions in release evidence." echo "Migrations completed; record revisions in release evidence."
@@ -42,6 +42,12 @@ curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-co
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check") internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; } [ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
sms_internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000")
[ "$sms_internal_code" = "404" ] || { echo "Public SMS internal API returned $sms_internal_code, expected 404" >&2; exit 1; }
sms_callback_code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' --data '[]' \
"${PUBLIC_WEB_URL}/callbacks/idgtl/sms")
[ "$sms_callback_code" = "403" ] || { echo "SMS callback without provider IP returned $sms_callback_code, expected 403" >&2; exit 1; }
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/") headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff' printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
@@ -4,6 +4,7 @@ import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { env, oidcIssuer } from "./config"; import { env, oidcIssuer } from "./config";
import { buildOidcDeviceMetadata } from "./oidc-device";
import { SingleFlight } from "./single-flight"; import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types"; import type { TokenSet } from "./types";
@@ -84,6 +85,7 @@ export async function beginAuthorization() {
encoding: Crypto.CryptoEncoding.BASE64, encoding: Crypto.CryptoEncoding.BASE64,
}); });
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() })); await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({ const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId, client_id: env.clientId,
@@ -94,6 +96,7 @@ export async function beginAuthorization() {
code_challenge_method: "S256", code_challenge_method: "S256",
state, state,
nonce, nonce,
...deviceMetadata,
})}`; })}`;
if (Platform.OS === "web" && typeof window !== "undefined") { if (Platform.OS === "web" && typeof window !== "undefined") {
window.location.assign(url); window.location.assign(url);
@@ -0,0 +1,94 @@
import Constants from "expo-constants";
import * as Crypto from "expo-crypto";
import { Platform } from "react-native";
const DEVICE_ID_KEY = "han.web-device-id";
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
type Store = {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
export type OidcDeviceMetadata = Partial<Record<
| "han_device_id"
| "han_fingerprint"
| "han_platform"
| "han_os_name"
| "han_os_version"
| "han_app_version",
string
>>;
function safe(value: unknown, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || CONTROL_CHARACTERS.test(normalized)) {
return undefined;
}
return normalized;
}
type BrowserDetails = {
osName?: string;
osVersion?: string;
fingerprintSource?: string;
};
function browserDetails(): BrowserDetails {
if (typeof navigator === "undefined") return {};
const userAgent = navigator.userAgent;
const platform = safe(navigator.platform, 64);
const windows = userAgent.match(/Windows NT ([\d.]+)/);
const android = userAgent.match(/Android ([\d.]+)/);
const ios = userAgent.match(/(?:iPhone )?OS ([\d_]+)/);
const osName = windows ? "Windows" : android ? "Android" : ios ? "iOS" : platform;
const osVersion = windows?.[1] ?? android?.[1] ?? ios?.[1]?.replaceAll("_", ".");
return {
...(osName ? { osName } : {}),
...(osVersion ? { osVersion } : {}),
fingerprintSource: [
userAgent,
navigator.language,
platform,
Intl.DateTimeFormat().resolvedOptions().timeZone,
typeof screen === "undefined" ? "" : `${screen.width}x${screen.height}`,
].join("|"),
};
}
export async function buildOidcDeviceMetadata(store: Store): Promise<OidcDeviceMetadata> {
const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web";
let deviceId = safe(await store.get(DEVICE_ID_KEY), 256);
if (!deviceId) {
deviceId = Crypto.randomUUID();
await store.set(DEVICE_ID_KEY, deviceId);
}
const browser = platform === "web" ? browserDetails() : {};
const constants = Platform.constants as unknown as Record<string, unknown>;
const fingerprintSource = browser.fingerprintSource
?? [platform, constants.Brand, constants.Model, constants.osVersion].join("|");
const fingerprint = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
`${deviceId}|${fingerprintSource}`,
);
const osName = browser.osName
?? safe(constants.systemName, 64)
?? (platform === "ios" ? "iOS" : platform === "android" ? "Android" : undefined);
const osVersion = browser.osVersion
?? safe(String(constants.osVersion ?? Platform.Version ?? ""), 64);
const appVersion = safe(Constants.expoConfig?.version, 64);
const safeOsName = safe(osName, 64);
const safeOsVersion = safe(osVersion, 64);
return {
han_device_id: deviceId,
han_fingerprint: fingerprint,
han_platform: platform,
...(safeOsName ? { han_os_name: safeOsName } : {}),
...(safeOsVersion ? { han_os_version: safeOsVersion } : {}),
...(appVersion ? { han_app_version: appVersion } : {}),
};
}
@@ -1,4 +1,18 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
vi.mock("expo-constants", () => ({
default: { expoConfig: { version: "1.0.0" } },
}));
vi.mock("expo-crypto", () => ({
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"),
digestStringAsync: vi.fn(async () => "stable-fingerprint"),
}));
vi.mock("react-native", () => ({
Platform: { OS: "web", Version: "test", constants: {} },
}));
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
import { reconcileMessages } from "../../src/reconcile"; import { reconcileMessages } from "../../src/reconcile";
import { sessionMemory } from "../../src/session"; import { sessionMemory } from "../../src/session";
import { SingleFlight } from "../../src/single-flight"; import { SingleFlight } from "../../src/single-flight";
@@ -95,3 +109,26 @@ describe("WebSocket authentication protocol", () => {
expect(protocol).not.toContain("="); expect(protocol).not.toContain("=");
}); });
}); });
describe("OIDC device metadata", () => {
it("создаёт стабильный web UUID и передаёт доступные han_* поля", async () => {
const values = new Map<string, string>();
const store = {
get: async (key: string) => values.get(key) ?? null,
set: async (key: string, value: string) => {
values.set(key, value);
},
};
const first = await buildOidcDeviceMetadata(store);
const second = await buildOidcDeviceMetadata(store);
expect(first.han_device_id).toBe("123e4567-e89b-42d3-a456-426614174000");
expect(second.han_device_id).toBe(first.han_device_id);
expect(first).toMatchObject({
han_fingerprint: "stable-fingerprint",
han_platform: "web",
han_app_version: "1.0.0",
});
});
});
+79 -2
View File
@@ -1,3 +1,11 @@
x-no-sms-secrets: &no-sms-secrets
SMS_DATABASE_URL: ""
SMS_SERVICE_TOKEN: ""
KEYCLOAK_SMS_SERVICE_TOKEN: ""
IDGTL_SMS_API_KEY: ""
IDGTL_SMS_CALLBACK_USERNAME: ""
IDGTL_SMS_CALLBACK_PASSWORD: ""
x-api-runtime: &api-runtime x-api-runtime: &api-runtime
build: build:
context: ../../api-backend context: ../../api-backend
@@ -5,6 +13,7 @@ x-api-runtime: &api-runtime
env_file: env_file:
- path: ../../.env - path: ../../.env
required: false required: false
environment: *no-sms-secrets
volumes: volumes:
- type: bind - type: bind
source: ${PG_CA_HOST_PATH} source: ${PG_CA_HOST_PATH}
@@ -16,6 +25,29 @@ x-api-runtime: &api-runtime
driver: json-file driver: json-file
options: {max-size: "50m", max-file: "5"} options: {max-size: "50m", max-file: "5"}
x-sms-runtime: &sms-runtime
build:
context: ../../sms-service
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
services: services:
frontend-static: frontend-static:
build: build:
@@ -45,6 +77,7 @@ services:
- path: ../../.env - path: ../../.env
required: false required: false
environment: environment:
<<: *no-sms-secrets
KC_DB: postgres KC_DB: postgres
KC_DB_URL: ${KEYCLOAK_DB_URL} KC_DB_URL: ${KEYCLOAK_DB_URL}
KC_DB_SCHEMA: ${KEYCLOAK_DB_SCHEMA:-keycloak} KC_DB_SCHEMA: ${KEYCLOAK_DB_SCHEMA:-keycloak}
@@ -59,12 +92,13 @@ services:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN} KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false} KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required} KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required} KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300} KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp} KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required} KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
KEYCLOAK_SMS_SERVICE_URL: ${KEYCLOAK_SMS_SERVICE_URL:-http://sms-service:8080}
KEYCLOAK_SMS_SERVICE_TOKEN: ${KEYCLOAK_SMS_SERVICE_TOKEN:?KEYCLOAK_SMS_SERVICE_TOKEN is required}
command: ["start", "--optimized", "--import-realm"] command: ["start", "--optimized", "--import-realm"]
expose: ["8080", "9000"] expose: ["8080", "9000"]
volumes: volumes:
@@ -85,6 +119,43 @@ services:
driver: json-file driver: json-file
options: {max-size: "50m", max-file: "5"} options: {max-size: "50m", max-file: "5"}
sms-service:
<<: *sms-runtime
expose: ["8080"]
networks: [backend, observability, egress]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
sms-worker:
<<: *sms-runtime
entrypoint: []
command: ["han-sms-worker"]
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_API_KEY: ${IDGTL_SMS_API_KEY:?IDGTL_SMS_API_KEY is required}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
networks: [backend, observability, egress]
depends_on:
sms-service: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-sms-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
message-safety: message-safety:
build: build:
context: ../../message-safety context: ../../message-safety
@@ -92,6 +163,8 @@ services:
env_file: env_file:
- path: ../../.env - path: ../../.env
required: false required: false
environment:
<<: *no-sms-secrets
expose: ["8080"] expose: ["8080"]
volumes: volumes:
- type: bind - type: bind
@@ -179,6 +252,8 @@ services:
env_file: env_file:
- path: ../../.env - path: ../../.env
required: false required: false
environment:
<<: *no-sms-secrets
expose: ["8080"] expose: ["8080"]
volumes: volumes:
- type: bind - type: bind
@@ -207,6 +282,8 @@ services:
env_file: env_file:
- path: ../../.env - path: ../../.env
required: false required: false
environment:
<<: *no-sms-secrets
expose: ["8080"] expose: ["8080"]
volumes: volumes:
- type: bind - type: bind
+2 -1
View File
@@ -7,10 +7,11 @@ KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300 KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
KEYCLOAK_SMS_SERVICE_TOKEN=replace-with-independent-service-token
KEYCLOAK_LOG_LEVEL=INFO KEYCLOAK_LOG_LEVEL=INFO
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35 KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
+1
View File
@@ -6,6 +6,7 @@ COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
COPY src ./src COPY src ./src
COPY realm ./realm COPY realm ./realm
COPY themes ./themes
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
+15 -5
View File
@@ -9,10 +9,12 @@ Production-like Keycloak 26.1.4 image and realm for OTP-only phone authenticatio
- Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`. - Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`.
- Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days. - Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days.
- Realm brute-force protection uses temporary bounded lockouts. - Realm brute-force protection uses temporary bounded lockouts.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migration `han-otp-1.0.0` is applied by Keycloak's JPA entity provider. - OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migrations are applied by Keycloak's JPA entity provider.
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison. - OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed. - Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
- Mock mode is explicit. Startup rejects missing values, code `1234`, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup. - Every challenge snapshots code length, TTL, SMS-order timeout and settings version. Runtime OTP values are not read from environment variables.
- Mock mode is explicit and retains the configured test code. SMS mode generates a cryptographically secure numeric OTP, stores only its HMAC and orders delivery through `POST /internal/sms/v1/send`; Keycloak never calls or polls the provider.
- SMS mode requires `KEYCLOAK_SMS_SERVICE_URL` and an independent `KEYCLOAK_SMS_SERVICE_TOKEN`. No real credentials are committed.
## Build and test ## Build and test
@@ -73,14 +75,22 @@ Private signing keys are generated and stored by Keycloak and are absent from th
Provider tables: Provider tables:
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock; - `han_otp_challenge`: expiring, one-time challenges with explicit ordering/active/final statuses, settings snapshot and optional `sms_message_id`;
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC; - `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
- `han_otp_security_event`: append-only minimal outcomes without raw phone or OTP. - `han_otp_security_event`: append-only send/verify outcomes with SMS correlation and validated device audit metadata, without raw phone or OTP.
Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use. Resend creates a new durable order and marks earlier active/ordering challenges as superseded. Verification accepts only active, unexpired challenges, locks the row, increments attempts, and atomically consumes a valid code. Provider delivery status never participates in verification.
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables. Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
The provider schedules a once-per-minute expiry update and also performs lazy expiry on send and verify. The theme renders digit inputs and countdown from the challenge snapshot, submits a real resend action and carries optional `han_*` device metadata.
## SMS order behavior
`200` or `202` with a valid UUID `sms_message_id` and ISO-8601 `ordered_at` activates a real-mode challenge. Timeout, I/O failure or 5xx is retried once with the same `keycloak:challenge:{id}` idempotency key; final failure marks that challenge `order_failed`. The retry creates neither another challenge nor another send-counter increment.
Reserve, SMS HTTP order, and activation/order-failure run as separate transaction phases. The HTTP call holds no challenge/counter database lock, and every retry retains the same challenge id.
## Release and recovery ## Release and recovery
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path. Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
+3 -2
View File
@@ -21,12 +21,13 @@ services:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required} KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true} KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required} KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required} KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300} KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp} KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required} KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
KEYCLOAK_SMS_SERVICE_URL: ${KEYCLOAK_SMS_SERVICE_URL:-http://sms-service:8080}
KEYCLOAK_SMS_SERVICE_TOKEN: ${KEYCLOAK_SMS_SERVICE_TOKEN:-}
KC_LOG_CONSOLE_OUTPUT: json KC_LOG_CONSOLE_OUTPUT: json
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO} KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35} JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
@@ -5,21 +5,26 @@ import java.time.Duration;
final class Config { final class Config {
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true); static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE"); static final String MOCK_CODE = env("KEYCLOAK_OTP_MOCK_CODE", "");
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8); static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds( static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600)); integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL", static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
"http://api-backend:8000/internal/settings/v1/otp")); "http://api-backend:8000/internal/settings/v1/otp"));
static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN"); static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN");
static final URI SMS_SERVICE_URL = URI.create(env("KEYCLOAK_SMS_SERVICE_URL",
"http://sms-service:8080")).resolve("/internal/sms/v1/send");
static final String SMS_SERVICE_TOKEN = env("KEYCLOAK_SMS_SERVICE_TOKEN", "");
static { static {
if (!MOCK_ENABLED) { if (MOCK_ENABLED && (!MOCK_CODE.matches("\\d{6,10}") || "1234".equals(MOCK_CODE))) {
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start"); throw new IllegalStateException(
"KEYCLOAK_OTP_MOCK_CODE must be a non-default numeric code of 6 to 10 digits");
} }
if (MOCK_CODE.isBlank() || "1234".equals(MOCK_CODE) || MOCK_CODE.length() < 6) { if (!MOCK_ENABLED
throw new IllegalStateException("KEYCLOAK_OTP_MOCK_CODE must be a non-default secret of at least 6 characters"); && SMS_SERVICE_TOKEN.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException(
"KEYCLOAK_SMS_SERVICE_TOKEN must contain at least 32 bytes in SMS mode");
} }
if (HMAC_KEY.length < 32) { if (HMAC_KEY.length < 32) {
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes"); throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
@@ -28,6 +33,10 @@ final class Config {
private Config() {} private Config() {}
static void validate() {
// Class initialization performs the fail-closed validation.
}
private static String required(String name) { private static String required(String name) {
String value = System.getenv(name); String value = System.getenv(name);
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
@@ -18,6 +18,17 @@ final class Crypto {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
} }
static String randomNumericCode(int length) {
if (length < 4 || length > 10) {
throw new IllegalArgumentException("OTP length must be between 4 and 10");
}
StringBuilder code = new StringBuilder(length);
for (int index = 0; index < length; index++) {
code.append(RANDOM.nextInt(10));
}
return code.toString();
}
static String hmac(String purpose, String value) { static String hmac(String purpose, String value) {
try { try {
Mac mac = Mac.getInstance("HmacSHA256"); Mac mac = Mac.getInstance("HmacSHA256");
@@ -0,0 +1,66 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.MultivaluedMap;
import java.util.Set;
import org.keycloak.authentication.AuthenticationFlowContext;
record DeviceMetadata(
String clientIp,
String userAgent,
String deviceId,
String fingerprint,
String osName,
String osVersion,
String platform,
String appVersion) {
private static final Set<String> PLATFORMS = Set.of("web", "ios", "android");
static DeviceMetadata capture(AuthenticationFlowContext context) {
MultivaluedMap<String, String> form = context.getHttpRequest().getDecodedFormParameters();
var session = context.getAuthenticationSession();
MultivaluedMap<String, String> query = context.getHttpRequest().getUri().getQueryParameters();
String deviceId = value(form, query, session.getAuthNote("han.device_id"), "han_device_id", 256);
String fingerprint = value(form, query, session.getAuthNote("han.fingerprint"), "han_fingerprint", 256);
String osName = value(form, query, session.getAuthNote("han.os_name"), "han_os_name", 64);
String osVersion = value(form, query, session.getAuthNote("han.os_version"), "han_os_version", 64);
String platform = value(form, query, session.getAuthNote("han.platform"), "han_platform", 16);
String appVersion = value(form, query, session.getAuthNote("han.app_version"), "han_app_version", 64);
if (platform != null && !PLATFORMS.contains(platform)) platform = null;
save(session, "han.device_id", deviceId);
save(session, "han.fingerprint", fingerprint);
save(session, "han.os_name", osName);
save(session, "han.os_version", osVersion);
save(session, "han.platform", platform);
save(session, "han.app_version", appVersion);
return new DeviceMetadata(
clean(context.getConnection().getRemoteAddr(), 64),
clean(context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent"), 1024),
deviceId, fingerprint, osName, osVersion, platform, appVersion);
}
private static String value(
MultivaluedMap<String, String> form,
MultivaluedMap<String, String> query,
String saved,
String name,
int max) {
String submitted = form.getFirst(name);
if (submitted == null) submitted = query.getFirst(name);
return clean(submitted == null ? saved : submitted, max);
}
private static void save(
org.keycloak.sessions.AuthenticationSessionModel session, String name, String value) {
if (value == null) session.removeAuthNote(name);
else session.setAuthNote(name, value);
}
private static String clean(String value, int max) {
if (value == null || value.isBlank() || value.length() > max) return null;
for (int i = 0; i < value.length(); i++) {
if (Character.isISOControl(value.charAt(i))) return null;
}
return value;
}
}
@@ -0,0 +1,38 @@
package ru.han.chat.keycloak;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.utils.KeycloakModelUtils;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
final class OtpFlow {
private OtpFlow() {}
static OtpChallengeEntity start(
AuthenticationFlowContext context,
String phone,
SettingsBridge.Settings settings,
DeviceMetadata device) {
OtpStore.Reservation reservation = KeycloakModelUtils.runJobInTransactionWithResult(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).reserve(phone, settings, device));
OtpChallengeEntity challenge = reservation.challenge();
if (!Config.MOCK_ENABLED) {
String challengeId = challenge.id;
try {
String requestId = context.getHttpRequest().getHttpHeaders().getHeaderString("X-Request-ID");
String traceparent = context.getHttpRequest().getHttpHeaders().getHeaderString("traceparent");
SmsOrderClient.OrderResult order = new SmsOrderClient().order(
challengeId, phone, reservation.otp(), settings, requestId, traceparent);
challenge = KeycloakModelUtils.runJobInTransactionWithResult(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).activate(challengeId, order, device));
} catch (RuntimeException exception) {
KeycloakModelUtils.runJobInTransaction(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).orderFailed(challengeId, device));
throw exception;
}
}
return challenge;
}
}
@@ -4,6 +4,7 @@ import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.UUID;
import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import ru.han.chat.keycloak.entity.OtpChallengeEntity; import ru.han.chat.keycloak.entity.OtpChallengeEntity;
@@ -17,7 +18,7 @@ final class OtpStore {
this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager(); this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
} }
OtpChallengeEntity reserve(String phone, SettingsBridge.Limits limits) { Reservation reserve(String phone, SettingsBridge.Settings settings, DeviceMetadata device) {
Instant now = Instant.now(); Instant now = Instant.now();
String phoneHmac = Crypto.hmac("phone", phone); String phoneHmac = Crypto.hmac("phone", phone);
OtpSendCounterEntity counter = entityManager.find( OtpSendCounterEntity counter = entityManager.find(
@@ -34,77 +35,169 @@ final class OtpStore {
counter.windowStart = now; counter.windowStart = now;
counter.sendCount = 0; counter.sendCount = 0;
} }
if (counter.sendCount >= limits.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, "limited", "daily_limit"); expireDue(now);
if (counter.sendCount >= settings.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, null, "limited", "daily_limit", device);
throw new OtpLimitException("otp_send_limited"); throw new OtpLimitException("otp_send_limited");
} }
if (counter.lastSentAt.plusSeconds(limits.minSecondsBetween()).isAfter(now)) { if (counter.lastSentAt.plusSeconds(settings.minSecondsBetween()).isAfter(now)) {
event("otp_send", phoneHmac, null, "limited", "cooldown"); event("otp_send", phoneHmac, null, null, "limited", "cooldown", device);
throw new OtpLimitException("otp_send_limited"); throw new OtpLimitException("otp_send_cooldown");
} }
counter.sendCount++; counter.sendCount++;
counter.lastSentAt = now; counter.lastSentAt = now;
entityManager.createQuery(""" entityManager.createQuery("""
update OtpChallengeEntity c set c.consumedAt = :now, c.providerStatus = 'superseded' update OtpChallengeEntity c set c.challengeStatus = 'superseded'
where c.phoneHmac = :phone and c.consumedAt is null and c.expiresAt > :now where c.phoneHmac = :phone and c.challengeStatus in ('active', 'ordering')
""").setParameter("now", now).setParameter("phone", phoneHmac).executeUpdate(); """).setParameter("phone", phoneHmac).executeUpdate();
OtpChallengeEntity challenge = new OtpChallengeEntity(); OtpChallengeEntity challenge = new OtpChallengeEntity();
challenge.id = Crypto.randomId(); challenge.id = Crypto.randomId();
String otp = Config.MOCK_ENABLED ? Config.MOCK_CODE : Crypto.randomNumericCode(settings.codeLength());
if (Config.MOCK_ENABLED && otp.length() != settings.codeLength()) {
throw new IllegalStateException("Mock OTP length must match the settings snapshot");
}
challenge.phoneHmac = phoneHmac; challenge.phoneHmac = phoneHmac;
challenge.destinationMasked = PhoneNormalizer.mask(phone); challenge.destinationMasked = PhoneNormalizer.mask(phone);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, Config.MOCK_CODE); challenge.otpHash = Crypto.hmac("otp:" + challenge.id, otp);
challenge.createdAt = now; challenge.createdAt = now;
challenge.expiresAt = now.plus(Config.OTP_TTL); challenge.expiresAt = now.plusSeconds(settings.ttlSeconds());
challenge.verifyAttempts = 0; challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = limits.maxVerifyAttempts(); challenge.maxVerifyAttempts = settings.maxVerifyAttempts();
challenge.settingsVersion = limits.version(); challenge.settingsVersion = settings.version();
challenge.providerId = "mock-" + Crypto.randomId(); challenge.deliveryMode = Config.MOCK_ENABLED ? "mock" : "sms";
challenge.providerStatus = "accepted"; challenge.challengeStatus = Config.MOCK_ENABLED ? "active" : "ordering";
challenge.orderedAt = Config.MOCK_ENABLED ? now : null;
challenge.otpTtlSec = settings.ttlSeconds();
challenge.otpCodeLength = settings.codeLength();
entityManager.persist(challenge); entityManager.persist(challenge);
event("otp_send", phoneHmac, challenge.id, "success", "mock"); if (Config.MOCK_ENABLED) {
event("otp_send", phoneHmac, challenge.id, null, "success", "mock", device);
}
return new Reservation(challenge, otp);
}
OtpChallengeEntity activate(
String challengeId, SmsOrderClient.OrderResult order, DeviceMetadata device) {
OtpChallengeEntity challenge = locked(challengeId);
if (!"ordering".equals(challenge.challengeStatus)) return challenge;
challenge.smsMessageId = order.smsMessageId();
challenge.orderedAt = order.orderedAt();
challenge.expiresAt = order.orderedAt().plusSeconds(challenge.otpTtlSec);
challenge.challengeStatus = "active";
event("otp_send", challenge.phoneHmac, challenge.id, challenge.smsMessageId,
"success", "ordered", device);
return challenge; return challenge;
} }
boolean consume(String challengeId, String suppliedCode) { void orderFailed(String challengeId, DeviceMetadata device) {
OtpChallengeEntity challenge = locked(challengeId);
if (!"ordering".equals(challenge.challengeStatus)) return;
challenge.challengeStatus = "order_failed";
event("otp_send", challenge.phoneHmac, challenge.id, null,
"failure", "order_failed", device);
}
boolean consume(String challengeId, String suppliedCode, DeviceMetadata device) {
OtpChallengeEntity challenge = entityManager.find( OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE); OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now(); Instant now = Instant.now();
if (challenge == null || challenge.consumedAt != null || !challenge.expiresAt.isAfter(now)) { if (challenge == null) return false;
if (challenge != null) event("otp_verify", challenge.phoneHmac, challengeId, "failure", "expired_or_used"); if (!"active".equals(challenge.challengeStatus)) {
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"already_used", challenge.challengeStatus, device);
return false;
}
if (!challenge.expiresAt.isAfter(now)) {
challenge.challengeStatus = "expired";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"expired", "ttl", device);
return false; return false;
} }
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) { if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
event("otp_verify", challenge.phoneHmac, challengeId, "limited", "attempt_limit"); challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"limited", "attempt_limit", device);
return false; return false;
} }
challenge.verifyAttempts++; challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals( boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode)); challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
if (!valid) { if (!valid) {
event("otp_verify", challenge.phoneHmac, challengeId, "failure", "invalid"); boolean limited = challenge.verifyAttempts >= challenge.maxVerifyAttempts;
if (limited) challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device);
return false; return false;
} }
challenge.consumedAt = now; challenge.consumedAt = now;
challenge.providerStatus = "consumed"; challenge.challengeStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, "success", "verified"); event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"success", "verified", device);
return true; return true;
} }
private void event(String type, String phoneHmac, String challengeId, String outcome, String details) { OtpChallengeEntity get(String challengeId) {
return entityManager.find(OtpChallengeEntity.class, challengeId);
}
void expireDue() {
expireDue(Instant.now());
}
private OtpChallengeEntity locked(String challengeId) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
if (challenge == null) throw new IllegalStateException("OTP challenge not found");
return challenge;
}
private void expireDue(Instant now) {
entityManager.createQuery("""
update OtpChallengeEntity c set c.challengeStatus = 'expired'
where c.challengeStatus = 'active' and c.expiresAt <= :now
""").setParameter("now", now).executeUpdate();
}
private void event(
String type,
String phoneHmac,
String challengeId,
UUID smsMessageId,
String outcome,
String details,
DeviceMetadata device) {
OtpSecurityEventEntity event = new OtpSecurityEventEntity(); OtpSecurityEventEntity event = new OtpSecurityEventEntity();
event.id = Crypto.randomId(); event.id = Crypto.randomId();
event.occurredAt = Instant.now(); event.occurredAt = Instant.now();
event.eventType = type; event.eventType = type;
event.phoneHmac = phoneHmac; event.phoneHmac = phoneHmac;
event.challengeId = challengeId; event.challengeId = challengeId;
event.smsMessageId = smsMessageId;
event.outcome = outcome; event.outcome = outcome;
event.details = details; event.details = details;
if (device != null) {
event.clientIp = device.clientIp();
event.userAgent = device.userAgent();
event.deviceId = device.deviceId();
event.fingerprint = device.fingerprint();
event.osName = device.osName();
event.osVersion = device.osVersion();
event.platform = device.platform();
event.appVersion = device.appVersion();
}
entityManager.persist(event); entityManager.persist(event);
} }
record Reservation(OtpChallengeEntity challenge, String otp) {}
static final class OtpLimitException extends RuntimeException { static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); } OtpLimitException(String message) { super(message); }
boolean isCooldown() {
return "otp_send_cooldown".equals(getMessage());
}
} }
} }
@@ -12,40 +12,62 @@ public final class PhoneIdentityAuthenticator implements Authenticator {
static final String PHONE_NOTE = "han.phone"; static final String PHONE_NOTE = "han.phone";
static final String CHALLENGE_NOTE = "han.otp.challenge"; static final String CHALLENGE_NOTE = "han.otp.challenge";
static final String MASKED_NOTE = "han.phone.masked"; static final String MASKED_NOTE = "han.phone.masked";
static final String CODE_LENGTH_NOTE = "han.otp.code_length";
static final String EXPIRES_AT_NOTE = "han.otp.expires_at";
private final PhoneNormalizer normalizer = new PhoneNormalizer(); private final PhoneNormalizer normalizer = new PhoneNormalizer();
@Override @Override
public void authenticate(AuthenticationFlowContext context) { public void authenticate(AuthenticationFlowContext context) {
DeviceMetadata device = DeviceMetadata.capture(context);
if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) { if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) {
context.success(); context.success();
return; return;
} }
context.challenge(context.form().createForm("phone.ftl")); context.challenge(phoneForm(context, null, device));
} }
@Override @Override
public void action(AuthenticationFlowContext context) { public void action(AuthenticationFlowContext context) {
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone"); String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
DeviceMetadata device = DeviceMetadata.capture(context);
try { try {
String phone = normalizer.normalize(rawPhone); String phone = normalizer.normalize(rawPhone);
SettingsBridge.Limits limits = SettingsBridge.get(); SettingsBridge.Settings settings = SettingsBridge.get();
var challenge = new OtpStore(context.getSession()).reserve(phone, limits); var challenge = OtpFlow.start(context, phone, settings, device);
context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone); context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone);
context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id); context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id);
context.getAuthenticationSession().setAuthNote(MASKED_NOTE, challenge.destinationMasked); context.getAuthenticationSession().setAuthNote(MASKED_NOTE, challenge.destinationMasked);
context.getAuthenticationSession().setAuthNote(
CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
context.getAuthenticationSession().setAuthNote(
EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
context.success(); context.success();
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
Response response = context.form().setError("phoneInvalid").createForm("phone.ftl"); Response response = phoneForm(context, "phoneInvalid", device);
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response); context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
} catch (OtpStore.OtpLimitException exception) { } catch (OtpStore.OtpLimitException exception) {
Response response = context.form().setError("otpLimited").createForm("phone.ftl"); Response response = phoneForm(
context, exception.isCooldown() ? "otpCooldown" : "otpLimited", device);
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response); context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
Response response = context.form().setError("otpUnavailable").createForm("phone.ftl"); Response response = phoneForm(context, "otpUnavailable", device);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response); context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response);
} }
} }
private static Response phoneForm(
AuthenticationFlowContext context, String messageKey, DeviceMetadata device) {
var form = context.form()
.setAttribute("hanDeviceId", device.deviceId())
.setAttribute("hanFingerprint", device.fingerprint())
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.setAttribute("hanAppVersion", device.appVersion());
if (messageKey != null) form.setError(messageKey);
return form.createForm("phone.ftl");
}
@Override public boolean requiresUser() { return false; } @Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; } @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} @Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@@ -19,7 +19,7 @@ public final class PhoneOtpAuthenticator implements Authenticator {
} }
String masked = context.getAuthenticationSession() String masked = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE); .getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE);
context.challenge(context.form().setAttribute("maskedPhone", masked).createForm("otp.ftl")); context.challenge(otpForm(context, masked, null));
} }
@Override @Override
@@ -27,16 +27,37 @@ public final class PhoneOtpAuthenticator implements Authenticator {
String challengeId = context.getAuthenticationSession() String challengeId = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE); .getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE); String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE);
String action = context.getHttpRequest().getDecodedFormParameters().getFirst("otp_action");
String code = context.getHttpRequest().getDecodedFormParameters().getFirst("otp"); String code = context.getHttpRequest().getDecodedFormParameters().getFirst("otp");
if (challengeId == null || phone == null) { if (challengeId == null || phone == null) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR); context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return; return;
} }
if (!new OtpStore(context.getSession()).consume(challengeId, code)) { DeviceMetadata device = DeviceMetadata.capture(context);
Response response = context.form() if ("resend".equals(action)) {
.setAttribute("maskedPhone", PhoneNormalizer.mask(phone)) try {
.setError("otpInvalid") var challenge = OtpFlow.start(context, phone, SettingsBridge.get(), device);
.createForm("otp.ftl"); context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.CHALLENGE_NOTE, challenge.id);
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
context.challenge(otpForm(context, challenge.destinationMasked, null));
} catch (OtpStore.OtpLimitException exception) {
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
otpForm(
context,
PhoneNormalizer.mask(phone),
exception.isCooldown() ? "otpCooldown" : "otpLimited"));
} catch (RuntimeException exception) {
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
otpForm(context, PhoneNormalizer.mask(phone), "otpUnavailable"));
}
return;
}
if (!new OtpStore(context.getSession()).consume(challengeId, code, device)) {
Response response = otpForm(context, PhoneNormalizer.mask(phone), "otpInvalid");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response); context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return; return;
} }
@@ -62,6 +83,26 @@ public final class PhoneOtpAuthenticator implements Authenticator {
context.success(); context.success();
} }
private static Response otpForm(AuthenticationFlowContext context, String masked, String messageKey) {
DeviceMetadata device = DeviceMetadata.capture(context);
String codeLength = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CODE_LENGTH_NOTE);
String expiresAt = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.EXPIRES_AT_NOTE);
var form = context.form()
.setAttribute("maskedPhone", masked)
.setAttribute("otpCodeLength", codeLength == null ? 6 : Integer.parseInt(codeLength))
.setAttribute("otpExpiresAt", expiresAt == null ? 0 : Long.parseLong(expiresAt))
.setAttribute("hanDeviceId", device.deviceId())
.setAttribute("hanFingerprint", device.fingerprint())
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.setAttribute("hanAppVersion", device.appVersion());
if (messageKey != null) form.setError(messageKey);
return form.createForm("otp.ftl");
}
@Override public boolean requiresUser() { return false; } @Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; } @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} @Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@@ -7,7 +7,9 @@ import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.timer.TimerProvider;
public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory { public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "han-phone-otp"; public static final String ID = "han-phone-otp";
@@ -25,11 +27,16 @@ public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory
@Override public boolean isUserSetupAllowed() { return false; } @Override public boolean isUserSetupAllowed() { return false; }
@Override public String getHelpText() { return "Verifies and atomically consumes a durable phone OTP challenge."; } @Override public String getHelpText() { return "Verifies and atomically consumes a durable phone OTP challenge."; }
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); } @Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
@Override public void init(Config.Scope config) { @Override public void init(Config.Scope config) { ru.han.chat.keycloak.Config.validate(); }
if (!ru.han.chat.keycloak.Config.MOCK_ENABLED) { @Override
throw new IllegalStateException("OTP delivery provider is not configured"); public void postInit(KeycloakSessionFactory factory) {
try (KeycloakSession session = factory.create()) {
session.getProvider(TimerProvider.class).schedule(
() -> KeycloakModelUtils.runJobInTransaction(
factory, jobSession -> new OtpStore(jobSession).expireDue()),
60_000L,
"han-otp-expiry");
} }
} }
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {} @Override public void close() {}
} }
@@ -17,18 +17,25 @@ final class SettingsBridge {
.connectTimeout(Duration.ofSeconds(2)).build(); .connectTimeout(Duration.ofSeconds(2)).build();
private static volatile Cached cached; private static volatile Cached cached;
record Limits(int maxSendsPer24h, int minSecondsBetween, int maxVerifyAttempts, String version) {} record Settings(
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {} int maxSendsPer24h,
int minSecondsBetween,
int maxVerifyAttempts,
int codeLength,
int ttlSeconds,
int smsOrderTimeoutMs,
String version) {}
private record Cached(Settings settings, Instant fetchedAt, Instant refreshAfter, String etag) {}
private SettingsBridge() {} private SettingsBridge() {}
static Limits get() { static Settings get() {
Cached local = cached; Cached local = cached;
Instant now = Instant.now(); Instant now = Instant.now();
if (local != null && now.isBefore(local.refreshAfter)) return local.limits; if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
synchronized (SettingsBridge.class) { synchronized (SettingsBridge.class) {
local = cached; local = cached;
if (local != null && now.isBefore(local.refreshAfter)) return local.limits; if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
try { try {
HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL) HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL)
.timeout(Duration.ofSeconds(3)) .timeout(Duration.ofSeconds(3))
@@ -38,27 +45,35 @@ final class SettingsBridge {
if (local != null && local.etag != null) builder.header("If-None-Match", local.etag); if (local != null && local.etag != null) builder.header("If-None-Match", local.etag);
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 304 && local != null) { if (response.statusCode() == 304 && local != null) {
cached = new Cached(local.limits, now, now.plusSeconds(60), local.etag); cached = new Cached(local.settings, now, now.plusSeconds(60), local.etag);
return local.limits; return local.settings;
} }
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode()); if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
int max = integer(response.body(), "max_send_attempts_per_24h"); int max = integer(response.body(), "max_send_attempts_per_24h");
int minimum = integer(response.body(), "min_seconds_between_attempts"); int minimum = integer(response.body(), "min_seconds_between_attempts");
int maxVerify = integer(response.body(), "max_verify_attempts"); int maxVerify = integer(response.body(), "max_verify_attempts");
int codeLength = integer(response.body(), "code_length");
int otpTtl = integer(response.body(), "ttl_seconds");
int orderTimeout = integer(response.body(), "sms_order_timeout_ms");
int ttl = integer(response.body(), "cache_ttl_seconds"); int ttl = integer(response.body(), "cache_ttl_seconds");
String version = string(response.body(), "version"); String version = string(response.body(), "version");
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400 if (max < 1 || max > 100 || minimum < 0 || minimum > 86400
|| maxVerify < 1 || maxVerify > 10 || ttl < 1 || ttl > 3600) { || maxVerify < 1 || maxVerify > 10
|| codeLength < 4 || codeLength > 10
|| otpTtl < 60 || otpTtl > 900 || otpTtl % 60 != 0
|| orderTimeout < 100 || orderTimeout > 30000
|| ttl < 1 || ttl > 3600) {
throw new IllegalStateException("settings_invalid_range"); throw new IllegalStateException("settings_invalid_range");
} }
Limits limits = new Limits(max, minimum, maxVerify, version); Settings settings = new Settings(
cached = new Cached(limits, now, now.plusSeconds(ttl), max, minimum, maxVerify, codeLength, otpTtl, orderTimeout, version);
cached = new Cached(settings, now, now.plusSeconds(ttl),
response.headers().firstValue("ETag").orElse(null)); response.headers().firstValue("ETag").orElse(null));
return limits; return settings;
} catch (Exception exception) { } catch (Exception exception) {
if (local != null && now.isBefore(local.fetchedAt.plus(Config.SETTINGS_MAX_STALE))) { if (local != null && now.isBefore(local.fetchedAt.plus(Config.SETTINGS_MAX_STALE))) {
LOG.warn("OTP settings refresh failed; using bounded last-known-good"); LOG.warn("OTP settings refresh failed; using bounded last-known-good");
return local.limits; return local.settings;
} }
throw new IllegalStateException("OTP settings unavailable; send denied", exception); throw new IllegalStateException("OTP settings unavailable; send denied", exception);
} }
@@ -0,0 +1,109 @@
package ru.han.chat.keycloak;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class SmsOrderClient {
private static final Pattern MESSAGE_ID =
Pattern.compile("\"sms_message_id\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern ORDERED_AT =
Pattern.compile("\"ordered_at\"\\s*:\\s*\"([^\"]+)\"");
private final HttpClient client;
private final URI serviceUrl;
private final String serviceToken;
SmsOrderClient() {
this(HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2))
.build(),
Config.SMS_SERVICE_URL, Config.SMS_SERVICE_TOKEN);
}
SmsOrderClient(HttpClient client, URI serviceUrl, String serviceToken) {
this.client = client;
this.serviceUrl = serviceUrl;
this.serviceToken = serviceToken;
}
OrderResult order(
String challengeId,
String phone,
String otp,
SettingsBridge.Settings settings,
String requestId,
String traceparent) {
String body = requestBody(challengeId, phone, otp, settings);
HttpRequest.Builder builder = HttpRequest.newBuilder(serviceUrl)
.timeout(Duration.ofMillis(settings.smsOrderTimeoutMs()))
.header("Authorization", "Bearer " + serviceToken)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Request-ID", requestId == null ? challengeId : requestId)
.POST(HttpRequest.BodyPublishers.ofString(body));
if (traceparent != null && !traceparent.isBlank()) builder.header("traceparent", traceparent);
HttpRequest request = builder.build();
for (int attempt = 0; attempt < 2; attempt++) {
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 202) {
return parse(response.body());
}
if (response.statusCode() < 500 || attempt == 1) {
throw new SmsOrderException("sms_order_http_" + response.statusCode());
}
} catch (java.net.http.HttpTimeoutException exception) {
if (attempt == 1) throw new SmsOrderException("sms_order_timeout", exception);
} catch (java.io.IOException exception) {
if (attempt == 1) throw new SmsOrderException("sms_order_io", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new SmsOrderException("sms_order_interrupted", exception);
}
}
throw new SmsOrderException("sms_order_unavailable");
}
static OrderResult parse(String json) {
Matcher idMatcher = MESSAGE_ID.matcher(json);
Matcher orderedMatcher = ORDERED_AT.matcher(json);
if (!idMatcher.find() || !orderedMatcher.find()) {
throw new SmsOrderException("sms_order_invalid_response");
}
try {
return new OrderResult(UUID.fromString(idMatcher.group(1)), Instant.parse(orderedMatcher.group(1)));
} catch (RuntimeException exception) {
throw new SmsOrderException("sms_order_invalid_response", exception);
}
}
static String requestBody(
String challengeId, String phone, String otp, SettingsBridge.Settings settings) {
return ("{\"idempotency_key\":\"keycloak:challenge:%s\","
+ "\"template_code\":\"auth_otp\",\"locale\":\"ru\","
+ "\"phone_e164\":\"%s\",\"substitutions\":{\"code\":\"%s\",\"ttl_min\":\"%d\"},"
+ "\"customer_ref\":\"%s\",\"message_ttl_sec\":%d}").formatted(
escape(challengeId), escape(phone), escape(otp), settings.ttlSeconds() / 60,
escape(challengeId), settings.ttlSeconds());
}
private static String escape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
record OrderResult(UUID smsMessageId, Instant orderedAt) {}
static final class SmsOrderException extends RuntimeException {
SmsOrderException(String message) { super(message); }
SmsOrderException(String message, Throwable cause) { super(message, cause); }
}
}
@@ -6,6 +6,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Version; import jakarta.persistence.Version;
import java.time.Instant; import java.time.Instant;
import java.util.UUID;
@Entity @Entity
@Table(name = "han_otp_challenge") @Table(name = "han_otp_challenge")
@@ -20,7 +21,13 @@ public class OtpChallengeEntity {
@Column(name = "verify_attempts", nullable = false) public int verifyAttempts; @Column(name = "verify_attempts", nullable = false) public int verifyAttempts;
@Column(name = "max_verify_attempts", nullable = false) public int maxVerifyAttempts; @Column(name = "max_verify_attempts", nullable = false) public int maxVerifyAttempts;
@Column(name = "settings_version", nullable = false, length = 128) public String settingsVersion; @Column(name = "settings_version", nullable = false, length = 128) public String settingsVersion;
@Column(name = "provider_id", nullable = false, length = 128) public String providerId; @Column(name = "provider_id", length = 128) public String providerId;
@Column(name = "provider_status", nullable = false, length = 32) public String providerStatus; @Column(name = "provider_status", length = 32) public String providerStatus;
@Column(name = "sms_message_id") public UUID smsMessageId;
@Column(name = "delivery_mode", nullable = false, length = 16) public String deliveryMode;
@Column(name = "challenge_status", nullable = false, length = 16) public String challengeStatus;
@Column(name = "ordered_at") public Instant orderedAt;
@Column(name = "otp_ttl_sec", nullable = false) public int otpTtlSec;
@Column(name = "otp_code_length", nullable = false) public int otpCodeLength;
@Version public long version; @Version public long version;
} }
@@ -5,6 +5,8 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.Instant; import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.ColumnTransformer;
@Entity @Entity
@Table(name = "han_otp_security_event") @Table(name = "han_otp_security_event")
@@ -16,4 +18,15 @@ public class OtpSecurityEventEntity {
@Column(name = "challenge_id", length = 32) public String challengeId; @Column(name = "challenge_id", length = 32) public String challengeId;
@Column(name = "outcome", nullable = false, length = 32) public String outcome; @Column(name = "outcome", nullable = false, length = 32) public String outcome;
@Column(name = "details", length = 256) public String details; @Column(name = "details", length = 256) public String details;
@Column(name = "sms_message_id") public UUID smsMessageId;
@Column(name = "client_ip", columnDefinition = "inet")
@ColumnTransformer(write = "cast(? as inet)")
public String clientIp;
@Column(name = "user_agent") public String userAgent;
@Column(name = "device_id", length = 256) public String deviceId;
@Column(name = "fingerprint", length = 256) public String fingerprint;
@Column(name = "os_name", length = 64) public String osName;
@Column(name = "os_version", length = 64) public String osVersion;
@Column(name = "platform", length = 16) public String platform;
@Column(name = "app_version", length = 64) public String appVersion;
} }
@@ -50,4 +50,65 @@
<column name="occurred_at"/> <column name="occurred_at"/>
</createIndex> </createIndex>
</changeSet> </changeSet>
<changeSet id="han-otp-1.1.0-sms-lifecycle" author="han-chat">
<addColumn tableName="han_otp_challenge">
<column name="sms_message_id" type="uuid"/>
<column name="delivery_mode" type="varchar(16)"/>
<column name="challenge_status" type="varchar(16)"/>
<column name="ordered_at" type="timestamp with time zone"/>
<column name="otp_ttl_sec" type="int"/>
<column name="otp_code_length" type="smallint"/>
</addColumn>
<sql>
UPDATE han_otp_challenge
SET delivery_mode = 'mock',
challenge_status = CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END,
ordered_at = created_at,
otp_ttl_sec = 60,
otp_code_length = 6;
ALTER TABLE han_otp_challenge ALTER COLUMN delivery_mode SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN challenge_status SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN otp_ttl_sec SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN otp_code_length SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN provider_id DROP NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN provider_status DROP NOT NULL;
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_delivery_mode
CHECK (delivery_mode IN ('mock', 'sms'));
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_challenge_status
CHECK (challenge_status IN
('ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'));
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_ttl
CHECK (otp_ttl_sec BETWEEN 60 AND 900 AND otp_ttl_sec % 60 = 0);
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_code_length
CHECK (otp_code_length BETWEEN 4 AND 10);
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_active_sms
CHECK (challenge_status != 'active' OR delivery_mode != 'sms' OR sms_message_id IS NOT NULL);
</sql>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_status_expiry">
<column name="challenge_status"/><column name="expires_at"/>
</createIndex>
<sql>
CREATE INDEX ix_han_otp_challenge_sms_message
ON han_otp_challenge (sms_message_id) WHERE sms_message_id IS NOT NULL;
</sql>
<addColumn tableName="han_otp_security_event">
<column name="sms_message_id" type="uuid"/>
<column name="client_ip" type="inet"/>
<column name="user_agent" type="text"/>
<column name="device_id" type="varchar(256)"/>
<column name="fingerprint" type="varchar(256)"/>
<column name="os_name" type="varchar(64)"/>
<column name="os_version" type="varchar(64)"/>
<column name="platform" type="varchar(16)"/>
<column name="app_version" type="varchar(64)"/>
</addColumn>
<sql>
ALTER TABLE han_otp_security_event ADD CONSTRAINT ck_han_otp_event_platform
CHECK (platform IS NULL OR platform IN ('web', 'ios', 'android'));
CREATE INDEX ix_han_otp_event_sms_message
ON han_otp_security_event (sms_message_id) WHERE sms_message_id IS NOT NULL;
</sql>
</changeSet>
</databaseChangeLog> </databaseChangeLog>
@@ -2,6 +2,7 @@ package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -19,4 +20,12 @@ class CryptoTest {
assertTrue(Crypto.constantTimeEquals("same-value", "same-value")); assertTrue(Crypto.constantTimeEquals("same-value", "same-value"));
assertFalse(Crypto.constantTimeEquals("same-value", "same-valuf")); assertFalse(Crypto.constantTimeEquals("same-value", "same-valuf"));
} }
@Test
void randomOtpIsNumericAndUsesRequestedLength() {
String code = Crypto.randomNumericCode(8);
assertTrue(code.matches("\\d{8}"));
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(3));
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(11));
}
} }
@@ -0,0 +1,41 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class SmsLifecycleContractTest {
@Test
void migrationContainsLifecycleSnapshotAndAuditColumns() throws Exception {
String migration = Files.readString(
Path.of("src/main/resources/META-INF/han-otp-changelog.xml"));
for (String required : new String[] {
"sms_message_id", "delivery_mode", "challenge_status", "ordered_at",
"otp_ttl_sec", "otp_code_length", "client_ip", "user_agent",
"device_id", "fingerprint", "os_name", "os_version", "platform", "app_version",
"'ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'"
}) {
assertTrue(migration.contains(required), "Missing migration contract: " + required);
}
assertTrue(migration.contains("delivery_mode = 'mock'"));
assertTrue(migration.contains(
"CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END"));
}
@Test
void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception {
String template = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
assertTrue(template.contains("otpCodeLength"));
assertTrue(template.contains("otpExpiresAt"));
assertTrue(template.contains("(otpExpiresAt!0)?c"));
assertTrue(template.contains("name=\"otp_action\" value=\"resend\""));
assertTrue(template.contains("han_device_id"));
assertTrue(script.contains("han_device_id"));
assertTrue(script.contains("expiresAt - Date.now()"));
assertTrue(script.contains("Number.isFinite(expiresAt)"));
}
}
@@ -0,0 +1,75 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
class SmsOrderClientTest {
private static final SettingsBridge.Settings SETTINGS =
new SettingsBridge.Settings(3, 30, 5, 6, 120, 3000, "v1");
@Test
void requestUsesStableIdempotencyAndSnapshot() {
String body = SmsOrderClient.requestBody(
"challenge-1", "+79001234567", "482193", SETTINGS);
assertTrue(body.contains("\"idempotency_key\":\"keycloak:challenge:challenge-1\""));
assertTrue(body.contains("\"template_code\":\"auth_otp\""));
assertTrue(body.contains("\"code\":\"482193\""));
assertTrue(body.contains("\"ttl_min\":\"2\""));
assertTrue(body.contains("\"message_ttl_sec\":120"));
assertFalse(body.contains("Authorization"));
}
@Test
void parsesOnlyUuidAndIsoOrderedTimestamp() {
UUID id = UUID.randomUUID();
SmsOrderClient.OrderResult result = SmsOrderClient.parse(
"{\"sms_message_id\":\"" + id + "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}");
assertEquals(id, result.smsMessageId());
assertEquals(Instant.parse("2026-07-22T13:00:00Z"), result.orderedAt());
assertThrows(SmsOrderClient.SmsOrderException.class,
() -> SmsOrderClient.parse("{\"sms_message_id\":\"not-a-uuid\"}"));
}
@Test
void retriesServerFailureWithSameOrder() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
AtomicInteger calls = new AtomicInteger();
UUID messageId = UUID.randomUUID();
server.createContext("/internal/sms/v1/send", exchange -> {
assertEquals("Bearer test-token", exchange.getRequestHeaders().getFirst("Authorization"));
int call = calls.incrementAndGet();
byte[] response = (call == 1 ? "{}" :
"{\"sms_message_id\":\"" + messageId
+ "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}")
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(call == 1 ? 503 : 202, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start();
try {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort()
+ "/internal/sms/v1/send");
SmsOrderClient client = new SmsOrderClient(HttpClient.newHttpClient(), uri, "test-token");
SmsOrderClient.OrderResult result =
client.order("challenge-1", "+79001234567", "482193", SETTINGS, "request-1", null);
assertEquals(messageId, result.smsMessageId());
assertEquals(2, calls.get());
} finally {
server.stop(0);
}
}
}
@@ -20,5 +20,6 @@ verifyOtp=Подтвердить
mockMode=Тестовый режим отправки кода mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона. phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован. otpInvalid=Код неверен, истёк или уже использован.
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже. otpLimited=Слишком много попыток. Повторите позже.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже. otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
@@ -16,8 +16,15 @@
<form id="kc-otp-form" action="${url.loginAction}" method="post"> <form id="kc-otp-form" action="${url.loginAction}" method="post">
<input id="otp" name="otp" type="hidden" value=""/> <input id="otp" name="otp" type="hidden" value=""/>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>"> <input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
<#list 0..5 as index> <input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>"
style="grid-template-columns: repeat(${otpCodeLength!6}, minmax(0, 1fr));">
<#list 0..((otpCodeLength!6) - 1) as index>
<input class="han-otp-digit" type="text" inputmode="numeric" maxlength="1" <input class="han-otp-digit" type="text" inputmode="numeric" maxlength="1"
aria-label="${msg("otpDigit", index + 1)}" aria-label="${msg("otpDigit", index + 1)}"
<#if index == 0>autocomplete="one-time-code" autofocus</#if> <#if index == 0>autocomplete="one-time-code" autofocus</#if>
@@ -33,8 +40,10 @@
</#if> </#if>
<div class="han-resend"> <div class="han-resend">
<p id="han-resend-countdown">${msg("otpResendCountdown")} <strong>0:59</strong></p> <p id="han-resend-countdown" data-expires-at="${(otpExpiresAt!0)?c}">
<button id="han-resend-button" type="button" hidden onclick="window.history.back()"> ${msg("otpResendCountdown")} <strong>—</strong>
</p>
<button id="han-resend-button" type="submit" name="otp_action" value="resend" hidden>
<span aria-hidden="true">↻</span> <span aria-hidden="true">↻</span>
<span>${msg("otpResend")}</span> <span>${msg("otpResend")}</span>
</button> </button>
@@ -45,6 +54,6 @@
</button> </button>
</form> </form>
</div> </div>
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script> <script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
</#if> </#if>
</@layout.registrationLayout> </@layout.registrationLayout>
@@ -17,6 +17,12 @@
</div> </div>
<form id="kc-phone-form" action="${url.loginAction}" method="post"> <form id="kc-phone-form" action="${url.loginAction}" method="post">
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<div class="han-field"> <div class="han-field">
<label for="phone">${msg("phoneLabel")}</label> <label for="phone">${msg("phoneLabel")}</label>
<input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" <input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel"
@@ -46,6 +52,6 @@
<span>${msg("privacyPolicy")}</span> <span>${msg("privacyPolicy")}</span>
</p> </p>
</div> </div>
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script> <script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
</#if> </#if>
</@layout.registrationLayout> </@layout.registrationLayout>
@@ -1,4 +1,30 @@
(function () { (function () {
function randomId() {
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (char) {
var value = Math.random() * 16 | 0;
return (char === "x" ? value : (value & 3 | 8)).toString(16);
});
}
function initDeviceMetadata() {
var deviceId = window.localStorage.getItem("han_device_id") || randomId();
var fingerprint = window.localStorage.getItem("han_fingerprint") || randomId();
window.localStorage.setItem("han_device_id", deviceId);
window.localStorage.setItem("han_fingerprint", fingerprint);
document.querySelectorAll(".han-device-id").forEach(function (input) {
if (!input.value) input.value = deviceId;
});
document.querySelectorAll(".han-fingerprint").forEach(function (input) {
if (!input.value) input.value = fingerprint;
});
document.querySelectorAll(".han-os-name").forEach(function (input) {
if (!input.value) {
input.value = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
}
});
}
function initPhoneForm() { function initPhoneForm() {
var input = document.getElementById("phone"); var input = document.getElementById("phone");
var submit = document.getElementById("han-phone-submit"); var submit = document.getElementById("han-phone-submit");
@@ -81,23 +107,31 @@
syncOtp(); syncOtp();
var seconds = 59;
var countdown = document.getElementById("han-resend-countdown"); var countdown = document.getElementById("han-resend-countdown");
var countdownValue = countdown && countdown.querySelector("strong"); var countdownValue = countdown && countdown.querySelector("strong");
var resend = document.getElementById("han-resend-button"); var resend = document.getElementById("han-resend-button");
if (!countdown || !countdownValue || !resend) return; if (!countdown || !countdownValue || !resend) return;
var timer = window.setInterval(function () { var expiresAt = Number(countdown.getAttribute("data-expires-at"));
seconds -= 1; if (!Number.isFinite(expiresAt)) expiresAt = Date.now();
countdownValue.textContent = "0:" + String(seconds).padStart(2, "0"); var timer;
function updateCountdown() {
var seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
countdownValue.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
if (seconds <= 0) { if (seconds <= 0) {
window.clearInterval(timer); if (timer) window.clearInterval(timer);
countdown.hidden = true; countdown.hidden = true;
resend.hidden = false; resend.hidden = false;
} }
}, 1000); }
updateCountdown();
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
resend.addEventListener("click", function () {
window.setTimeout(function () { resend.disabled = true; }, 0);
});
} }
initDeviceMetadata();
initPhoneForm(); initPhoneForm();
initOtpForm(); initOtpForm();
})(); })();
+3 -1
View File
@@ -12,11 +12,12 @@ services:
NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0} NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m} NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m}
NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m} NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m}
NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-10r/m} NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-60r/m}
NGINX_RATE_LIMIT_PUBLIC: ${NGINX_RATE_LIMIT_PUBLIC:-60r/m} NGINX_RATE_LIMIT_PUBLIC: ${NGINX_RATE_LIMIT_PUBLIC:-60r/m}
NGINX_RATE_LIMIT_POLLING: ${NGINX_RATE_LIMIT_POLLING:-60r/m} NGINX_RATE_LIMIT_POLLING: ${NGINX_RATE_LIMIT_POLLING:-60r/m}
NGINX_RATE_LIMIT_DOWNLOADS: ${NGINX_RATE_LIMIT_DOWNLOADS:-30r/m} NGINX_RATE_LIMIT_DOWNLOADS: ${NGINX_RATE_LIMIT_DOWNLOADS:-30r/m}
NGINX_RATE_LIMIT_BITRIX: ${NGINX_RATE_LIMIT_BITRIX:-120r/m} NGINX_RATE_LIMIT_BITRIX: ${NGINX_RATE_LIMIT_BITRIX:-120r/m}
NGINX_RATE_LIMIT_SMS_CALLBACK: ${NGINX_RATE_LIMIT_SMS_CALLBACK:-120r/m}
NGINX_RATE_LIMIT_WS: ${NGINX_RATE_LIMIT_WS:-30r/m} NGINX_RATE_LIMIT_WS: ${NGINX_RATE_LIMIT_WS:-30r/m}
NGINX_MESSAGE_READ_TIMEOUT_SEC: ${NGINX_MESSAGE_READ_TIMEOUT_SEC:-330} NGINX_MESSAGE_READ_TIMEOUT_SEC: ${NGINX_MESSAGE_READ_TIMEOUT_SEC:-330}
FRONTEND_DEV_PROXY_ENABLED: ${FRONTEND_DEV_PROXY_ENABLED:-false} FRONTEND_DEV_PROXY_ENABLED: ${FRONTEND_DEV_PROXY_ENABLED:-false}
@@ -37,6 +38,7 @@ services:
frontend-static: {condition: service_completed_successfully} frontend-static: {condition: service_completed_successfully}
api-backend: {condition: service_healthy} api-backend: {condition: service_healthy}
keycloak: {condition: service_healthy} keycloak: {condition: service_healthy}
sms-service: {condition: service_healthy}
bitrix-local-app: {condition: service_healthy} bitrix-local-app: {condition: service_healthy}
bitrix-sync: {condition: service_healthy} bitrix-sync: {condition: service_healthy}
healthcheck: healthcheck:
@@ -50,6 +50,7 @@ http {
limit_req_zone $polling_key zone=polling:10m rate=${NGINX_RATE_LIMIT_POLLING}; limit_req_zone $polling_key zone=polling:10m rate=${NGINX_RATE_LIMIT_POLLING};
limit_req_zone $binary_remote_addr zone=downloads:10m rate=${NGINX_RATE_LIMIT_DOWNLOADS}; limit_req_zone $binary_remote_addr zone=downloads:10m rate=${NGINX_RATE_LIMIT_DOWNLOADS};
limit_req_zone $binary_remote_addr zone=bitrix_callbacks:10m rate=${NGINX_RATE_LIMIT_BITRIX}; limit_req_zone $binary_remote_addr zone=bitrix_callbacks:10m rate=${NGINX_RATE_LIMIT_BITRIX};
limit_req_zone $binary_remote_addr zone=sms_callbacks:10m rate=${NGINX_RATE_LIMIT_SMS_CALLBACK};
limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=${NGINX_RATE_LIMIT_WS}; limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=${NGINX_RATE_LIMIT_WS};
limit_conn_zone $binary_remote_addr zone=connections:10m; limit_conn_zone $binary_remote_addr zone=connections:10m;
@@ -61,6 +62,7 @@ http {
upstream api_backend { server api-backend:8000; keepalive 32; } upstream api_backend { server api-backend:8000; keepalive 32; }
upstream keycloak_upstream { server keycloak:8080; keepalive 16; } upstream keycloak_upstream { server keycloak:8080; keepalive 16; }
upstream sms_service_upstream { server sms-service:8080; keepalive 8; }
upstream bitrix_local { server bitrix-local-app:8080; keepalive 16; } upstream bitrix_local { server bitrix-local-app:8080; keepalive 16; }
upstream bitrix_sync_upstream { server bitrix-sync:8080; keepalive 8; } upstream bitrix_sync_upstream { server bitrix-sync:8080; keepalive 8; }
upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; } upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; }
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC" required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_SMS_CALLBACK NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC"
for name in $required; do for name in $required; do
eval "value=\${$name:-}" eval "value=\${$name:-}"
if [ -z "$value" ]; then if [ -z "$value" ]; then
@@ -24,7 +24,7 @@ if [ "${FRONTEND_DEV_PROXY_ENABLED:-false}" = "true" ] \
fi fi
umask 027 umask 027
common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}' common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_SMS_CALLBACK} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}'
site_vars='${PUBLIC_HOST} ${NGINX_TLS_CERTIFICATE} ${NGINX_TLS_CERTIFICATE_KEY} ${NGINX_MESSAGE_READ_TIMEOUT_SEC} ${BITRIX_FRAME_ANCESTORS}' site_vars='${PUBLIC_HOST} ${NGINX_TLS_CERTIFICATE} ${NGINX_TLS_CERTIFICATE_KEY} ${NGINX_MESSAGE_READ_TIMEOUT_SEC} ${BITRIX_FRAME_ANCESTORS}'
security_vars='${NGINX_HSTS_MAX_AGE} ${S3_CONNECT_SRC}' security_vars='${NGINX_HSTS_MAX_AGE} ${S3_CONNECT_SRC}'
@@ -120,6 +120,20 @@ server {
proxy_pass http://keycloak_upstream; proxy_pass http://keycloak_upstream;
} }
location = /callbacks/idgtl/sms {
if ($request_method != POST) { return 405; }
allow 185.203.96.7;
deny all;
limit_req zone=sms_callbacks burst=30 nodelay;
client_max_body_size 256k;
proxy_buffering off;
proxy_cache off;
include /etc/nginx/snippets/proxy-common.conf;
proxy_read_timeout 15s;
proxy_pass http://sms_service_upstream;
}
location ^~ /callbacks/idgtl/ { return 404; }
location = /bitrix/handler { location = /bitrix/handler {
limit_req zone=bitrix_callbacks burst=60 nodelay; limit_req zone=bitrix_callbacks burst=60 nodelay;
include /etc/nginx/snippets/proxy-common.conf; include /etc/nginx/snippets/proxy-common.conf;
+36 -5
View File
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
REQUIRED = { REQUIRED = {
"APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "DATABASE_URL", "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "DATABASE_URL",
"BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
"MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST", "MESSAGE_SAFETY_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST",
"PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL",
"KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL", "KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL",
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL", "REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL",
@@ -18,6 +18,9 @@ REQUIRED = {
"BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN", "BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN",
"BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN", "BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY", "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY",
"KEYCLOAK_SMS_SERVICE_URL", "KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN",
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
"IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD",
"KEYCLOAK_ADMIN", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET", "KEYCLOAK_ADMIN", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET",
"BITRIX_TOKEN_ENCRYPTION_KEY", "BITRIX_TOKEN_ENCRYPTION_KEY",
"SELECTEL_S3_ENDPOINT_URL", "SELECTEL_S3_ENDPOINT_URL",
@@ -29,7 +32,10 @@ REQUIRED = {
SECRET_KEYS = { SECRET_KEYS = {
key for key in REQUIRED key for key in REQUIRED
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY")) if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY"))
} | {"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN"} } | {
"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN",
"IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD",
}
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I) PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)
@@ -64,16 +70,25 @@ def main() -> int:
value = env.get(key, "") value = env.get(key, "")
if value and (len(value) < 16 or PLACEHOLDER.search(value)): if value and (len(value) < 16 or PLACEHOLDER.search(value)):
errors.append(f"{key}: секрет должен быть непустым, уникальным и длиной >=16") errors.append(f"{key}: секрет должен быть непустым, уникальным и длиной >=16")
for key in ("SMS_SERVICE_TOKEN", "KEYCLOAK_SMS_SERVICE_TOKEN"):
if env.get(key) and len(env[key]) < 32:
errors.append(f"{key}: service token должен иметь длину >=32")
production = env.get("APP_ENV") in {"production-like", "production"} production = env.get("APP_ENV") in {"production-like", "production"}
if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false": if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false":
errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false") errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false")
if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true": if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true":
errors.append("NGINX_TLS_ENABLED: production-like/production требует true") errors.append("NGINX_TLS_ENABLED: production-like/production требует true")
for key in ("PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL"): for key in (
"PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
):
if env.get(key) and urlparse(env[key]).scheme != "https": if env.get(key) and urlparse(env[key]).scheme != "https":
errors.append(f"{key}: публичный URL должен использовать https") errors.append(f"{key}: публичный URL должен использовать https")
for key in ("KEYCLOAK_INTERNAL_URL", "MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL"): for key in (
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_SMS_SERVICE_URL",
"MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL",
):
parsed = urlparse(env.get(key, "")) parsed = urlparse(env.get(key, ""))
if parsed.scheme != "http" or "." in (parsed.hostname or ""): if parsed.scheme != "http" or "." in (parsed.hostname or ""):
errors.append(f"{key}: ожидается http URL с Docker DNS service name") errors.append(f"{key}: ожидается http URL с Docker DNS service name")
@@ -96,14 +111,20 @@ def main() -> int:
errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}") errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}")
for key in ( for key in (
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL", "DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
"MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL", "MESSAGE_SAFETY_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_URL",
): ):
value = env.get(key, "") value = env.get(key, "")
if "sslmode=verify-full" not in value or "sslrootcert=" not in value: if "sslmode=verify-full" not in value or "sslrootcert=" not in value:
errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert") errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert")
if re.search(r"(?:[?&](?:options|currentSchema)=)", value, re.I):
errors.append(
f"{key}: options/currentSchema запрещены через PgBouncer; "
"используйте database-level search_path роли"
)
pairs = ( pairs = (
("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"), ("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"),
("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"), ("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"),
("KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN"),
) )
for left, right in pairs: for left, right in pairs:
if env.get(left) != env.get(right): if env.get(left) != env.get(right):
@@ -121,6 +142,16 @@ def main() -> int:
if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true": if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true":
if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true": if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true":
errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP") errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP")
real_sms = env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "false"
if production and real_sms and (
env.get("IDGTL_SMS_BASE_URL", "").rstrip("/") != "https://direct.i-dgtl.ru"
):
errors.append("IDGTL_SMS_BASE_URL: production contract требует https://direct.i-dgtl.ru")
expected_callback = f"https://{env.get('PUBLIC_HOST', '')}/callbacks/idgtl/sms"
if production and env.get("IDGTL_SMS_CALLBACK_PUBLIC_URL") != expected_callback:
errors.append(
"IDGTL_SMS_CALLBACK_PUBLIC_URL должен совпадать с публичным host и callback path"
)
if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}: if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}:
errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint") errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint")
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim AS builder
WORKDIR /build
RUN pip install --no-cache-dir --upgrade pip build
COPY pyproject.toml ./
COPY app ./app
RUN python -m build --wheel
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
WORKDIR /app
COPY --from=builder /build/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
COPY alembic.ini ./
COPY migrations ./migrations
USER 10001:10001
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-proxy-headers"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_table_schema = sms
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -0,0 +1 @@
"""HAN SMS service."""
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
import asyncpg
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SCHEMA = "sms"
class Channel(StrEnum):
SMS = "SMS"
class SendStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
FAILED = "failed"
UNCERTAIN = "uncertain"
SKIPPED = "skipped"
class DeliveryStatus(StrEnum):
UNKNOWN = "unknown"
SENT = "sent"
DELIVERED = "delivered"
UNDELIVERED = "undelivered"
UNSENT = "unsent"
class Base(DeclarativeBase):
pass
class SmsTemplate(Base):
__tablename__ = "sms_template"
__table_args__ = (
UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
Index(
"uq_template_active",
"code",
"channel",
"locale",
unique=True,
postgresql_where=text("is_active"),
),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
code: Mapped[str] = mapped_column(String(64), nullable=False)
channel: Mapped[Channel] = mapped_column(
Enum(
Channel,
name="sms_channel",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
)
)
locale: Mapped[str] = mapped_column(String(16), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
body_template: Mapped[str] = mapped_column(Text, nullable=False)
placeholders: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
sender_name: Mapped[str | None] = mapped_column(String(64))
max_parts: Mapped[int] = mapped_column(SmallInteger, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
created_by: Mapped[str] = mapped_column(String(64), nullable=False)
class SmsSetting(Base):
__tablename__ = "sms_setting"
__table_args__ = {"schema": SCHEMA}
setting_key: Mapped[str] = mapped_column(String(128), primary_key=True)
setting_value: Mapped[object] = mapped_column(JSONB, nullable=False)
value_type: Mapped[str] = mapped_column(String(16), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class SmsOutboundMessage(Base):
__tablename__ = "sms_outbound_message"
__table_args__ = (
UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
Index(
"uq_outbound_provider_message",
"provider",
"provider_message_id",
unique=True,
postgresql_where=text("provider_message_id IS NOT NULL"),
),
Index("ix_outbound_phone_created", "phone_e164", text("created_at DESC")),
Index(
"ix_outbound_requester_process_created",
"requester_service",
"process",
text("created_at DESC"),
),
Index("ix_outbound_customer_ref", "customer_ref"),
Index("ix_outbound_send_created", "send_status", "created_at"),
Index("ix_outbound_delivery_updated", "delivery_status", "updated_at"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
requester_service: Mapped[str] = mapped_column(String(64), nullable=False)
process: Mapped[str] = mapped_column(String(64), nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
phone_e164: Mapped[str] = mapped_column(String(16), nullable=False)
phone_digits: Mapped[str] = mapped_column(String(15), nullable=False)
phone_masked: Mapped[str] = mapped_column(String(32), nullable=False)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.sms_template.id"), nullable=False
)
template_code: Mapped[str] = mapped_column(String(64), nullable=False)
body_rendered: Mapped[str] = mapped_column(Text, nullable=False)
substitutions: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
send_status: Mapped[SendStatus] = mapped_column(
Enum(
SendStatus,
name="sms_send_status",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
delivery_status: Mapped[DeliveryStatus] = mapped_column(
Enum(
DeliveryStatus,
name="sms_delivery_status",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
provider_message_id: Mapped[str | None] = mapped_column(String(128))
provider_external_id: Mapped[str | None] = mapped_column(String(128))
customer_ref: Mapped[str | None] = mapped_column(String(128))
idempotency_key: Mapped[str] = mapped_column(String(192), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
request_id: Mapped[str | None] = mapped_column(String(128))
traceparent: Mapped[str | None] = mapped_column(String(55))
provider_http_status: Mapped[int | None] = mapped_column(Integer)
provider_error_code: Mapped[str | None] = mapped_column(String(64))
provider_error_message: Mapped[str | None] = mapped_column(String(256))
sender_name: Mapped[str] = mapped_column(String(64), nullable=False)
message_ttl_sec: Mapped[int | None] = mapped_column(Integer)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
worker_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
parts: Mapped[int | None] = mapped_column(Integer)
price: Mapped[Decimal | None] = mapped_column(Numeric(14, 4))
currency: Mapped[str | None] = mapped_column(String(3))
callback_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SmsCallbackEvent(Base):
__tablename__ = "sms_callback_event"
__table_args__ = (
UniqueConstraint(
"message_uuid",
"callback_event",
"status",
"status_time",
name="uq_callback_event",
),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
message_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
callback_event: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
status_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def asyncpg_dsn(url: str) -> str:
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
def create_postgres_engine(url: str) -> AsyncEngine:
dsn = asyncpg_dsn(url)
async def connect() -> asyncpg.Connection:
return await asyncpg.connect(dsn=dsn)
return create_async_engine(
"postgresql+asyncpg://", async_creator=connect, pool_pre_ping=True
)
class Database:
def __init__(self, url: str) -> None:
self.engine = create_postgres_engine(url)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.sessions() as session:
yield session
async def close(self) -> None:
await self.engine.dispose()
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import hashlib
import hmac
import json
import re
import string
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import phonenumbers
from app.db import DeliveryStatus, SendStatus
GSM_BASIC = (
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ "
"!\"%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
GSM_EXTENDED = "^{}\\[~]|€"
PHONE_RE = re.compile(r"^\+[1-9]\d{7,14}$")
class DomainError(Exception):
def __init__(
self, code: str, status: int, message: str, details: dict[str, Any] | None = None
) -> None:
self.code = code
self.status = status
self.message = message
self.details = details or {}
super().__init__(message)
def normalize_phone(value: str) -> tuple[str, str, str]:
if not PHONE_RE.fullmatch(value):
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
try:
parsed = phonenumbers.parse(value, None)
except phonenumbers.NumberParseException:
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164") from None
if not phonenumbers.is_valid_number(parsed):
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
if normalized != value:
raise DomainError("sms_request_invalid", 422, "phone_e164 must be canonical E.164")
digits = normalized[1:]
masked = f"+{digits[:1]}{'*' * max(0, len(digits) - 5)}{digits[-4:]}"
return normalized, digits, masked
def request_fingerprint(payload: dict[str, Any]) -> str:
canonical = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def destination_hmac(phone_e164: str, key: bytes) -> str:
return hmac.new(key, phone_e164.encode(), hashlib.sha256).hexdigest()
def sms_parts(body: str) -> int:
if not body or "\ufeff" in body or "\x00" in body:
raise DomainError("sms_request_invalid", 422, "Rendered message contains invalid text")
gsm_units = 0
for char in body:
if char in GSM_BASIC:
gsm_units += 1
elif char in GSM_EXTENDED:
gsm_units += 2
else:
total = len(body.encode("utf-16-be")) // 2
return 1 if total <= 70 else (total + 66) // 67
return 1 if gsm_units <= 160 else (gsm_units + 152) // 153
def render_template(
body_template: str,
placeholders: list[str],
substitutions: dict[str, Any],
max_parts: int,
) -> str:
expected = set(placeholders)
supplied = set(substitutions)
if expected != supplied:
raise DomainError(
"sms_request_invalid",
422,
"Substitutions do not match template placeholders",
{"missing": sorted(expected - supplied), "unknown": sorted(supplied - expected)},
)
parsed = {
field_name
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
if field_name is not None
and not format_spec
and not conversion
and field_name.isidentifier()
}
if parsed != expected or any(
format_spec or conversion
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
if field_name is not None
):
raise DomainError("sms_request_invalid", 422, "Template placeholder contract is invalid")
body = body_template.format_map({key: str(value) for key, value in substitutions.items()})
if len(body.encode("utf-8")) > 2048 or sms_parts(body) > max_parts:
raise DomainError("sms_request_invalid", 422, "Rendered message exceeds template limit")
return body
@dataclass(frozen=True)
class ProviderResult:
send_status: SendStatus
http_status: int | None = None
message_uuid: str | None = None
external_id: str | None = None
error_code: str | None = None
error_message: str | None = None
retry_safe: bool = False
contract_violation: bool = False
DELIVERY_RANK = {
DeliveryStatus.UNKNOWN: 0,
DeliveryStatus.SENT: 1,
DeliveryStatus.DELIVERED: 2,
DeliveryStatus.UNDELIVERED: 2,
DeliveryStatus.UNSENT: 2,
}
def delivery_transition(current: DeliveryStatus, incoming: str) -> DeliveryStatus | None:
try:
target = DeliveryStatus(incoming.lower())
except ValueError:
return None
if DELIVERY_RANK[target] < DELIVERY_RANK[current]:
return current
if DELIVERY_RANK[target] == DELIVERY_RANK[current] and target != current:
return current
return target
def parse_status_time(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
raise DomainError("callback_invalid", 422, "Invalid callback status_time") from None
if parsed.tzinfo is None:
raise DomainError("callback_invalid", 422, "Callback status_time requires timezone")
return parsed
+322
View File
@@ -0,0 +1,322 @@
from __future__ import annotations
import base64
import binascii
import hmac
import logging
import time
import uuid
from contextlib import asynccontextmanager
from typing import Annotated, Any
import structlog
import uvicorn
from fastapi import Body, Depends, FastAPI, Header, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from pydantic import ValidationError
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.db import Database, SmsTemplate
from app.domain import DomainError
from app.metrics import CALLBACK_LAG, CALLBACK_TOTAL
from app.schemas import CallbackItem, ErrorEnvelope, MessageResponse, SendRequest, SendResponse
from app.service import (
apply_callback,
create_order,
load_runtime_settings,
read_message,
)
from app.settings import get_settings
log = structlog.get_logger()
def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
configure_logging(settings.log_level)
app.state.settings = settings
app.state.db = Database(settings.database_url)
yield
await app.state.db.close()
app = FastAPI(
title="HAN SMS Service",
version="1.0.0",
openapi_version="3.1.0",
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
@app.middleware("http")
async def request_context(request: Request, call_next: Any) -> Response:
supplied = request.headers.get("X-Request-ID", "").strip()
request_id = supplied[:128] if supplied and supplied.isprintable() else str(uuid.uuid4())
request.state.request_id = request_id
started = time.monotonic()
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
request_id=request_id,
method=request.method,
route=request.url.path,
**{"service.name": "sms-service"},
)
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
log.info(
"request.complete",
status_code=response.status_code,
duration_ms=round((time.monotonic() - started) * 1000, 2),
)
return response
def error_response(
request: Request,
code: str,
message: str,
status: int,
details: dict[str, Any] | list[dict[str, Any]] | None = None,
) -> JSONResponse:
return JSONResponse(
status_code=status,
content={
"error": {
"code": code,
"message": message,
"request_id": getattr(request.state, "request_id", str(uuid.uuid4())),
"details": details or {},
}
},
)
@app.exception_handler(DomainError)
async def domain_error(request: Request, exc: DomainError) -> JSONResponse:
response = error_response(request, exc.code, exc.message, exc.status, exc.details)
if "retry_after" in exc.details:
response.headers["Retry-After"] = str(exc.details["retry_after"])
return response
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
details = [
{"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]}
for item in exc.errors()
]
log.warning("request.validation_failed", details=details)
return error_response(
request, "sms_request_invalid", "SMS request validation failed", 422, details
)
@app.exception_handler(StarletteHTTPException)
async def http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = "not_found" if exc.status_code == 404 else "method_not_allowed"
return error_response(request, code, "Resource was not found", exc.status_code)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
log.exception("request.failed", error_code="internal_error")
return error_response(request, "internal_error", "Internal server error", 500)
async def session(request: Request):
async for value in request.app.state.db.session():
yield value
Session = Annotated[AsyncSession, Depends(session)]
async def bearer_auth(request: Request) -> None:
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
raise DomainError("unauthorized", 401, "Authentication failed")
supplied = authorization.removeprefix("Bearer ").strip()
expected = request.app.state.settings.service_token.get_secret_value()
if not supplied or not hmac.compare_digest(supplied, expected):
raise DomainError("unauthorized", 401, "Authentication failed")
InternalAuth = Annotated[None, Depends(bearer_auth)]
def basic_auth(request: Request) -> None:
authorization = request.headers.get("Authorization", "")
encoded = (
authorization.removeprefix("Basic ").strip() if authorization.startswith("Basic ") else ""
)
try:
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
username, password = decoded.split(":", 1)
except (binascii.Error, UnicodeDecodeError, ValueError):
raise DomainError("unauthorized", 401, "Authentication failed") from None
settings = request.app.state.settings
valid_user = hmac.compare_digest(username, settings.callback_username.get_secret_value())
valid_password = hmac.compare_digest(password, settings.callback_password.get_secret_value())
if not (valid_user and valid_password):
raise DomainError("unauthorized", 401, "Authentication failed")
CallbackAuth = Annotated[None, Depends(basic_auth)]
@app.get("/health/live", tags=["health"])
async def live() -> dict[str, str]:
return {"status": "live"}
@app.get("/health/ready", tags=["health"])
async def ready(db: Session) -> JSONResponse:
components = {
"postgres": "failed",
"schema": "failed",
"settings": "failed",
"template": "failed",
}
try:
await db.execute(text("SELECT 1"))
components["postgres"] = "ok"
revision = await db.scalar(text("SELECT version_num FROM sms.alembic_version LIMIT 1"))
if revision != "0002_seed":
raise RuntimeError("unexpected sms schema revision")
components["schema"] = "ok"
runtime = await load_runtime_settings(db)
components["settings"] = "ok"
template_count = await db.scalar(
select(func.count(SmsTemplate.id)).where(
SmsTemplate.code == "auth_otp",
SmsTemplate.is_active.is_(True),
SmsTemplate.approved_at.is_not(None),
(SmsTemplate.sender_name.is_not(None))
| (text(":sender <> ''").bindparams(sender=runtime.default_sender_name)),
)
)
if template_count != 1:
raise RuntimeError("active approved auth_otp template is missing")
components["template"] = "ok"
except Exception:
log.warning("readiness.failed")
failed = "failed" in components.values()
return JSONResponse(
{"status": "not_ready" if failed else "ready", "components": components},
status_code=503 if failed else 200,
)
@app.get("/metrics", include_in_schema=False)
async def metrics() -> Response:
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
@app.post(
"/internal/sms/v1/send",
response_model=SendResponse,
responses={
401: {"model": ErrorEnvelope},
409: {"model": ErrorEnvelope},
422: {"model": ErrorEnvelope},
429: {"model": ErrorEnvelope},
503: {"model": ErrorEnvelope},
},
tags=["internal"],
)
async def send(
body: SendRequest,
request: Request,
db: Session,
_auth: InternalAuth,
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
traceparent: Annotated[
str | None,
Header(pattern=r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$"),
] = None,
) -> JSONResponse:
result, created = await create_order(
db,
body,
x_request_id,
traceparent,
request.app.state.settings.service_token.get_secret_value().encode(),
)
return JSONResponse(result.model_dump(mode="json"), status_code=202 if created else 200)
@app.get(
"/internal/sms/v1/messages/{sms_message_id}",
response_model=MessageResponse,
responses={401: {"model": ErrorEnvelope}, 404: {"model": ErrorEnvelope}},
tags=["internal"],
)
async def message(sms_message_id: uuid.UUID, db: Session, _auth: InternalAuth) -> MessageResponse:
return await read_message(db, sms_message_id)
@app.post(
"/callbacks/idgtl/sms",
status_code=204,
responses={401: {"model": ErrorEnvelope}, 422: {"model": ErrorEnvelope}},
tags=["callback"],
)
async def callback(
payload: Annotated[list[dict[str, Any]], Body(min_length=1, max_length=1000)],
request: Request,
db: Session,
_auth: CallbackAuth,
) -> Response:
valid_count = 0
for raw in payload:
try:
item = CallbackItem.model_validate(raw)
except ValidationError:
CALLBACK_TOTAL.labels("idgtl", "invalid").inc()
log.warning("callback.rejected", reason="schema_invalid")
continue
accepted = await apply_callback(db, item)
CALLBACK_TOTAL.labels("idgtl", "accepted" if accepted else "rejected").inc()
if accepted:
valid_count += 1
lag = max(0.0, (datetime_now() - item.status_time).total_seconds())
CALLBACK_LAG.labels("idgtl", item.status.lower()).observe(lag)
await db.commit()
return Response(status_code=204, headers={"X-Callback-Items-Accepted": str(valid_count)})
def datetime_now():
from datetime import UTC, datetime
return datetime.now(UTC)
def run() -> None:
settings = get_settings()
uvicorn.run(
"app.main:app",
host="0.0.0.0", # noqa: S104 - required container listener
port=settings.api_port,
proxy_headers=False,
)
@@ -0,0 +1,39 @@
from prometheus_client import Counter, Gauge, Histogram
SEND_TOTAL = Counter(
"sms_send_total",
"Provider send outcomes",
("provider", "send_status"),
)
PROVIDER_LATENCY = Histogram(
"sms_provider_request_duration_seconds",
"Provider request latency",
("provider",),
)
UNCERTAIN_TOTAL = Counter(
"sms_uncertain_total",
"Ambiguous provider outcomes",
("provider",),
)
CALLBACK_TOTAL = Counter(
"sms_callback_total",
"Callback items",
("provider", "result"),
)
CALLBACK_LAG = Histogram(
"sms_callback_lag_seconds",
"Callback status-to-receipt lag",
("provider", "status"),
)
PENDING_AGE = Gauge(
"sms_pending_oldest_age_seconds",
"Age of oldest pending message",
)
JOURNAL_ROWS = Gauge(
"sms_journal_rows",
"Outbound journal row count",
)
SETTINGS_VALID = Gauge(
"sms_settings_valid",
"Whether cached technical settings are valid",
)
@@ -0,0 +1,161 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
from app.db import SendStatus, SmsOutboundMessage
from app.domain import ProviderResult
@dataclass(frozen=True)
class IdgtlConfig:
base_url: str
api_key: str
callback_url: str
callback_username: str
callback_password: str
connect_timeout_ms: int
request_timeout_ms: int
callback_enabled: bool
def callback_url_with_credentials(config: IdgtlConfig) -> str:
parts = urlsplit(config.callback_url)
credentials = (
f"{quote(config.callback_username, safe='')}:{quote(config.callback_password, safe='')}"
)
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
return urlunsplit((parts.scheme, f"{credentials}@{host}", parts.path, parts.query, ""))
def build_payload(message: SmsOutboundMessage, config: IdgtlConfig) -> list[dict[str, object]]:
item: dict[str, object] = {
"channelType": "SMS",
"senderName": message.sender_name,
"destination": message.phone_digits,
"content": message.body_rendered,
"externalMessageId": str(message.id),
"ttl": message.message_ttl_sec,
}
if config.callback_enabled:
item["callbackUrl"] = callback_url_with_credentials(config)
item["callbackEvents"] = ["delivered", "sent"]
return [item]
def classify_response(response: httpx.Response, expected_external_id: str) -> ProviderResult:
if response.status_code != 200:
if 400 <= response.status_code < 500:
return ProviderResult(
SendStatus.REJECTED,
response.status_code,
error_code=f"http_{response.status_code}",
error_message="provider_rejected",
)
return ProviderResult(
SendStatus.UNCERTAIN,
response.status_code,
error_code=f"http_{response.status_code}",
error_message="provider_result_uncertain",
)
try:
payload = response.json()
except ValueError:
return ProviderResult(
SendStatus.REJECTED,
200,
error_code="malformed_json",
error_message="provider_contract_violation",
contract_violation=True,
)
items = payload.get("items") if isinstance(payload, dict) else None
if isinstance(payload, dict) and items is None:
items = payload.get("messages") or payload.get("results") or payload.get("response")
errors = payload.get("errors") if isinstance(payload, dict) else None
if errors is not False or not isinstance(items, list) or len(items) != 1:
return ProviderResult(
SendStatus.REJECTED,
200,
error_code="invalid_response",
error_message="provider_contract_violation",
contract_violation=True,
)
item = items[0]
if not isinstance(item, dict):
return ProviderResult(
SendStatus.REJECTED,
200,
error_code="invalid_item",
error_message="provider_contract_violation",
contract_violation=True,
)
message_uuid = item.get("messageUuid")
external_id = item.get("externalMessageId")
try:
uuid.UUID(str(message_uuid))
except (ValueError, TypeError, AttributeError):
message_uuid = None
valid = item.get("code") == 201 and message_uuid and external_id == expected_external_id
if not valid:
return ProviderResult(
SendStatus.REJECTED,
200,
error_code=str(item.get("code") or "invalid_item"),
error_message="provider_contract_violation",
contract_violation=True,
)
return ProviderResult(
SendStatus.ACCEPTED,
200,
message_uuid=str(message_uuid),
external_id=str(external_id),
)
class IdgtlClient:
def __init__(self, client: httpx.AsyncClient, config: IdgtlConfig) -> None:
self.client = client
self.config = config
async def send(self, message: SmsOutboundMessage) -> ProviderResult:
timeout = httpx.Timeout(
self.config.request_timeout_ms / 1000,
connect=self.config.connect_timeout_ms / 1000,
)
try:
headers = {"Authorization": f"Basic {self.config.api_key}"}
if message.request_id:
headers["X-Request-ID"] = message.request_id
if message.traceparent:
headers["traceparent"] = message.traceparent
response = await self.client.post(
f"{self.config.base_url.rstrip('/')}/api/v1/message",
headers=headers,
json=build_payload(message, self.config),
timeout=timeout,
)
except (httpx.ConnectError, httpx.ConnectTimeout):
return ProviderResult(
SendStatus.FAILED,
error_code="connect_failure",
error_message="provider_connect_failure",
retry_safe=True,
)
except (httpx.ReadTimeout, httpx.WriteError, httpx.ReadError, httpx.RemoteProtocolError):
return ProviderResult(
SendStatus.UNCERTAIN,
error_code="ambiguous_transport_failure",
error_message="provider_result_uncertain",
)
except httpx.RequestError:
return ProviderResult(
SendStatus.UNCERTAIN,
error_code="transport_failure",
error_message="provider_result_uncertain",
)
return classify_response(response, str(message.id))
@@ -0,0 +1,93 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
class SendRequest(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
idempotency_key: str = Field(min_length=8, max_length=192)
template_code: Literal["auth_otp"]
locale: Literal["ru"]
phone_e164: str = Field(min_length=9, max_length=16)
substitutions: dict[str, str | int] = Field(min_length=1, max_length=16)
customer_ref: str = Field(min_length=1, max_length=128)
message_ttl_sec: int = Field(ge=60, le=86400)
class SendResponse(BaseModel):
sms_message_id: uuid.UUID
ordered_at: datetime
class MessageResponse(BaseModel):
sms_message_id: uuid.UUID
ordered_at: datetime
updated_at: datetime
requester_service: str
process: str
channel: str
provider: str
phone_masked: str
template_code: str
customer_ref: str | None
send_status: str
delivery_status: str
provider_message_id: str | None
accepted_at: datetime | None
sent_at: datetime | None
delivered_at: datetime | None
attempt_count: int
provider_error_code: str | None
class CallbackItem(BaseModel):
model_config = ConfigDict(extra="allow")
channel_type: str = Field(validation_alias=AliasChoices("channel_type", "channelType"))
message_uuid: str = Field(
min_length=1,
max_length=128,
validation_alias=AliasChoices("message_uuid", "messageUuid"),
)
external_message_id: str = Field(
min_length=1,
max_length=128,
validation_alias=AliasChoices("external_message_id", "externalMessageId"),
)
callback_event: str = Field(
min_length=1,
max_length=32,
validation_alias=AliasChoices("callback_event", "callbackEvent", "event"),
)
status: str = Field(min_length=1, max_length=32)
status_time: datetime = Field(validation_alias=AliasChoices("status_time", "statusTime"))
error_code: str | None = Field(
default=None, validation_alias=AliasChoices("error_code", "errorCode")
)
parts: int | None = Field(default=None, ge=0)
price: Decimal | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, min_length=3, max_length=3)
@field_validator("status_time")
@classmethod
def require_timezone(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("status_time requires a timezone")
return value
class ErrorDetail(BaseModel):
code: str
message: str
request_id: str
details: dict[str, Any] | list[dict[str, Any]]
class ErrorEnvelope(BaseModel):
error: ErrorDetail
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, cast
import structlog
from sqlalchemy import func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import (
Channel,
DeliveryStatus,
SendStatus,
SmsCallbackEvent,
SmsOutboundMessage,
SmsSetting,
SmsTemplate,
)
from app.domain import (
DomainError,
delivery_transition,
destination_hmac,
normalize_phone,
render_template,
request_fingerprint,
)
from app.schemas import CallbackItem, MessageResponse, SendRequest, SendResponse
log = structlog.get_logger()
@dataclass(frozen=True)
class RuntimeSettings:
default_sender_name: str
connect_timeout_ms: int
request_timeout_ms: int
callback_enabled: bool
poll_interval_ms: int
lease_seconds: int
SETTING_RULES: dict[str, tuple[type, int | None, int | None]] = {
"provider.idgtl.default_sender_name": (str, 1, 64),
"provider.idgtl.connect_timeout_ms": (int, 100, 30_000),
"provider.idgtl.request_timeout_ms": (int, 1_000, 120_000),
"provider.idgtl.callback_enabled": (bool, None, None),
"worker.poll_interval_ms": (int, 100, 60_000),
"worker.lease_seconds": (int, 10, 600),
}
async def load_runtime_settings(db: AsyncSession) -> RuntimeSettings:
rows = (
await db.execute(select(SmsSetting).where(SmsSetting.setting_key.in_(SETTING_RULES)))
).scalars()
values = {row.setting_key: row.setting_value for row in rows}
if values.keys() != SETTING_RULES.keys():
raise RuntimeError("required sms settings are missing")
for key, (expected_type, minimum, maximum) in SETTING_RULES.items():
value = values[key]
if type(value) is not expected_type: # bool is an int subclass
raise RuntimeError(f"invalid sms setting type: {key}")
if isinstance(value, (int, str)):
size = value if isinstance(value, int) else len(value)
if minimum is not None and size < minimum:
raise RuntimeError(f"sms setting below minimum: {key}")
if maximum is not None and size > maximum:
raise RuntimeError(f"sms setting above maximum: {key}")
sender = str(values["provider.idgtl.default_sender_name"])
if sender.startswith("__"):
raise RuntimeError("provider sender name is not configured")
return RuntimeSettings(
default_sender_name=sender,
connect_timeout_ms=cast(int, values["provider.idgtl.connect_timeout_ms"]),
request_timeout_ms=cast(int, values["provider.idgtl.request_timeout_ms"]),
callback_enabled=cast(bool, values["provider.idgtl.callback_enabled"]),
poll_interval_ms=cast(int, values["worker.poll_interval_ms"]),
lease_seconds=cast(int, values["worker.lease_seconds"]),
)
def fingerprint_payload(body: SendRequest, phone_e164: str) -> dict[str, Any]:
return {
"idempotency_key": body.idempotency_key,
"template_code": body.template_code,
"locale": body.locale,
"phone_e164": phone_e164,
"substitutions": body.substitutions,
"customer_ref": body.customer_ref,
"message_ttl_sec": body.message_ttl_sec,
}
def validate_otp_request(body: SendRequest) -> None:
code = body.substitutions.get("code")
ttl_min = body.substitutions.get("ttl_min")
if (
not isinstance(code, str)
or not code.isascii()
or not code.isdigit()
or not 4 <= len(code) <= 10
or body.message_ttl_sec % 60 != 0
or str(ttl_min) != str(body.message_ttl_sec // 60)
):
raise DomainError(
"sms_request_invalid", 422, "OTP substitutions and message TTL are inconsistent"
)
def send_response(message: SmsOutboundMessage) -> SendResponse:
return SendResponse(sms_message_id=message.id, ordered_at=message.requested_at)
async def existing_order(
db: AsyncSession, idempotency_key: str, fingerprint: str
) -> SmsOutboundMessage | None:
message = await db.scalar(
select(SmsOutboundMessage).where(
SmsOutboundMessage.requester_service == "keycloak",
SmsOutboundMessage.idempotency_key == idempotency_key,
)
)
if message and message.request_fingerprint != fingerprint:
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
return message
async def enforce_rate_limit(db: AsyncSession, phone_e164: str, destination_key: bytes) -> None:
digest = destination_hmac(phone_e164, destination_key)
lock_id = int.from_bytes(bytes.fromhex(digest[:16]), byteorder="big", signed=True)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_id})
since = datetime.now(UTC) - timedelta(minutes=10)
count = await db.scalar(
select(func.count(SmsOutboundMessage.id)).where(
SmsOutboundMessage.requester_service == "keycloak",
SmsOutboundMessage.phone_e164 == phone_e164,
SmsOutboundMessage.created_at >= since,
)
)
if (count or 0) >= 5:
raise DomainError(
"rate_limit_exceeded",
429,
"Rate limit exceeded",
{"retry_after": 600},
)
async def create_order(
db: AsyncSession,
body: SendRequest,
request_id: str | None,
traceparent: str | None,
destination_key: bytes,
) -> tuple[SendResponse, bool]:
validate_otp_request(body)
phone_e164, phone_digits, phone_masked = normalize_phone(body.phone_e164)
fingerprint = request_fingerprint(fingerprint_payload(body, phone_e164))
existing = await existing_order(db, body.idempotency_key, fingerprint)
if existing:
return send_response(existing), False
runtime = await load_runtime_settings(db)
template = await db.scalar(
select(SmsTemplate).where(
SmsTemplate.code == body.template_code,
SmsTemplate.channel == Channel.SMS,
SmsTemplate.locale == body.locale,
SmsTemplate.is_active.is_(True),
SmsTemplate.approved_at.is_not(None),
)
)
if not template:
raise DomainError("sms_service_unavailable", 503, "SMS service is unavailable")
sender = template.sender_name or runtime.default_sender_name
rendered = render_template(
template.body_template, template.placeholders, body.substitutions, template.max_parts
)
await enforce_rate_limit(db, phone_e164, destination_key)
now = datetime.now(UTC)
message = SmsOutboundMessage(
id=uuid.uuid4(),
requested_at=now,
updated_at=now,
requester_service="keycloak",
process="auth_otp",
channel="SMS",
provider="idgtl",
phone_e164=phone_e164,
phone_digits=phone_digits,
phone_masked=phone_masked,
template_id=template.id,
template_code=template.code,
body_rendered=rendered,
substitutions=body.substitutions,
send_status=SendStatus.PENDING,
delivery_status=DeliveryStatus.UNKNOWN,
customer_ref=body.customer_ref,
idempotency_key=body.idempotency_key,
request_fingerprint=fingerprint,
request_id=request_id,
traceparent=traceparent,
sender_name=sender,
message_ttl_sec=body.message_ttl_sec,
attempt_count=0,
next_attempt_at=now,
)
db.add(message)
try:
await db.commit()
except IntegrityError:
await db.rollback()
concurrent = await existing_order(db, body.idempotency_key, fingerprint)
if concurrent:
return send_response(concurrent), False
raise
return send_response(message), True
def message_response(message: SmsOutboundMessage) -> MessageResponse:
return MessageResponse(
sms_message_id=message.id,
ordered_at=message.requested_at,
updated_at=message.updated_at,
requester_service=message.requester_service,
process=message.process,
channel=message.channel,
provider=message.provider,
phone_masked=message.phone_masked,
template_code=message.template_code,
customer_ref=message.customer_ref,
send_status=message.send_status.value,
delivery_status=message.delivery_status.value,
provider_message_id=message.provider_message_id,
accepted_at=message.accepted_at,
sent_at=message.sent_at,
delivered_at=message.delivered_at,
attempt_count=message.attempt_count,
provider_error_code=message.provider_error_code,
)
async def read_message(db: AsyncSession, message_id: uuid.UUID) -> MessageResponse:
message = await db.scalar(
select(SmsOutboundMessage).where(
SmsOutboundMessage.id == message_id,
SmsOutboundMessage.requester_service == "keycloak",
)
)
if not message:
raise DomainError("not_found", 404, "Resource was not found")
return message_response(message)
async def apply_callback(db: AsyncSession, item: CallbackItem) -> bool:
if item.channel_type.upper() != "SMS":
log.warning("callback.rejected", reason="wrong_channel")
return False
message = await db.scalar(
select(SmsOutboundMessage)
.where(
SmsOutboundMessage.provider == "idgtl",
SmsOutboundMessage.provider_message_id == item.message_uuid,
)
.with_for_update()
)
if not message or item.external_message_id != str(message.id):
digest = hashlib.sha256(item.message_uuid.encode()).hexdigest()[:16]
log.warning(
"callback.rejected", reason="unknown_or_conflicting_message", message_hash=digest
)
return False
target = delivery_transition(message.delivery_status, item.status)
if target is None:
log.warning("callback.rejected", reason="unknown_status", sms_message_id=str(message.id))
return False
inserted = await db.scalar(
pg_insert(SmsCallbackEvent)
.values(
id=uuid.uuid4(),
message_uuid=item.message_uuid,
callback_event=item.callback_event.lower(),
status=item.status.lower(),
status_time=item.status_time,
)
.on_conflict_do_nothing(constraint="uq_callback_event")
.returning(SmsCallbackEvent.id)
)
if inserted is None:
return True
now = datetime.now(UTC)
message.delivery_status = target
message.callback_last_at = now
message.updated_at = now
message.provider_error_code = item.error_code
message.parts = item.parts if item.parts is not None else message.parts
message.price = item.price if item.price is not None else message.price
message.currency = item.currency if item.currency is not None else message.currency
if target == DeliveryStatus.SENT and message.sent_at is None:
message.sent_at = item.status_time
elif target == DeliveryStatus.DELIVERED and message.delivered_at is None:
message.delivered_at = item.status_time
return True
@@ -0,0 +1,53 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
database_url: str = Field(alias="SMS_DATABASE_URL")
service_token: SecretStr = Field(alias="SMS_SERVICE_TOKEN", min_length=32)
idgtl_base_url: AnyHttpUrl = Field(
default=AnyHttpUrl("https://direct.i-dgtl.ru"), alias="IDGTL_SMS_BASE_URL"
)
idgtl_api_key: SecretStr | None = Field(default=None, alias="IDGTL_SMS_API_KEY")
callback_public_url: AnyHttpUrl = Field(alias="IDGTL_SMS_CALLBACK_PUBLIC_URL")
callback_username: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_USERNAME")
callback_password: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_PASSWORD")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
api_port: int = Field(default=8080, alias="SMS_API_PORT", ge=1, le=65535)
@field_validator(
"service_token",
"callback_username",
"callback_password",
)
@classmethod
def reject_placeholders(cls, value: SecretStr) -> SecretStr:
raw = value.get_secret_value().strip()
if not raw or raw.lower() in {"changeme", "secret", "token", "<secret>"}:
raise ValueError("secret is missing or is a placeholder")
return value
@field_validator("idgtl_api_key")
@classmethod
def reject_api_key_placeholder(cls, value: SecretStr | None) -> SecretStr | None:
if value is None:
return None
return cls.reject_placeholders(value)
@field_validator("callback_public_url")
@classmethod
def callback_must_be_https(cls, value: AnyHttpUrl) -> AnyHttpUrl:
if value.scheme != "https":
raise ValueError("callback URL must use HTTPS")
if value.username or value.password:
raise ValueError("callback URL must not contain credentials")
return value
@lru_cache
def get_settings() -> Settings:
return Settings()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import asyncio
import logging
import random
import signal
import time
from datetime import UTC, datetime, timedelta
import httpx
import structlog
from sqlalchemy import and_, func, or_, select, update
from app.db import Database, SendStatus, SmsOutboundMessage
from app.metrics import (
JOURNAL_ROWS,
PENDING_AGE,
PROVIDER_LATENCY,
SEND_TOTAL,
SETTINGS_VALID,
UNCERTAIN_TOTAL,
)
from app.provider import IdgtlClient, IdgtlConfig
from app.service import RuntimeSettings, load_runtime_settings
from app.settings import Settings, get_settings
log = structlog.get_logger()
MAX_CONNECT_ATTEMPTS = 3
def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
)
async def reconcile_expired_leases(db: Database) -> int:
now = datetime.now(UTC)
async with db.sessions.begin() as session:
result = await session.execute(
update(SmsOutboundMessage)
.where(
SmsOutboundMessage.send_status == SendStatus.PENDING,
SmsOutboundMessage.attempt_count > 0,
SmsOutboundMessage.worker_locked_until < now,
)
.values(
send_status=SendStatus.UNCERTAIN,
worker_locked_until=None,
next_attempt_at=None,
updated_at=now,
provider_error_code="worker_lease_expired",
provider_error_message="provider_result_uncertain",
)
.returning(SmsOutboundMessage.id)
)
ids = list(result.scalars())
for message_id in ids:
SEND_TOTAL.labels("idgtl", SendStatus.UNCERTAIN.value).inc()
UNCERTAIN_TOTAL.labels("idgtl").inc()
log.error("worker.lease_expired", sms_message_id=str(message_id))
return len(ids)
async def lease_message(db: Database, runtime: RuntimeSettings) -> SmsOutboundMessage | None:
now = datetime.now(UTC)
eligible = or_(
and_(
SmsOutboundMessage.send_status == SendStatus.PENDING,
SmsOutboundMessage.attempt_count == 0,
),
and_(
SmsOutboundMessage.send_status == SendStatus.FAILED,
SmsOutboundMessage.attempt_count < MAX_CONNECT_ATTEMPTS,
),
)
async with db.sessions.begin() as session:
message = await session.scalar(
select(SmsOutboundMessage)
.where(
eligible,
SmsOutboundMessage.next_attempt_at <= now,
or_(
SmsOutboundMessage.worker_locked_until.is_(None),
SmsOutboundMessage.worker_locked_until < now,
),
)
.order_by(SmsOutboundMessage.next_attempt_at, SmsOutboundMessage.created_at)
.with_for_update(skip_locked=True)
.limit(1)
)
if message:
message.send_status = SendStatus.PENDING
message.attempt_count += 1
message.last_attempt_at = now
message.worker_locked_until = now + timedelta(seconds=runtime.lease_seconds)
message.updated_at = now
return message
async def save_result(db: Database, message_id, result, attempt_count: int) -> None:
now = datetime.now(UTC)
status = result.send_status
next_attempt = None
if result.retry_safe and attempt_count < MAX_CONNECT_ATTEMPTS:
next_attempt = now + timedelta(seconds=(2**attempt_count) + random.uniform(0, 1)) # noqa: S311
async with db.sessions.begin() as session:
values = {
"send_status": status,
"provider_http_status": result.http_status,
"provider_message_id": result.message_uuid,
"provider_external_id": result.external_id,
"provider_error_code": result.error_code,
"provider_error_message": result.error_message,
"worker_locked_until": None,
"next_attempt_at": next_attempt,
"updated_at": now,
}
if status == SendStatus.ACCEPTED:
values["accepted_at"] = now
await session.execute(
update(SmsOutboundMessage)
.where(
SmsOutboundMessage.id == message_id,
SmsOutboundMessage.send_status == SendStatus.PENDING,
SmsOutboundMessage.attempt_count == attempt_count,
)
.values(**values)
)
SEND_TOTAL.labels("idgtl", status.value).inc()
if status == SendStatus.UNCERTAIN:
UNCERTAIN_TOTAL.labels("idgtl").inc()
if result.contract_violation:
log.error("provider.contract_violation", sms_message_id=str(message_id))
def provider_config(settings: Settings, runtime: RuntimeSettings) -> IdgtlConfig:
if settings.idgtl_api_key is None:
raise RuntimeError("IDGTL_SMS_API_KEY is required by sms-worker")
return IdgtlConfig(
base_url=str(settings.idgtl_base_url),
api_key=settings.idgtl_api_key.get_secret_value(),
callback_url=str(settings.callback_public_url),
callback_username=settings.callback_username.get_secret_value(),
callback_password=settings.callback_password.get_secret_value(),
connect_timeout_ms=runtime.connect_timeout_ms,
request_timeout_ms=runtime.request_timeout_ms,
callback_enabled=runtime.callback_enabled,
)
async def update_queue_metrics(db: Database) -> None:
async with db.sessions() as session:
oldest = await session.scalar(
select(func.min(SmsOutboundMessage.created_at)).where(
SmsOutboundMessage.send_status == SendStatus.PENDING
)
)
count = await session.scalar(select(func.count(SmsOutboundMessage.id)))
age = max(0.0, (datetime.now(UTC) - oldest).total_seconds()) if oldest else 0.0
PENDING_AGE.set(age)
JOURNAL_ROWS.set(count or 0)
async def worker_loop(stop: asyncio.Event) -> None:
settings = get_settings()
configure_logging(settings.log_level)
db = Database(settings.database_url)
async with httpx.AsyncClient() as http:
try:
while not stop.is_set():
try:
await reconcile_expired_leases(db)
async with db.sessions() as session:
runtime = await load_runtime_settings(session)
SETTINGS_VALID.set(1)
message = await lease_message(db, runtime)
if message is None:
await update_queue_metrics(db)
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
continue
client = IdgtlClient(http, provider_config(settings, runtime))
started = time.monotonic()
result = await client.send(message)
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
await save_result(db, message.id, result, message.attempt_count)
except TimeoutError:
continue
except Exception:
SETTINGS_VALID.set(0)
log.exception("worker.iteration_failed")
try:
await asyncio.wait_for(stop.wait(), timeout=5)
except TimeoutError:
pass
finally:
await db.close()
def run() -> None:
stop = asyncio.Event()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for name in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(name, stop.set)
except NotImplementedError:
pass
try:
loop.run_until_complete(worker_loop(stop))
finally:
loop.close()
@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from app.db import Base, create_postgres_engine
from app.settings import get_settings
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
database_url = get_settings().database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_schema="sms",
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema="sms",
include_schemas=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_postgres_engine(database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_async_migrations())
@@ -0,0 +1,231 @@
"""Create SMS journal schema objects.
Revision ID: 0001_initial
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
SCHEMA = "sms"
channel = postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA, create_type=False)
send_status = postgresql.ENUM(
"pending",
"accepted",
"rejected",
"failed",
"uncertain",
"skipped",
name="sms_send_status",
schema=SCHEMA,
create_type=False,
)
delivery_status = postgresql.ENUM(
"unknown",
"sent",
"delivered",
"undelivered",
"unsent",
name="sms_delivery_status",
schema=SCHEMA,
create_type=False,
)
def upgrade() -> None:
bind = op.get_bind()
postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA).create(bind)
postgresql.ENUM(
"pending",
"accepted",
"rejected",
"failed",
"uncertain",
"skipped",
name="sms_send_status",
schema=SCHEMA,
).create(bind)
postgresql.ENUM(
"unknown",
"sent",
"delivered",
"undelivered",
"unsent",
name="sms_delivery_status",
schema=SCHEMA,
).create(bind)
op.create_table(
"sms_template",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("code", sa.String(64), nullable=False),
sa.Column("channel", channel, nullable=False),
sa.Column("locale", sa.String(16), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("body_template", sa.Text(), nullable=False),
sa.Column("placeholders", postgresql.JSONB(), nullable=False),
sa.Column("sender_name", sa.String(64)),
sa.Column("max_parts", sa.SmallInteger(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("approved_at", sa.DateTime(timezone=True)),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("created_by", sa.String(64), nullable=False),
sa.CheckConstraint("version > 0", name="ck_template_version_positive"),
sa.CheckConstraint("max_parts BETWEEN 1 AND 10", name="ck_template_max_parts"),
sa.UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
schema=SCHEMA,
)
op.create_index(
"uq_template_active",
"sms_template",
["code", "channel", "locale"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("is_active"),
)
op.create_table(
"sms_setting",
sa.Column("setting_key", sa.String(128), primary_key=True),
sa.Column("setting_value", postgresql.JSONB(), nullable=False),
sa.Column("value_type", sa.String(16), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"value_type IN ('string','integer','boolean')", name="ck_setting_value_type"
),
schema=SCHEMA,
)
op.create_table(
"sms_outbound_message",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column("sent_at", sa.DateTime(timezone=True)),
sa.Column("delivered_at", sa.DateTime(timezone=True)),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("requester_service", sa.String(64), nullable=False),
sa.Column("process", sa.String(64), nullable=False),
sa.Column("channel", sa.String(16), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("phone_e164", sa.String(16), nullable=False),
sa.Column("phone_digits", sa.String(15), nullable=False),
sa.Column("phone_masked", sa.String(32), nullable=False),
sa.Column(
"template_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(f"{SCHEMA}.sms_template.id"),
nullable=False,
),
sa.Column("template_code", sa.String(64), nullable=False),
sa.Column("body_rendered", sa.Text(), nullable=False),
sa.Column("substitutions", postgresql.JSONB(), nullable=False),
sa.Column("send_status", send_status, nullable=False),
sa.Column("delivery_status", delivery_status, nullable=False),
sa.Column("provider_message_id", sa.String(128)),
sa.Column("provider_external_id", sa.String(128)),
sa.Column("customer_ref", sa.String(128)),
sa.Column("idempotency_key", sa.String(192), nullable=False),
sa.Column("request_fingerprint", sa.String(64), nullable=False),
sa.Column("request_id", sa.String(128)),
sa.Column("traceparent", sa.String(55)),
sa.Column("provider_http_status", sa.Integer()),
sa.Column("provider_error_code", sa.String(64)),
sa.Column("provider_error_message", sa.String(256)),
sa.Column("sender_name", sa.String(64), nullable=False),
sa.Column("message_ttl_sec", sa.Integer()),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_attempt_at", sa.DateTime(timezone=True)),
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
sa.Column("worker_locked_until", sa.DateTime(timezone=True)),
sa.Column("parts", sa.Integer()),
sa.Column("price", sa.Numeric(14, 4)),
sa.Column("currency", sa.String(3)),
sa.Column("callback_last_at", sa.DateTime(timezone=True)),
sa.CheckConstraint("channel = 'SMS'", name="ck_outbound_channel"),
sa.CheckConstraint("provider = 'idgtl'", name="ck_outbound_provider"),
sa.CheckConstraint("process = 'auth_otp'", name="ck_outbound_process"),
sa.CheckConstraint("message_ttl_sec BETWEEN 60 AND 86400", name="ck_outbound_ttl"),
sa.CheckConstraint("attempt_count >= 0", name="ck_outbound_attempts"),
sa.UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
schema=SCHEMA,
)
op.create_index(
"uq_outbound_provider_message",
"sms_outbound_message",
["provider", "provider_message_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("provider_message_id IS NOT NULL"),
)
op.create_index(
"ix_outbound_phone_created",
"sms_outbound_message",
["phone_e164", sa.text("created_at DESC")],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_requester_process_created",
"sms_outbound_message",
["requester_service", "process", sa.text("created_at DESC")],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_customer_ref", "sms_outbound_message", ["customer_ref"], schema=SCHEMA
)
op.create_index(
"ix_outbound_send_created",
"sms_outbound_message",
["send_status", "created_at"],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_delivery_updated",
"sms_outbound_message",
["delivery_status", "updated_at"],
schema=SCHEMA,
)
op.create_table(
"sms_callback_event",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("message_uuid", sa.String(128), nullable=False),
sa.Column("callback_event", sa.String(32), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("status_time", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"received_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.UniqueConstraint(
"message_uuid", "callback_event", "status", "status_time", name="uq_callback_event"
),
schema=SCHEMA,
)
def downgrade() -> None:
op.drop_table("sms_callback_event", schema=SCHEMA)
op.drop_table("sms_outbound_message", schema=SCHEMA)
op.drop_table("sms_setting", schema=SCHEMA)
op.drop_table("sms_template", schema=SCHEMA)
delivery_status.drop(op.get_bind())
send_status.drop(op.get_bind())
channel.drop(op.get_bind())
@@ -0,0 +1,108 @@
"""Seed versioned technical settings and OTP template placeholder.
Revision ID: 0002_seed
"""
import uuid
import sqlalchemy as sa
from alembic import op
revision = "0002_seed"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
TEMPLATE_ID = uuid.UUID("5ac2a77e-590c-4b24-87d8-baa0f1240cd1")
def upgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"""
INSERT INTO sms.sms_template (
id, code, channel, locale, version, body_template, placeholders,
sender_name, max_parts, is_active, approved_at, created_by
) VALUES (
:id, 'auth_otp', 'SMS', 'ru', 1,
'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
'["code","ttl_min"]'::jsonb, NULL, 1, true, NULL, 'migration'
)
ON CONFLICT (code, channel, locale, version) DO NOTHING
"""
),
{"id": TEMPLATE_ID},
)
settings = (
(
"provider.idgtl.default_sender_name",
'"__SET_ME_AFTER_PROVIDER_APPROVAL__"',
"string",
"Provider-approved default sender name",
),
(
"provider.idgtl.connect_timeout_ms",
"3000",
"integer",
"Direct connection timeout in milliseconds",
),
(
"provider.idgtl.request_timeout_ms",
"70000",
"integer",
"Direct total request timeout in milliseconds",
),
(
"provider.idgtl.callback_enabled",
"true",
"boolean",
"Include delivery callback in provider requests",
),
(
"worker.poll_interval_ms",
"500",
"integer",
"Queue polling interval in milliseconds",
),
(
"worker.lease_seconds",
"90",
"integer",
"Exclusive provider-call lease duration",
),
)
for key, value, value_type, description in settings:
bind.execute(
sa.text(
"""
INSERT INTO sms.sms_setting (
setting_key, setting_value, value_type, description
) VALUES (:key, CAST(:value AS jsonb), :value_type, :description)
ON CONFLICT (setting_key) DO NOTHING
"""
),
{
"key": key,
"value": value,
"value_type": value_type,
"description": description,
},
)
def downgrade() -> None:
op.execute(sa.text("DELETE FROM sms.sms_template WHERE id = :id").bindparams(id=TEMPLATE_ID))
op.execute(
"""
DELETE FROM sms.sms_setting
WHERE setting_key IN (
'provider.idgtl.default_sender_name',
'provider.idgtl.connect_timeout_ms',
'provider.idgtl.request_timeout_ms',
'provider.idgtl.callback_enabled',
'worker.poll_interval_ms',
'worker.lease_seconds'
)
"""
)
+314
View File
@@ -0,0 +1,314 @@
openapi: 3.1.0
info:
title: HAN SMS Service
version: 1.0.0
description: Durable internal SMS ordering and i-Digital delivery callbacks.
servers:
- url: http://sms-service:8080
paths:
/internal/sms/v1/send:
post:
operationId: orderSms
security:
- serviceBearer: []
parameters:
- $ref: "#/components/parameters/RequestId"
- $ref: "#/components/parameters/Traceparent"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SendRequest"
responses:
"202":
description: New order durably committed; provider has not necessarily been called.
content:
application/json:
schema:
$ref: "#/components/schemas/SendResponse"
"200":
description: Idempotent replay of an existing order.
content:
application/json:
schema:
$ref: "#/components/schemas/SendResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"409":
$ref: "#/components/responses/IdempotencyConflict"
"422":
$ref: "#/components/responses/InvalidRequest"
"429":
$ref: "#/components/responses/RateLimited"
"503":
$ref: "#/components/responses/Unavailable"
/internal/sms/v1/messages/{sms_message_id}:
get:
operationId: readSmsOrder
security:
- serviceBearer: []
parameters:
- name: sms_message_id
in: path
required: true
schema:
type: string
format: uuid
- $ref: "#/components/parameters/RequestId"
responses:
"200":
description: Redacted message diagnostics; never contains OTP, body, or full phone.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/callbacks/idgtl/sms:
post:
operationId: acceptIdgtlCallback
security:
- callbackBasic: []
requestBody:
required: true
content:
application/json:
schema:
type: array
minItems: 1
maxItems: 1000
items:
$ref: "#/components/schemas/IdgtlCallbackItem"
responses:
"204":
description: Valid callback items committed; invalid items were safely ignored.
"401":
$ref: "#/components/responses/Unauthorized"
"422":
$ref: "#/components/responses/InvalidRequest"
webhooks:
idgtlDeliveryStatus:
post:
summary: The same payload accepted at /callbacks/idgtl/sms.
security:
- callbackBasic: []
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/IdgtlCallbackItem"
responses:
"204":
description: Callback committed.
components:
securitySchemes:
serviceBearer:
type: http
scheme: bearer
bearerFormat: opaque-service-token
callbackBasic:
type: http
scheme: basic
parameters:
RequestId:
name: X-Request-ID
in: header
required: false
schema:
type: string
maxLength: 128
Traceparent:
name: traceparent
in: header
required: false
schema:
type: string
pattern: "^[\\da-f]{2}-[\\da-f]{32}-[\\da-f]{16}-[\\da-f]{2}$"
schemas:
SendRequest:
type: object
additionalProperties: false
required:
- idempotency_key
- template_code
- locale
- phone_e164
- substitutions
- customer_ref
- message_ttl_sec
properties:
idempotency_key:
type: string
minLength: 8
maxLength: 192
template_code:
const: auth_otp
locale:
const: ru
phone_e164:
type: string
pattern: "^\\+[1-9]\\d{7,14}$"
substitutions:
type: object
additionalProperties: false
required: [code, ttl_min]
properties:
code:
type: string
pattern: "^\\d{4,10}$"
ttl_min:
oneOf:
- type: string
pattern: "^\\d{1,3}$"
- type: integer
minimum: 1
maximum: 1440
customer_ref:
type: string
minLength: 1
maxLength: 128
message_ttl_sec:
type: integer
minimum: 60
maximum: 86400
SendResponse:
type: object
additionalProperties: false
required: [sms_message_id, ordered_at]
properties:
sms_message_id:
type: string
format: uuid
ordered_at:
type: string
format: date-time
Message:
type: object
additionalProperties: false
description: Deliberately excludes phone_e164, body_rendered, and substitutions.
required:
- sms_message_id
- ordered_at
- updated_at
- requester_service
- process
- channel
- provider
- phone_masked
- template_code
- send_status
- delivery_status
- attempt_count
properties:
sms_message_id: {type: string, format: uuid}
ordered_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
requester_service: {const: keycloak}
process: {const: auth_otp}
channel: {const: SMS}
provider: {const: idgtl}
phone_masked: {type: string}
template_code: {const: auth_otp}
customer_ref: {type: [string, "null"]}
send_status:
enum: [pending, accepted, rejected, failed, uncertain, skipped]
delivery_status:
enum: [unknown, sent, delivered, undelivered, unsent]
provider_message_id: {type: [string, "null"]}
accepted_at: {type: [string, "null"], format: date-time}
sent_at: {type: [string, "null"], format: date-time}
delivered_at: {type: [string, "null"], format: date-time}
attempt_count: {type: integer, minimum: 0}
provider_error_code: {type: [string, "null"]}
IdgtlCallbackItem:
type: object
required:
- channelType
- messageUuid
- externalMessageId
- callbackEvent
- status
- statusTime
properties:
channelType:
const: SMS
messageUuid:
type: string
externalMessageId:
type: string
callbackEvent:
type: string
status:
enum: [sent, delivered, undelivered, unsent]
statusTime:
type: string
format: date-time
errorCode:
type: [string, "null"]
parts:
type: [integer, "null"]
minimum: 0
price:
type: [number, "null"]
minimum: 0
currency:
type: [string, "null"]
minLength: 3
maxLength: 3
Error:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: object
additionalProperties: false
required: [code, message, request_id, details]
properties:
code: {type: string}
message: {type: string}
request_id: {type: string}
details:
oneOf:
- type: object
- type: array
responses:
Unauthorized:
description: Missing or invalid credentials.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
IdempotencyConflict:
description: The key was already used with another meaningful payload.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
InvalidRequest:
description: Strict request or callback validation failed.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
RateLimited:
description: Caller and destination rate limit exceeded.
headers:
Retry-After:
schema: {type: integer}
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
Unavailable:
description: The order could not be durably committed.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
NotFound:
description: Message was not found in the caller scope.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
@@ -0,0 +1,59 @@
[project]
name = "han-sms-service"
version = "0.1.0"
description = "HAN Chat durable SMS delivery service"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"phonenumbers>=9,<10",
"prometheus-client>=0.22,<1",
"pydantic-settings>=2.10,<3",
"sqlalchemy[asyncio]>=2.0.41,<3",
"structlog>=25,<26",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = [
"aiosqlite>=0.21,<1",
"mypy>=1.16,<2",
"pytest>=8.4,<9",
"pytest-asyncio>=1.0,<2",
"pyyaml>=6,<7",
"ruff>=0.12,<1",
]
[project.scripts]
han-sms-api = "app.main:run"
han-sms-worker = "app.worker:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["S101"]
[tool.mypy]
python_version = "3.12"
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
ignore_missing_imports = true
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
exclude = ["migrations/"]
@@ -0,0 +1,25 @@
from pathlib import Path
import yaml
def test_static_contract_is_openapi_31_and_redacted() -> None:
contract = yaml.safe_load(
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
)
assert contract["openapi"] == "3.1.0"
paths = contract["paths"]
assert "/internal/sms/v1/send" in paths
assert "/internal/sms/v1/messages/{sms_message_id}" in paths
assert "/callbacks/idgtl/sms" in paths
message_fields = contract["components"]["schemas"]["Message"]["properties"]
assert {"phone_e164", "body_rendered", "substitutions"}.isdisjoint(message_fields)
assert "idgtlDeliveryStatus" in contract["webhooks"]
def test_send_contract_distinguishes_new_and_replayed_order() -> None:
contract = yaml.safe_load(
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
)
responses = contract["paths"]["/internal/sms/v1/send"]["post"]["responses"]
assert {"200", "202", "401", "409", "422", "429", "503"} <= responses.keys()
@@ -0,0 +1,35 @@
import base64
from types import SimpleNamespace
import pytest
from pydantic import SecretStr
from app.domain import DomainError
from app.main import basic_auth, bearer_auth
def request_with(authorization: str):
settings = SimpleNamespace(
service_token=SecretStr("s" * 43),
callback_username=SecretStr("callback-user"),
callback_password=SecretStr("callback-password"),
)
return SimpleNamespace(
headers={"Authorization": authorization},
app=SimpleNamespace(state=SimpleNamespace(settings=settings)),
)
@pytest.mark.asyncio
async def test_internal_api_requires_exact_bearer_token() -> None:
await bearer_auth(request_with(f"Bearer {'s' * 43}"))
with pytest.raises(DomainError) as error:
await bearer_auth(request_with("Bearer wrong"))
assert error.value.code == "unauthorized"
def test_callback_requires_exact_basic_credentials() -> None:
encoded = base64.b64encode(b"callback-user:callback-password").decode()
basic_auth(request_with(f"Basic {encoded}"))
with pytest.raises(DomainError):
basic_auth(request_with("Basic invalid"))
@@ -0,0 +1,85 @@
import pytest
from app.db import DeliveryStatus
from app.domain import (
DomainError,
delivery_transition,
normalize_phone,
render_template,
request_fingerprint,
sms_parts,
)
def test_phone_is_canonical_and_masked() -> None:
e164, digits, masked = normalize_phone("+79001234567")
assert e164 == "+79001234567"
assert digits == "79001234567"
assert masked == "+7******4567"
@pytest.mark.parametrize("phone", ["79001234567", "+012345678", "+7900", "+7999999999999999"])
def test_invalid_phone_is_rejected(phone: str) -> None:
with pytest.raises(DomainError) as error:
normalize_phone(phone)
assert error.value.code == "sms_request_invalid"
def test_strict_template_render() -> None:
result = render_template(
"Код входа: {code}. Действителен {ttl_min} мин.",
["code", "ttl_min"],
{"code": "482193", "ttl_min": 1},
1,
)
assert result == "Код входа: 482193. Действителен 1 мин."
@pytest.mark.parametrize(
"substitutions",
[
{"code": "482193"},
{"code": "482193", "ttl_min": 1, "extra": "forbidden"},
],
)
def test_template_rejects_placeholder_mismatch(substitutions) -> None:
with pytest.raises(DomainError):
render_template(
"Код: {code}; TTL: {ttl_min}",
["code", "ttl_min"],
substitutions,
1,
)
def test_template_rejects_format_expressions() -> None:
with pytest.raises(DomainError):
render_template("{code!r}", ["code"], {"code": "123456"}, 1)
def test_sms_parts_supports_gsm_and_unicode() -> None:
assert sms_parts("A" * 160) == 1
assert sms_parts("A" * 161) == 2
assert sms_parts("Я" * 70) == 1
assert sms_parts("Я" * 71) == 2
def test_fingerprint_is_canonical() -> None:
first = request_fingerprint({"b": 2, "a": {"y": 2, "x": 1}})
second = request_fingerprint({"a": {"x": 1, "y": 2}, "b": 2})
assert first == second
@pytest.mark.parametrize(
("current", "incoming", "expected"),
[
(DeliveryStatus.UNKNOWN, "sent", DeliveryStatus.SENT),
(DeliveryStatus.SENT, "delivered", DeliveryStatus.DELIVERED),
(DeliveryStatus.DELIVERED, "sent", DeliveryStatus.DELIVERED),
(DeliveryStatus.UNDELIVERED, "sent", DeliveryStatus.UNDELIVERED),
(DeliveryStatus.DELIVERED, "unsent", DeliveryStatus.DELIVERED),
(DeliveryStatus.UNKNOWN, "bogus", None),
],
)
def test_delivery_status_is_monotonic(current, incoming, expected) -> None:
assert delivery_transition(current, incoming) == expected
@@ -0,0 +1,90 @@
import uuid
import httpx
import pytest
from app.db import SendStatus
from app.provider import IdgtlConfig, callback_url_with_credentials, classify_response
def response(status: int, payload=None) -> httpx.Response:
request = httpx.Request("POST", "https://direct.example/api/v1/message")
if payload is None:
return httpx.Response(status, request=request)
return httpx.Response(status, json=payload, request=request)
@pytest.mark.parametrize("status", [401, 402, 403, 422])
def test_explicit_business_rejections_are_not_retried(status: int) -> None:
result = classify_response(response(status), "message-id")
assert result.send_status == SendStatus.REJECTED
assert result.retry_safe is False
@pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_ambiguous_http_results_are_uncertain(status: int) -> None:
result = classify_response(response(status), "message-id")
assert result.send_status == SendStatus.UNCERTAIN
assert result.retry_safe is False
def test_exact_success_contract() -> None:
message_uuid = str(uuid.uuid4())
result = classify_response(
response(
200,
{
"errors": False,
"response": [
{
"code": 201,
"messageUuid": message_uuid,
"externalMessageId": "message-id",
}
],
},
),
"message-id",
)
assert result.send_status == SendStatus.ACCEPTED
assert result.message_uuid == message_uuid
@pytest.mark.parametrize(
"payload",
[
{"errors": True, "response": []},
{"errors": False, "response": []},
{"errors": False, "response": [{"code": 200}]},
{
"errors": False,
"response": [
{
"code": 201,
"messageUuid": str(uuid.uuid4()),
"externalMessageId": "wrong",
}
],
},
],
)
def test_malformed_200_is_rejected_contract_violation(payload) -> None:
result = classify_response(response(200, payload), "message-id")
assert result.send_status == SendStatus.REJECTED
assert result.contract_violation is True
def test_callback_credentials_are_url_encoded() -> None:
config = IdgtlConfig(
base_url="https://direct.example",
api_key="api-key",
callback_url="https://tohin.ru/callbacks/idgtl/sms",
callback_username="user@example",
callback_password="p:a/ss", # noqa: S106 - synthetic URL-encoding fixture
connect_timeout_ms=3000,
request_timeout_ms=70000,
callback_enabled=True,
)
assert callback_url_with_credentials(config) == (
"https://user%40example:p%3Aa%2Fss@tohin.ru/callbacks/idgtl/sms"
)
@@ -0,0 +1,53 @@
import pytest
from pydantic import ValidationError
from app.domain import DomainError
from app.schemas import CallbackItem, SendRequest
from app.service import validate_otp_request
def valid_send(**overrides) -> SendRequest:
payload = {
"idempotency_key": "keycloak:challenge:01JABCDEF",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": "+79001234567",
"substitutions": {"code": "482193", "ttl_min": "1"},
"customer_ref": "01JABCDEF",
"message_ttl_sec": 60,
}
payload.update(overrides)
return SendRequest.model_validate(payload)
def test_send_request_is_strict() -> None:
with pytest.raises(ValidationError):
valid_send(extra="forbidden")
@pytest.mark.parametrize(
("substitutions", "ttl"),
[
({"code": "12ab", "ttl_min": "1"}, 60),
({"code": "123456", "ttl_min": "2"}, 60),
({"code": "123456", "ttl_min": "1"}, 61),
],
)
def test_otp_substitutions_match_ttl(substitutions, ttl) -> None:
with pytest.raises(DomainError):
validate_otp_request(valid_send(substitutions=substitutions, message_ttl_sec=ttl))
def test_callback_accepts_provider_camel_case() -> None:
item = CallbackItem.model_validate(
{
"channelType": "SMS",
"messageUuid": "provider-id",
"externalMessageId": "internal-id",
"callbackEvent": "delivered",
"status": "delivered",
"statusTime": "2026-07-22T12:00:00Z",
}
)
assert item.channel_type == "SMS"
assert item.status_time.tzinfo is not None
+20 -3
View File
@@ -44,9 +44,15 @@ class InfrastructureConfigTests(unittest.TestCase):
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
self.assertIn("networks: [backend, observability, egress]", application) self.assertIn("networks: [backend, observability, egress]", application)
self.assertIn("networks: [public, backend, observability]", application) self.assertIn("networks: [public, backend, observability]", application)
self.assertEqual(
application.count(
"IDGTL_SMS_API_KEY: ${IDGTL_SMS_API_KEY:?IDGTL_SMS_API_KEY is required}"
),
1,
)
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8") jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
self.assertEqual(jobs.count("networks: [backend, egress]"), 4) self.assertEqual(jobs.count("networks: [backend, egress]"), 5)
observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8") observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("networks: [observability, backend, egress]", observability) self.assertIn("networks: [observability, backend, egress]", observability)
@@ -93,6 +99,10 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("location = /auth/callback", site) self.assertIn("location = /auth/callback", site)
self.assertIn("location ^~ /auth/resources/", site) self.assertIn("location ^~ /auth/resources/", site)
self.assertIn("location ^~ /auth/realms/", site) self.assertIn("location ^~ /auth/realms/", site)
self.assertIn("location = /callbacks/idgtl/sms", site)
self.assertIn("allow 185.203.96.7;", site)
self.assertIn("proxy_pass http://sms_service_upstream;", site)
self.assertIn("upstream sms_service_upstream", config)
self.assertNotIn("security-headers.conf", proxy_keycloak) self.assertNotIn("security-headers.conf", proxy_keycloak)
self.assertNotIn("X-Frame-Options", proxy_keycloak) self.assertNotIn("X-Frame-Options", proxy_keycloak)
@@ -134,6 +144,7 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("frontend-test-site", application) self.assertIn("frontend-test-site", application)
self.assertIn("frontend-static:/output", application) self.assertIn("frontend-static:/output", application)
for service, command in ( for service, command in (
("sms-worker:", "han-sms-worker"),
("delivery-worker:", "han-delivery-worker"), ("delivery-worker:", "han-delivery-worker"),
("safety-recovery-worker:", "han-safety-worker"), ("safety-recovery-worker:", "han-safety-worker"),
("cleanup-worker:", "han-cleanup-worker"), ("cleanup-worker:", "han-cleanup-worker"),
@@ -169,10 +180,13 @@ class InfrastructureConfigTests(unittest.TestCase):
"api-backend/alembic/env.py", "api-backend/alembic/env.py",
"bitrix-local-app/alembic/env.py", "bitrix-local-app/alembic/env.py",
"bitrix-sync/alembic/env.py", "bitrix-sync/alembic/env.py",
"sms-service/migrations/env.py",
): ):
env_script = (ROOT / relative_path).read_text(encoding="utf-8") env_script = (ROOT / relative_path).read_text(encoding="utf-8")
self.assertIn('.replace("%", "%%")', env_script, relative_path) self.assertIn('.replace("%", "%%")', env_script, relative_path)
self.assertIn("create_postgres_engine", env_script, relative_path) self.assertIn("create_postgres_engine", env_script, relative_path)
sms_db = (ROOT / "sms-service/app/db.py").read_text(encoding="utf-8")
self.assertNotIn("server_settings", sms_db)
def test_contact_sync_qualifies_pgcrypto_digest(self) -> None: def test_contact_sync_qualifies_pgcrypto_digest(self) -> None:
initial = ( initial = (
@@ -185,7 +199,7 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("public.digest(", initial) self.assertIn("public.digest(", initial)
self.assertIn("public.digest(", fix) self.assertIn("public.digest(", fix)
self.assertIn('down_revision: str | None = "0001_initial"', fix) self.assertIn('down_revision: str | None = "0001_initial"', fix)
self.assertIn('revision != "0003_consent_audit"', main) self.assertIn('revision != "0005_otp_settings"', main)
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None: def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
migration = ( migration = (
@@ -212,10 +226,11 @@ class InfrastructureConfigTests(unittest.TestCase):
"KEYCLOAK_OTP_MOCK_ENABLED", "KEYCLOAK_OTP_MOCK_ENABLED",
"KEYCLOAK_OTP_MOCK_CODE", "KEYCLOAK_OTP_MOCK_CODE",
"KEYCLOAK_OTP_HMAC_KEY", "KEYCLOAK_OTP_HMAC_KEY",
"KEYCLOAK_OTP_TTL_SEC",
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", "KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
"KEYCLOAK_SETTINGS_BRIDGE_URL", "KEYCLOAK_SETTINGS_BRIDGE_URL",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
"KEYCLOAK_SMS_SERVICE_URL",
"KEYCLOAK_SMS_SERVICE_TOKEN",
): ):
self.assertIn(f" {variable}:", application) self.assertIn(f" {variable}:", application)
@@ -229,6 +244,8 @@ class InfrastructureConfigTests(unittest.TestCase):
"CURSOR_HMAC_SECRET=", "CURSOR_HMAC_SECRET=",
"BITRIX_TOKEN_ENCRYPTION_KEY=", "BITRIX_TOKEN_ENCRYPTION_KEY=",
"KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth", "KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth",
"KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080",
"IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms",
): ):
self.assertIn(required, example) self.assertIn(required, example)
materialized = example.replace("change-me", "0123456789abcdef0123456789abcdef") materialized = example.replace("change-me", "0123456789abcdef0123456789abcdef")
+8 -3
View File
@@ -76,7 +76,9 @@ Auth state machine: `guest → authorizing → bootstrapping → authenticated`;
Modal согласий отображает актуальные URL/версии из config. `personal_data` и `user_agreement` обязательны, `marketing` необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect. Modal согласий отображает актуальные URL/версии из config. `personal_data` и `user_agreement` обязательны, `marketing` необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
OTP вводится на странице/теме Keycloak. В MVP Keycloak сверяет mock-код из env; frontend не хранит и не проверяет код. Для тестовой среды UI может показывать только текст «используется тестовый OTP», но не получать secret из API. OTP вводится на странице/теме Keycloak. В mock mode Keycloak сверяет secret-код; в real mode Keycloak генерирует и локально проверяет OTP, а доставку заказывает в `sms-service` по module-11. Frontend не вызывает `sms-service`/Direct, не получает provider status, service URL/token или mock secret.
Resend запускает новое Keycloak action, блокирует double click на время запроса и сообщает, что предыдущий код недействителен (`superseded`). Countdown строится из snapshot challenge (`expires_at`/`otp_ttl_sec`), без hardcoded `6` digits или `0:59`.
### 5.3. Чат ### 5.3. Чат
@@ -185,6 +187,7 @@ Presigned URL не сохраняется и редактируется из д
| 422 blocked | нейтральное сообщение, контент не отправлен | | 422 blocked | нейтральное сообщение, контент не отправлен |
| 429 | countdown по `Retry-After` | | 429 | countdown по `Retry-After` |
| 503/504 | зависимость недоступна; retry с тем же key | | 503/504 | зависимость недоступна; retry с тем же key |
| OTP invalid/expired/superseded/limited | показать соответствующий безопасный Keycloak UX; generic order unavailable не раскрывает provider |
| S3 PUT error | оставить attachment intent, предложить повтор | | S3 PUT error | оставить attachment intent, предложить повтор |
| WS failure | polling badge, чат остаётся usable | | WS failure | polling badge, чат остаётся usable |
@@ -245,7 +248,7 @@ Production: статический export монтируется в корнев
| Сценарий | Варианты | | Сценарий | Варианты |
|---|---| |---|---|
| guest | просмотр public content; write закрыт | | guest | просмотр public content; write закрыт |
| first send | manual/popular → consents → mock OTP → delivered | | first send | manual/popular → consents → mock и real OTP → delivered |
| return | valid refresh без OTP; expired refresh с OTP | | return | valid refresh без OTP; expired refresh с OTP |
| text safety | allow, block, pending-to-final, timeout | | text safety | allow, block, pending-to-final, timeout |
| file | PDF/image allow, deny, wrong MIME/size/checksum, expired URL | | file | PDF/image allow, deny, wrong MIME/size/checksum, expired URL |
@@ -253,8 +256,9 @@ Production: статический export монтируется в корнев
| concurrency | два send click, несколько 401, две вкладки | | concurrency | два send click, несколько 401, две вкладки |
| profile | filled/null fields, empty documents, download failure | | profile | filled/null fields, empty documents, download failure |
| security | XSS text, token absence in logs/storage diagnostics | | security | XSS text, token absence in logs/storage diagnostics |
| OTP resend | double click; старый код `superseded`; новый код; snapshot countdown; order unavailable |
Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный Keycloak mock realm и API stub/compose используются в CI. Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. В CI используются Keycloak mock mode и локальный mock/WireMock Direct. Отдельный sandbox Direct не предполагается; provider smoke выполняется только ops на контролируемом номере.
## 17. Definition of Done ## 17. Definition of Done
@@ -271,6 +275,7 @@ Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный K
- accessibility checks и keyboard сценарии проходят; - accessibility checks и keyboard сценарии проходят;
- unit/component/contract/E2E matrix зелёная; - unit/component/contract/E2E matrix зелёная;
- production static и dev proxy режимы проверены через единственный nginx. - production static и dev proxy режимы проверены через единственный nginx.
- frontend bundle/config/analytics не содержит raw OTP, `sms-service`/Direct credentials или provider status; resend/expiry/limits проверены для real-mode контракта.
## 18. Решения, допущения и TBD ## 18. Решения, допущения и TBD
+14 -2
View File
@@ -17,16 +17,17 @@
|---|---|---| |---|---|---|
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS | | `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer | | `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
| exact `/callbacks/idgtl/sms` | `sms-service:8080` | public HTTPS POST Direct; IP allowlist + Basic auth в upstream |
| `/bitrix/sync/webhook/contact` | `bitrix-sync:8080` | public HTTPS POST, no cache; отсутствует, пока действует stub module-07 | | `/bitrix/sync/webhook/contact` | `bitrix-sync:8080` | public HTTPS POST, no cache; отсутствует, пока действует stub module-07 |
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS | | `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy | | exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
| `/` | static SPA либо Expo dev upstream | `try_files` fallback | | `/` | static SPA либо Expo dev upstream | `try_files` fallback |
`/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety` не имеет публичного route. `/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety` и `/internal/sms/*` не имеют публичного route.
## 3. Upstreams ## 3. Upstreams
Именованные upstream: `api_backend`, `keycloak`, `bitrix_local`, `bitrix_sync`, опционально `frontend_dev`. Для одной replica допустим `server service:port`; `keepalive` включён. Docker DNS resolver задаётся с коротким `valid` и `resolve` там, где поддерживает выбранная nginx edition; иначе контейнер перезапускается при смене IP upstream. Именованные upstream: `api_backend`, `keycloak`, `sms_service`, `bitrix_local`, `bitrix_sync`, опционально `frontend_dev`. Для одной replica допустим `server service:port`; `keepalive` включён. Docker DNS resolver задаётся с коротким `valid` и `resolve` там, где поддерживает выбранная nginx edition; иначе контейнер перезапускается при смене IP upstream.
Upstream failures не перенаправляются на другой сервис и не попадают в SPA. API возвращает `502/504` с безопасным nginx body и `X-Request-ID`; custom JSON error допустим для `/api`, но не имитирует backend domain code. Upstream failures не перенаправляются на другой сервис и не попадают в SPA. API возвращает `502/504` с безопасным nginx body и `X-Request-ID`; custom JSON error допустим для `/api`, но не имитирует backend domain code.
@@ -100,6 +101,7 @@ traceparent: входной валидный либо новый согласн
| обычный API | 3s / 30s / 30s | | обычный API | 3s / 30s / 30s |
| auth | 3s / 30s / 60s | | auth | 3s / 30s / 60s |
| Bitrix callback | 3s / 30s / 60s | | Bitrix callback | 3s / 30s / 60s |
| Direct SMS callback | 3s / 30s / 60s |
| WS | 3s / 30s / 75s+ | | WS | 3s / 30s / 75s+ |
| message POST | 3s / 30s / `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s` минимум | | message POST | 3s / 30s / `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s` минимум |
@@ -117,6 +119,7 @@ traceparent: входной валидный либо новый согласн
- `polling`: GET messages fallback; - `polling`: GET messages fallback;
- `downloads`: issuance URL; - `downloads`: issuance URL;
- `bitrix_callbacks`: мягкий burst для повторов; - `bitrix_callbacks`: мягкий burst для повторов;
- `idgtl_callbacks`: отдельный bounded burst, учитывающий повтор каждые 5 минут в течение суток;
- `ws_connect`: handshake; - `ws_connect`: handshake;
- `connections`: `limit_conn`. - `connections`: `limit_conn`.
@@ -162,6 +165,14 @@ CORS — exact allow-list из согласованного deploy config; appli
Bitrix placement может требовать embedding: для exact `/bitrix/placement` CSP `frame-ancestors` задаётся отдельным allow-list Bitrix24, а не ослабляет SPA. Bitrix placement может требовать embedding: для exact `/bitrix/placement` CSP `frame-ancestors` задаётся отдельным allow-list Bitrix24, а не ослабляет SPA.
### Callback i-Digital Direct
- Только exact `location = /callbacks/idgtl/sms`; разрешён только `POST`, остальные методы отклоняются.
- Source IP allowlist — `185.203.96.7`, но значение обязательно повторно сверяется с актуальной документацией Direct перед production. При WAF/LB используется только нормализованный trusted client IP.
- TLS обязателен; cache выключен; body size ограничен под массив callback items.
- Basic `Authorization` передаётся `sms-service`, но никогда не записывается в access/error logs. URL с credentials также редактируется.
- Nginx не проверяет provider payload и не преобразует статусы; это делает `sms-service`. Ошибку upstream/DB нельзя маскировать `2xx`, иначе Direct не повторит callback.
## 13. Health ## 13. Health
- внутренний `GET /nginx-health/live` возвращает static 200 и доступен Docker healthcheck; - внутренний `GET /nginx-health/live` возвращает static 200 и доступен Docker healthcheck;
@@ -248,6 +259,7 @@ curl -i https://tohin.ru/internal/safety/v1/messages/check
- CSP/CORS preflight и Bitrix placement exception; - CSP/CORS preflight и Bitrix placement exception;
- upstream down/timeout, failed reload, renewal rehearsal; - upstream down/timeout, failed reload, renewal rehearsal;
- logs не содержат secrets/query tokens. - logs не содержат secrets/query tokens.
- allowed Direct callback проходит; wrong IP/method и любой `/internal/sms/*` отклоняются; Authorization отсутствует в логах.
## 19. Definition of Done ## 19. Definition of Done
+65 -29
View File
@@ -1,6 +1,6 @@
# module-08. Проектная спецификация `keycloak` # module-08. Проектная спецификация `keycloak`
> Статус: целевая production-спецификация MVP; реальный SMS provider не входит в scope. > Статус: целевая production-спецификация OTP; mock действует до controlled rollout, real mode интегрируется только через `sms-service` по [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md).
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-02-frontend-test-site.md`](module-02-frontend-test-site.md), [`module-03-nginx.md`](module-03-nginx.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py). > Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-02-frontend-test-site.md`](module-02-frontend-test-site.md), [`module-03-nginx.md`](module-03-nginx.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
## 1. Назначение и границы ## 1. Назначение и границы
@@ -12,7 +12,7 @@ Keycloak отвечает за:
- realm, users, credentials, auth sessions и token lifecycle; - realm, users, credentials, auth sessions и token lifecycle;
- Authorization Code Flow with PKCE для Expo web/iOS/Android; - Authorization Code Flow with PKCE для Expo web/iOS/Android;
- нормализацию/уникальность телефона и claims; - нормализацию/уникальность телефона и claims;
- OTP authenticator/SPI, mock verification и продуктовые limits; - OTP authenticator/SPI, генерацию и локальную проверку OTP, challenge lifecycle, продуктовые limits и verify audit;
- brute-force, sessions, logout/revocation; - brute-force, sessions, logout/revocation;
- keys/JWKS rotation и health/metrics. - keys/JWKS rotation и health/metrics.
@@ -22,9 +22,10 @@ Keycloak отвечает за:
- App DB/profile/chat и CRM sync; - App DB/profile/chat и CRM sync;
- API service-to-service tokens; - API service-to-service tokens;
- пользовательскую UX-сессию; - пользовательскую UX-сессию;
- реальную отправку SMS в MVP. - шаблоны, отправку и provider delivery journal (это `sms-service`);
- прямой вызов i-Digital Direct и обработку delivery callback.
Реальный SMS provider — строго extension point/TBD. Mock code является секретом окружения, не контентом UI и не логируется. Mock code является секретом окружения, не контентом UI и не логируется. В real mode Keycloak вызывает только закрытый durable-order API `sms-service`; API Direct Verifier не используется.
## 2. Топология и публичный URL ## 2. Топология и публичный URL
@@ -130,9 +131,9 @@ Internal API по-прежнему используют service tokens из arch
3. `Phone Identity Authenticator` нормализует номер. 3. `Phone Identity Authenticator` нормализует номер.
4. Проверяются realm brute-force и product send limits. 4. Проверяются realm brute-force и product send limits.
5. Создаётся/находится user по canonical phone identity. 5. Создаётся/находится user по canonical phone identity.
6. `Phone OTP Challenge` инициирует mock/provider send. 6. `Phone OTP Challenge` создаёт `ordering`: mock активирует его локально, real mode заказывает SMS через `sms-service`.
7. Показывается форма OTP. 7. Показывается форма OTP.
8. Проверяются TTL/attempt limits/constant-time hash or mock compare. 8. Проверяются только локальные status/TTL/attempt limits и constant-time HMAC/mock compare; provider status не читается.
9. При успехе user enabled/phone verified, flow завершается code. 9. При успехе user enabled/phone verified, flow завершается code.
10. Frontend меняет code+verifier на tokens. 10. Frontend меняет code+verifier на tokens.
@@ -182,7 +183,7 @@ Required actions не должны предлагать пароль/email. По
Изменение телефона требует re-auth + OTP нового номера и invalidation sessions/tokens по policy. `api-backend` обновляет cached phone при следующем bootstrap/login claim; прямого вызова Keycloak DB нет. Изменение телефона требует re-auth + OTP нового номера и invalidation sessions/tokens по policy. `api-backend` обновляет cached phone при следующем bootstrap/login claim; прямого вызова Keycloak DB нет.
## 7. Mock OTP ## 7. Mock и real delivery mode
Env: Env:
@@ -193,24 +194,24 @@ KEYCLOAK_OTP_MOCK_CODE=<secret>
Правила: Правила:
- mock разрешён MVP production-like только как явно принятый риск; - mock временно разрешён до controlled SMS rollout только как явно принятый риск;
- пустой/default `1234` запрещён startup policy для production-like, если не согласован secret; - пустой/default `1234` запрещён startup policy для production-like, если не согласован secret;
- code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit; - code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit;
- сравнение constant-time; - сравнение constant-time;
- challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production; - challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production;
- code не сохраняется per-user в открытом виде; - code не сохраняется per-user в открытом виде;
- UI сообщает только «тестовый режим», без кода; - UI сообщает только «тестовый режим», без кода;
- `KEYCLOAK_OTP_MOCK_ENABLED=false` при отсутствии configured provider делает OTP flow fail-closed/not-ready, а не пропускает проверку. - `KEYCLOAK_OTP_MOCK_ENABLED=false` при недоступном/неконфигурированном `sms-service` завершает новый order generic unavailable; уже active challenges продолжают локальный verify до TTL.
Реальный provider interface: Реальный delivery interface:
```java ```java
interface OtpDeliveryProvider { interface OtpDeliveryProvider {
DeliveryResult send(E164Phone phone, String otp, Duration ttl, Correlation ctx); SmsOrderResult order(E164Phone phone, String otp, Duration ttl, String challengeId);
} }
``` ```
Будущий provider обязан вернуть `provider_message_id`; raw OTP не логируется. Выбор provider, template, sender, delivery status webhook и vendor credentials — TBD. Реализация real mode — `SmsOrderClient` к `POST /internal/sms/v1/send`. Успех — только `200/202` с `sms_message_id`; один HTTP retry использует тот же challenge и `idempotency_key=keycloak:challenge:{challenge_id}`. Keycloak не получает `provider_message_id`, template/sender/status/callback и не хранит vendor credentials.
## 8. OTP challenge и counters ## 8. OTP challenge и counters
@@ -218,14 +219,14 @@ interface OtpDeliveryProvider {
- challenge id random ≥128 bit; - challenge id random ≥128 bit;
- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper; - OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper;
- TTL (предлагается 5 минут) — technical security parameter; - TTL — snapshot `app_settings["otp.phone.ttl_seconds"]`, диапазон `60..900`, кратен 60; real mode считается от `ordered_at`;
- one-time use; success atomically consumes challenge; - one-time use; success atomically consumes challenge;
- max verification attempts per challenge; - max verification attempts per challenge;
- resend invalidates либо version-binds предыдущий challenge; - resend всегда переводит предыдущий `active`/`ordering` challenge в `superseded`;
- replay/parallel verify безопасны; - replay/parallel verify безопасны;
- destination stored masked/hash where possible. - destination stored masked/hash where possible.
Audit fields по arch-05: provider message id (для mock — synthetic non-secret), sent_at, destination_masked, otp_hash/reference, attempts, outcome. Никогда raw code. Audit хранит `sms_message_id` (nullable для mock/order_failed), `ordered_at`, destination masked/HMAC, attempts, outcome и device context. Provider send/delivery status и полный SMS journal в schema `keycloak` запрещены.
### 8.1. Product send limits bridge ### 8.1. Product send limits bridge
@@ -242,6 +243,10 @@ Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
{ {
"max_send_attempts_per_24h": 3, "max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30, "min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "2026-07-10T08:00:00Z", "version": "2026-07-10T08:00:00Z",
"cache_ttl_seconds": 60 "cache_ttl_seconds": 60
} }
@@ -270,11 +275,27 @@ Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/in
Минимальные records: Минимальные records:
- `han_otp_challenge`: id, phone_hmac, otp_hash/mock marker, created/expires/consumed, verify attempts, settings version, provider id/status; - `han_otp_challenge`: id, phone_hmac, destination_masked, otp_hash, `sms_message_id`, `delivery_mode`, `challenge_status`, `ordered_at`, `expires_at`, `otp_ttl_sec`, `otp_code_length`, verify attempts, settings version;
- `han_otp_send_counter`: phone_hmac, window_start, count, last_sent_at; - `han_otp_send_counter`: phone_hmac, window_start, count, last_sent_at;
- `han_otp_security_event`: append-only minimal outcome/retention. - `han_otp_security_event`: append-only событие на каждую send/verify попытку, `sms_message_id`, outcome/details и device context.
Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(expires_at)`. Cleanup bounded job. Доступ только `keycloak_user`. Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(challenge_status,expires_at)`, partial `sms_message_id` и event `sms_message_id`. Periodic expiry переводит active в `expired`; автоматическое удаление SMS journal выполняться здесь не может. Доступ только `keycloak_user`.
### 8.3. Lifecycle и границы транзакций
1. После limits/counter reservation прежние `active`/`ordering` становятся `superseded`; создаётся новый `ordering` с crypto-random numeric OTP и immutable settings snapshot.
2. В real mode HTTP order выполняется вне transaction с DB locks. Потерянный ответ повторяется с тем же challenge/idempotency key, без нового OTP/counter.
3. `200/202` + `sms_message_id` → короткая transaction устанавливает `ordered_at`, `expires_at=ordered_at+otp_ttl_sec`, status `active` и event `otp_send/ordered`.
4. Невозможность durable order → `order_failed`; прежний challenge не восстанавливается. В mock mode challenge сразу `active`, `sms_message_id=null`.
5. Verify разрешён только для `active`: success → `consumed`, неверный код увеличивает attempts/event, лимит → `limited`, TTL → `expired`. Никакой переход не зависит от Direct `send_status`/`delivery_status`.
Миграция существующих mock rows: дождаться прежнего max TTL либо истечь незавершённые challenges; установить `delivery_mode=mock`, `sms_message_id=null`, `ordered_at=created_at`, consumed rows → `consumed`, остальные → `expired`, backfill TTL/length текущими seed. Прежние `provider_id`/`provider_status` сначала nullable/неиспользуемые и удаляются только отдельной backward-incompatible migration после стабилизации.
### 8.4. Device context и verify events
`han_otp_security_event` содержит `client_ip`, `user_agent`, `device_id`, `fingerprint`, `os_name`, `os_version`, `platform`, `app_version`; `sms_message_id` копируется для корреляции. Событие `otp_verify` пишется на каждую попытку с outcome `success|failure|limited|expired|already_used`.
Frontend передаёт необязательные `han_device_id`, `han_fingerprint`, `han_platform`, `han_os_name`, `han_os_version`, `han_app_version` в OIDC request/hidden fields. Значения недоверенные audit metadata: id/fingerprint ≤256, OS/app ≤64, platform только `web|ios|android`, control characters запрещены. IP берётся только из trusted nginx chain, UA — из текущего запроса. Query/form/OTP/device identifiers редактируются в access logs.
## 9. Brute-force и abuse ## 9. Brute-force и abuse
@@ -472,15 +493,24 @@ KC_DB_URL_PROPERTIES=currentSchema=keycloak
KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=<secret> KEYCLOAK_OTP_MOCK_CODE=<secret>
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<secret> KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<secret>
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
KEYCLOAK_SMS_SERVICE_TOKEN=<secret>
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
``` ```
Дополнительные Keycloak-standard env version-specific (`KC_DB`, hostname/proxy/health/metrics/pool) фиксируются в `.env.example` после выбора image. Секреты только root `.env`/secret mounts с минимальными permissions. Дополнительные Keycloak-standard env version-specific (`KC_DB`, hostname/proxy/health/metrics/pool) фиксируются в `.env.example` после выбора image. Секреты только root `.env`/secret mounts с минимальными permissions.
Product limits `otp.phone.*`, включая `otp.phone.max_verify_attempts`, не дублируются env и поступают через settings bridge. OTP TTL остаётся security technical config provider-а: Все изменяемые OTP-параметры, включая limits, длину кода, TTL и timeout durable SMS order, не дублируются в env и поступают через settings bridge:
```text
otp.phone.code_length
otp.phone.ttl_seconds
otp.phone.sms_order_timeout_ms
```
Challenge сохраняет snapshot этих значений и `settings_version`; изменение настроек влияет только на новые challenges. В env остаются только secret/bootstrap-параметры:
```text ```text
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300 KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_OTP_HMAC_KEY=<secret> KEYCLOAK_OTP_HMAC_KEY=<secret>
``` ```
@@ -494,7 +524,7 @@ Keycloak management health endpoints включены. Compose проверяе
- realm/client/auth flow/provider loaded; - realm/client/auth flow/provider loaded;
- active signing key; - active signing key;
- settings bridge last-known-good для OTP send; - settings bridge last-known-good для OTP send;
- mock enabled с valid secret либо реальный provider configured. - mock enabled с valid secret либо real-mode `sms-service` URL/token configured. Общая readiness Keycloak не зависит от Direct/provider status; недоступность `sms-service` отражается отдельным degraded dependency indicator и блокирует только новый real order.
Стандартный Keycloak health сам не знает business provider state; custom provider readiness check/sidecar/synthetic internal check дополняет его. Public synthetic проверяет discovery/JWKS и authorization endpoint без отправки OTP. Стандартный Keycloak health сам не знает business provider state; custom provider readiness check/sidecar/synthetic internal check дополняет его. Public synthetic проверяет discovery/JWKS и authorization endpoint без отправки OTP.
@@ -527,7 +557,7 @@ Keycloak access log должен редактировать sensitive query. TRA
- active sessions/token refresh/error; - active sessions/token refresh/error;
- DB pool/JVM/GC/HTTP; - DB pool/JVM/GC/HTTP;
- JWKS/key age; - JWKS/key age;
- provider mode info (`mock`, later vendor), без phone labels. - delivery mode info (`mock`, `sms`) и provider dependency `idgtl`, без phone labels.
### Tracing ### Tracing
@@ -582,7 +612,9 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
| settings bridge down, cache valid | send limits по last-known-good | | settings bridge down, cache valid | send limits по last-known-good |
| settings bridge down, cache empty/stale | new OTP send fail-closed | | settings bridge down, cache empty/stale | new OTP send fail-closed |
| mock secret missing/invalid | startup/not-ready; OTP не bypass | | mock secret missing/invalid | startup/not-ready; OTP не bypass |
| SMS mode без provider | not-ready `otp_provider_unconfigured` | | real mode без URL/token `sms-service` | новый OTP order fail-closed `otp_provider_unconfigured`; startup/config gate не пройден |
| `sms-service` timeout/5xx | один retry с тем же idempotency key; затем `order_failed`, generic unavailable |
| Direct reject/timeout после durable order | active challenge не меняется; Keycloak provider status не читает |
| wrong OTP | generic error, increment counter | | wrong OTP | generic error, increment counter |
| too many sends/verifies | temporary reject/lockout, safe UX | | too many sends/verifies | temporary reject/lockout, safe UX |
| token signing key rotation | old keys passive в JWKS grace | | token signing key rotation | old keys passive в JWKS grace |
@@ -604,6 +636,9 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- settings cache/ETag/stale/fail-closed; - settings cache/ETag/stale/fail-closed;
- phone HMAC/counter cleanup; - phone HMAC/counter cleanup;
- provider SPI error mapping. - provider SPI error mapping.
- durable order `200/202`, idempotent retry, `409` reuse и `order_failed`;
- lifecycle `ordering/active/superseded/expired/limited/consumed`, periodic/lazy expiry;
- device metadata validation и append-only event на каждую verify.
### Realm/config contract ### Realm/config contract
@@ -629,6 +664,8 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
### E2E ### E2E
- new phone → mock OTP → PKCE tokens → API bootstrap; - new phone → mock OTP → PKCE tokens → API bootstrap;
- real mode: durable order открывает OTP form до ответа Direct; `sms_message_id` совпадает в обеих БД;
- resend отклоняет старый код; provider reject/timeout не меняет active challenge;
- existing user login; valid refresh without OTP; - existing user login; valid refresh without OTP;
- expired/revoked/rotated refresh → re-auth; - expired/revoked/rotated refresh → re-auth;
- wrong/expired/replayed code; - wrong/expired/replayed code;
@@ -657,7 +694,7 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- phone canonical E.164 и storage-level unique; - phone canonical E.164 и storage-level unique;
- claims соответствуют module-01 (`sub`, `phone_number`, audience); - claims соответствуют module-01 (`sub`, `phone_number`, audience);
- mock secret only env, не логируется/не отдаётся; - mock secret only env, не логируется/не отдаётся;
- OTP challenges/counters durable в Keycloak schema; - OTP challenges/counters/verify events durable в Keycloak schema; SMS journal/template/provider statuses там отсутствуют;
- product limits читаются только через canonical settings bridge/token; - product limits читаются только через canonical settings bridge/token;
- brute-force, TTL, verify attempts и enumeration protection работают; - brute-force, TTL, verify attempts и enumeration protection работают;
- refresh rotation/reuse detection/logout/revocation покрыты; - refresh rotation/reuse detection/logout/revocation покрыты;
@@ -666,7 +703,7 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- health/metrics/logging/tracing не раскрывают secrets/PII; - health/metrics/logging/tracing не раскрывают secrets/PII;
- container hardening/root Compose без published port; - container hardening/root Compose без published port;
- test matrix зелёная; - test matrix зелёная;
- реальный SMS явно остаётся extension point, не скрытой заглушкой. - mock и real mutually exclusive; real mode вызывает только durable-order API `sms-service`, Direct/Verifier/status polling отсутствуют.
## 27. Решения, допущения и TBD ## 27. Решения, допущения и TBD
@@ -679,13 +716,13 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- K5: counters/challenges в provider-owned PostgreSQL schema `keycloak`, не API Redis. - K5: counters/challenges в provider-owned PostgreSQL schema `keycloak`, не API Redis.
- K6: product limits только `/internal/settings/v1/otp` + `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`. - K6: product limits только `/internal/settings/v1/otp` + `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`.
- K7: refresh rotation/revoke-on-use; frontend single-flight. - K7: refresh rotation/revoke-on-use; frontend single-flight.
- K8: real SMS provider — extension point/TBD. - K8: real delivery`Keycloak → sms-service → i-Digital Direct`; verify остаётся локальным.
**Допущения:** **Допущения:**
- A1: единый public host `tohin.ru` и relative path `/auth`. - A1: единый public host `tohin.ru` и relative path `/auth`.
- A2: Keycloak version поддерживает нужные hostname/proxy/health options; точные names pin после выбора image. - A2: Keycloak version поддерживает нужные hostname/proxy/health options; точные names pin после выбора image.
- A3: product допускает mock OTP в первой production-like среде как временный риск. - A3: product допускает mock OTP до прохождения controlled real-SMS rollout как временный риск.
- A4: телефон в access token необходим API bootstrap и защищён TLS/short token TTL. - A4: телефон в access token необходим API bootstrap и защищён TLS/short token TTL.
**TBD:** **TBD:**
@@ -697,6 +734,5 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- K-TBD5: admin MFA/ops access topology и отдельный admin hostname. - K-TBD5: admin MFA/ops access topology и отдельный admin hostname.
- K-TBD6: signing-key rotation interval/HSM и emergency revocation. - K-TBD6: signing-key rotation interval/HSM и emergency revocation.
- K-TBD7: RPO/RTO/event retention/legal deletion. - K-TBD7: RPO/RTO/event retention/legal deletion.
- K-TBD8: SMS vendor, credentials, templates, sender, delivery receipts and failover. - K-TBD8 закрыт module-11 для v1: vendor i-Digital Direct, credentials/template/sender/callback принадлежат `sms-service`; failover вне v1.
- K-TBD9: CAPTCHA/risk scoring после mock. - K-TBD9: CAPTCHA/risk scoring после mock.
- K-TBD10: добавить proposed OTP technical env в arch-04 до реализации.
+57 -24
View File
@@ -39,6 +39,9 @@ Placeholders:
<RELEASE> immutable tag/git SHA <RELEASE> immutable tag/git SHA
<ACME_EMAIL> адрес ops, не placeholder в реальном запуске <ACME_EMAIL> адрес ops, не placeholder в реальном запуске
<BITRIX_PORTAL> разрешённый портал <BITRIX_PORTAL> разрешённый портал
<IDGTL_SENDER_NAME> согласованное в Direct имя отправителя
<IDGTL_STATIC_EGRESS_IP> фактический статический egress IP `sms-worker`
<IDGTL_TEST_PHONE> контролируемый номер для provider smoke
``` ```
## 3. Stage 0 — решения до provisioning ## 3. Stage 0 — решения до provisioning
@@ -72,7 +75,7 @@ Placeholders:
- [ ] RPO/RTO приняты хотя бы временно: ориентир RPO PG ≤15 минут/PITR, RTO ≤4 часа. - [ ] RPO/RTO приняты хотя бы временно: ориентир RPO PG ≤15 минут/PITR, RTO ≤4 часа.
- [ ] Решено: images pull из registry или build на VM. - [ ] Решено: images pull из registry или build на VM.
- [ ] Remote telemetry backend выбран либо явно принят ограниченный debug-only режим. - [ ] Remote telemetry backend выбран либо явно принят ограниченный debug-only режим.
- [ ] Риск mock OTP и Safety stub письменно принят. - [ ] Риск mock OTP до SMS cutover и Safety stub письменно принят; real SMS не включается без gates module-11.
**Ожидаемый результат:** есть release checklist с конкретными values; не создано ни одной публичной БД/Redis. **Ожидаемый результат:** есть release checklist с конкретными values; не создано ни одной публичной БД/Redis.
@@ -90,7 +93,7 @@ Security groups:
| internet | VM | TCP 80 | allow для redirect/ACME | | internet | VM | TCP 80 | allow для redirect/ACME |
| internet | VM | TCP 443 | allow | | internet | VM | TCP 443 | allow |
| VM private IP/SG | managed PG | `<PG_PORT>` | allow | | VM private IP/SG | managed PG | `<PG_PORT>` | allow |
| VM | internet | 443 | allow egress: registry, Bitrix, S3, OTLP, ACME | | VM | internet | 443 | allow egress: registry, Bitrix, S3, OTLP, ACME, i-Digital Direct |
| internet | managed PG | any | deny | | internet | managed PG | any | deny |
| internet | VM | 6379, 4317, 4318, 8000, 8080, 9000 | deny | | internet | VM | 6379, 4317, 4318, 8000, 8080, 9000 | deny |
@@ -197,7 +200,7 @@ CA managed PostgreSQL скачать из панели или документа
Прототип `init-managed-postgres.py` выдаёт runtime roles `CREATE` на schema и печатает DSN. Это допустимо только для bootstrap/dev, но **слишком широко для production runtime**. Перед production адаптировать: Прототип `init-managed-postgres.py` выдаёт runtime roles `CREATE` на schema и печатает DSN. Это допустимо только для bootstrap/dev, но **слишком широко для production runtime**. Перед production адаптировать:
1. создать пять schemas: `han_app`, `bitrix_local`, `bitrix_sync`, `keycloak`, `message_safety`; 1. создать шесть schemas: `han_app`, `bitrix_local`, `bitrix_sync`, `keycloak`, `message_safety`, `sms`;
2. создать runtime roles; 2. создать runtime roles;
3. создать migration roles либо controlled admin job; 3. создать migration roles либо controlled admin job;
4. schema owner = migration role; 4. schema owner = migration role;
@@ -239,14 +242,15 @@ psql "host=<PG_PRIVATE_HOST> port=<PG_PORT> dbname=<PG_DATABASE> user=<RUNTIME_U
2. `bitrix-local-app` Alembic владеет `bitrix_local`; 2. `bitrix-local-app` Alembic владеет `bitrix_local`;
3. `message-safety` stub не создаёт PG tables до production implementation; 3. `message-safety` stub не создаёт PG tables до production implementation;
4. `bitrix-sync` stub — optional empty baseline; 4. `bitrix-sync` stub — optional empty baseline;
5. Keycloak мигрирует standard tables сам; custom provider имеет собственные versioned migrations. 5. Keycloak мигрирует standard tables сам; custom provider имеет собственные versioned migrations;
6. `sms-service` владеет versioned migrations/seed schema `sms`; runtime `sms_user` не имеет доступа к `han_app`/`keycloak`.
Только expand/migrate/contract. Destructive migration — отдельный backup, approval и release. Downgrade data migrations не обещается; rollback приложения требует backward-compatible schema. Только expand/migrate/contract. Destructive migration — отдельный backup, approval и release. Downgrade data migrations не обещается; rollback приложения требует backward-compatible schema.
### Gate 3 ### Gate 3
- [ ] Backups/PITR/TLS/deletion protection включены. - [ ] Backups/PITR/TLS/deletion protection включены.
- [ ] Пять schemas/roles созданы. - [ ] Шесть schemas/roles созданы, включая `sms`/`sms_user`.
- [ ] Runtime roles не имеют DDL/чужого доступа. - [ ] Runtime roles не имеют DDL/чужого доступа.
- [ ] Migration credentials отделены от runtime. - [ ] Migration credentials отделены от runtime.
- [ ] Empty/previous-version migration test успешен. - [ ] Empty/previous-version migration test успешен.
@@ -366,6 +370,7 @@ openssl rand -hex 32
- Redis ACL credentials/URLs DB0/1/2; - Redis ACL credentials/URLs DB0/1/2;
- public web/API/auth URLs; - public web/API/auth URLs;
- Keycloak realm/audience/hostname/bootstrap/provider technical secrets; - Keycloak realm/audience/hostname/bootstrap/provider technical secrets;
- SMS DB URL, парные Keycloak↔SMS tokens, Direct `TOKEN_1`, callback URL и отдельные callback credentials;
- paired service tokens из arch-02; - paired service tokens из arch-02;
- Bitrix client/application/webhook/encryption secrets; - Bitrix client/application/webhook/encryption secrets;
- S3 endpoint/buckets/API and read-only Safety credentials; - S3 endpoint/buckets/API and read-only Safety credentials;
@@ -378,6 +383,7 @@ openssl rand -hex 32
```text ```text
BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN
BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN
KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN
``` ```
Service token и webhook token — разные secrets. Service token и webhook token — разные secrets.
@@ -398,6 +404,7 @@ Service token и webhook token — разные secrets.
- Safety timeout согласован с nginx; - Safety timeout согласован с nginx;
- secrets minimum length; - secrets minimum length;
- mock OTP risk flag explicitly accepted. - mock OTP risk flag explicitly accepted.
- placeholders `change-me`/`<...>` запрещены; real mode требует sender/template/API key/callback credentials и recorded static egress IP;
```bash ```bash
cd <BACKEND_ROOT> cd <BACKEND_ROOT>
@@ -466,12 +473,13 @@ cd <BACKEND_ROOT>
docker compose config --services docker compose config --services
``` ```
Ожидаются: `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` и one-shot jobs/profile components. В целевом real-SMS release ожидаются: `nginx`, `api-backend`, `message-safety`, `keycloak`, `sms-service`, `sms-worker` (либо документированный worker process), `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` и one-shot jobs/profile components.
Networks: Networks:
- `public`: nginx и минимально Keycloak/frontend path; - `public`: nginx и минимально Keycloak/frontend path;
- `backend`: internal services/Redis; - `backend`: internal services/Redis;
- `egress`: только утверждённые outbound workers; Keycloak в неё не входит, `sms-worker` входит;
- `observability`: services + Collector. - `observability`: services + Collector.
Volumes: Volumes:
@@ -661,6 +669,25 @@ docker compose run --rm api-backend python -m app.cli.validate_settings
- [ ] Mandatory settings valid; secrets отсутствуют в `app_settings`. - [ ] Mandatory settings valid; secrets отсутствуют в `app_settings`.
- [ ] Backward compatibility с текущими images подтверждена. - [ ] Backward compatibility с текущими images подтверждена.
### 13.4. Controlled rollout real SMS
До переключения Keycloak:
1. применить App DB seed `otp.phone.code_length`, `otp.phone.ttl_seconds`, `otp.phone.sms_order_timeout_ms`;
2. создать schema/role `sms`, применить migrations и idempotent seed `sms_setting`/active approved `auth_otp`;
3. в test environment развернуть `sms-service`/worker с локальным mock Direct и выполнить contract/E2E;
4. получить production Direct `TOKEN_1`, согласованные sender и template, отдельные callback credentials;
5. определить egress IP фактическим запросом из `sms-worker`, подтвердить его статичность/NAT, записать в inventory и передать Direct для allowlist;
6. развернуть production `sms-service`/worker и callback route, оставив `KEYCLOAK_OTP_MOCK_ENABLED=true`;
7. применить Keycloak expand migration/SPI, мигрировать старые challenges по module-11;
8. выполнить provider smoke отдельной ops-командой на `<IDGTL_TEST_PHONE>`; проверить journal, callback, redaction и отсутствие duplicate;
9. только после подписанных evidence переключить `KEYCLOAK_OTP_MOCK_ENABLED=false`;
10. проверить durable order до Direct response, resend/superseded, expiry snapshot, limits и verify при provider reject/timeout.
Production cutover запрещён при любом placeholder, несогласованном sender/template, отсутствующем API key/callback credentials, неподтверждённом callback IP или нестатическом egress IP. Direct API key — готовый `TOKEN_1` для Basic, повторно Base64 не кодируется.
Rollback SMS: немедленно вернуть Keycloak в mock mode; не удалять schema/journal и не откатывать migrations без доказанной backward compatibility. Остановить новые real orders, дать worker завершить либо зафиксировать in-flight/`uncertain`; предпочтителен forward-fix.
## 14. Stage 11 — Keycloak bootstrap ## 14. Stage 11 — Keycloak bootstrap
### 14.1. Первый старт ### 14.1. Первый старт
@@ -713,22 +740,25 @@ Custom OTP tables мигрируются versioned mechanism до включен
Архитектурный порядок: Архитектурный порядок:
1. Redis; 1. Redis;
2. Keycloak; 2. OTEL Collector;
3. OTEL Collector; 3. API backend/settings;
4. Message Safety; 4. SMS service/worker после migrations (при SMS release; Keycloak пока mock);
5. API backend; 5. Keycloak;
6. Bitrix local app; 6. Message Safety;
7. Bitrix sync; 7. Bitrix local app;
8. nginx. 8. Bitrix sync;
9. nginx.
Команды: Команды:
```bash ```bash
cd <BACKEND_ROOT> cd <BACKEND_ROOT>
docker compose up -d redis docker compose up -d redis
docker compose up -d keycloak otel-collector docker compose up -d otel-collector
docker compose up -d message-safety
docker compose up -d api-backend docker compose up -d api-backend
docker compose up -d sms-service sms-worker
docker compose up -d keycloak
docker compose up -d message-safety
docker compose up -d bitrix-local-app bitrix-sync docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx docker compose up -d nginx
docker compose ps docker compose ps
@@ -827,6 +857,7 @@ Expected: 308; public 200 strict DTO; discovery 200; internal 404; valid cert.
- silent refresh работает без OTP; - silent refresh работает без OTP;
- logout очищает tokens; - logout очищает tokens;
- wrong/replayed OTP не выдаёт tokens. - wrong/replayed OTP не выдаёт tokens.
- real mode: durable order возвращает `sms_message_id` до Direct response; callback обновляет только SMS journal; resend делает старый challenge `superseded`.
### 17.3. Message Safety правила stub ### 17.3. Message Safety правила stub
@@ -955,13 +986,14 @@ DB backup включает realm/users/signing keys/provider data. Secret-free r
### Application-only ### Application-only
1. объявить incident/maintenance; 1. объявить incident/maintenance;
2. сохранить diagnostics и current state; 2. при SMS incident вернуть `KEYCLOAK_OTP_MOCK_ENABLED=true`, прекратить новые real orders и сохранить journal/in-flight state;
3. остановить новые claims/send при возможности; 3. сохранить diagnostics и current state;
4. переключить image tags на previous digests; 4. остановить новые claims/send при возможности;
5. не выполнять Alembic downgrade; 5. переключить image tags на previous digests;
6. `docker compose up -d`; 6. не выполнять Alembic downgrade;
7. health/smoke/idempotency; 7. `docker compose up -d`;
8. проверить outbox/inbox/recovery. 8. health/smoke/idempotency;
9. проверить outbox/inbox/SMS pending/uncertain/recovery.
### После backward-incompatible migration ### После backward-incompatible migration
@@ -1162,7 +1194,7 @@ certbot delete active cert
- D-A2: обязательный минимум — `otel-collector`; доступность remote backend не предполагается до закрытия D-TBD11, local Grafana stack не обязателен. - D-A2: обязательный минимум — `otel-collector`; доступность remote backend не предполагается до закрытия D-TBD11, local Grafana stack не обязателен.
- D-A3: managed provider даёт private network, TLS, backups/PITR. - D-A3: managed provider даёт private network, TLS, backups/PITR.
- D-A4: Bitrix portal/connector/line остаются разрешёнными значениями architecture. - D-A4: Bitrix portal/connector/line остаются разрешёнными значениями architecture.
- D-A5: mock OTP временно разрешён как documented risk. - D-A5: mock OTP временно разрешён до controlled SMS cutover как documented risk.
### TBD до production ### TBD до production
@@ -1186,8 +1218,9 @@ certbot delete active cert
4. Прототипные TLS scripts используют отдельный service Compose/standalone downtime, тогда как целевая архитектура требует root Compose и two-phase webroot. 4. Прототипные TLS scripts используют отдельный service Compose/standalone downtime, тогда как целевая архитектура требует root Compose и two-phase webroot.
5. Prototype публиковал `/bitrix-internal/*` и использовал `/internal/v1/*`; целевой контур это запрещает и использует `/internal/openlines/v1/*`. 5. Prototype публиковал `/bitrix-internal/*` и использовал `/internal/v1/*`; целевой контур это запрещает и использует `/internal/openlines/v1/*`.
6. `arch-04` не содержит ряд proposed env из module-0409; production `.env.example` должен быть синхронизирован до реализации. 6. `arch-04` не содержит ряд proposed env из module-0409; production `.env.example` должен быть синхронизирован до реализации.
7. Точные RPO/RTO, retention, SLO, Keycloak version/TTL и Bitrix retry semantics не утверждены; начальные значения runbook не закрывают product/security decision. 7. Точные RPO/RTO, SLO, Keycloak version и Bitrix retry semantics не утверждены; OTP TTL задаётся `app_settings`, SMS journal по module-11 хранится бессрочно.
8. `init-managed-postgres.py` по умолчанию не задаёт TLS parameters при bootstrap connection и печатает credential-bearing DSN; его production-hardening обязателен. 8. `init-managed-postgres.py` по умолчанию не задаёт TLS parameters при bootstrap connection и печатает credential-bearing DSN; его production-hardening обязателен.
9. Текущие Compose/env/config artifacts могут ещё не содержать `sms-service`; документация не разрешает real mode до реализации и прохождения rollout gates.
## 29. Ссылки на прототип ## 29. Ссылки на прототип
+889
View File
@@ -0,0 +1,889 @@
# module-11. Сервис доставки SMS (i-Digital Direct)
> Статус: целевая проектная спецификация post-MVP (закрывает K-TBD8 / бэклог «интеграция с SMS-провайдером»).
> Реализация отсутствует. Документ задаёт обязательные контракты для разработки `sms-service` и доработки Keycloak.
> Источники провайдера: [Отправка SMS](https://api.docs.direct.i-dgtl.ru/messages/sms-sending/), [Авторизация](https://api.docs.direct.i-dgtl.ru/authorization/), [Callback](https://api.docs.direct.i-dgtl.ru/messages/callback/).
> Смежные: [`module-08-keycloak.md`](module-08-keycloak.md), [`arch-01`](../architectory/arch-01-system-architecture.md), [`arch-02`](../architectory/arch-02-api-contracts.md), [`arch-04`](../architectory/arch-04-settings-and-content.md).
**Критерий применимости:** до синхронизации `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` |
**Жёсткие правила:**
1. Keycloak **не** вызывает i-Digital напрямую и **не** хранит полный журнал SMS (текст, delivery status провайдера, шаблоны).
2. sms-service **не** генерирует OTP, **не** проверяет код и **не** знает, верно ли пользователь ввёл код.
3. Связка: Keycloak получает от sms-service `sms_message_id` и сохраняет его в своём challenge/событиях.
4. [API верификации телефона](https://api.docs.direct.i-dgtl.ru/verifier/api/) (`/verifier/send`, `/verifier/check`) **не используется**.
```text
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`) 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`
Запрос:
```text
{
"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` для нового заказа:
```json
{
"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. Вызов
```http
POST https://direct.i-dgtl.ru/api/v1/message
Authorization: Basic {TOKEN_1}
Content-Type: application/json
```
```text
[
{
"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, не шаблоны)
```text
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_at``expired`.
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. Артефакты реализации
```text
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/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:
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, а не параметр приложения или открытое архитектурное решение.
+18
View File
@@ -15,3 +15,21 @@ echo '*/5 * * * * root /opt/han-chat/ops/han-vm-metrics.sh --alert --log /var/lo
# diagnose-han-chat.sh # diagnose-han-chat.sh
# КОнтроль места
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.ID}}\t{{.CreatedSince}}'
docker builder du
# Очистка build cache
docker builder prune -af
# Cron раз в сутки (03:15) + лог
echo '15 3 * * * root /usr/bin/docker builder prune -af >> /var/log/docker-builder-prune.log 2>&1' | sudo tee /etc/cron.d/docker-builder-prune
sudo chmod 644 /etc/cron.d/docker-builder-prune
Логи
cat /etc/cron.d/docker-builder-prune
tail -n 20 /var/log/docker-builder-prune.log
# Проверка места
df -h /
docker system df
+91
View File
@@ -0,0 +1,91 @@
На ВМ выполните:
cd /opt/han-chat/backend
umask 077
read -r -p "Тестовый номер в E.164 (+79...): " TEST_PHONE
CHALLENGE_ID=$(python3 -c 'import uuid; print(uuid.uuid4())')
OTP_CODE=$(python3 -c 'import secrets; print(f"{secrets.randbelow(1000000):06d}")')
SMS_TOKEN=$(python3 - <<'PY'
from pathlib import Path
for line in Path(".env").read_text().splitlines():
if line.startswith("SMS_SERVICE_TOKEN="):
print(line.split("=", 1)[1].strip().strip("\"'"))
break
else:
raise SystemExit("SMS_SERVICE_TOKEN отсутствует")
PY
)
export TEST_PHONE CHALLENGE_ID OTP_CODE SMS_TOKEN
REQUEST_FILE=$(mktemp)
python3 - "$REQUEST_FILE" <<'PY'
import json
import os
import sys
payload = {
"idempotency_key": f"ops:smoke:{os.environ['CHALLENGE_ID']}",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": os.environ["TEST_PHONE"],
"substitutions": {
"code": os.environ["OTP_CODE"],
"ttl_min": "1",
},
"customer_ref": os.environ["CHALLENGE_ID"],
"message_ttl_sec": 60,
}
with open(sys.argv[1], "w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False)
PY
Создайте функцию отправки:
send_sms_smoke() {
docker compose --env-file .env --profile ops run --rm --no-deps \
--user 0:0 \
--entrypoint sh \
-e SMS_TOKEN \
-v "$REQUEST_FILE:/tmp/sms-request.json:ro" \
toolbox -ec '
curl -sS \
-w "\nHTTP %{http_code}\n" \
-X POST \
-H "Authorization: Bearer $SMS_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Request-ID: ops-sms-smoke" \
--data-binary @/tmp/sms-request.json \
http://sms-service:8080/internal/sms/v1/send
'
}
Отправка:
send_sms_smoke
Ожидается:
HTTP 202 и JSON с sms_message_id.
Проверьте журнал:
SELECT
id,
phone_masked,
send_status,
delivery_status,
provider_message_id,
provider_error_code,
attempt_count,
created_at
FROM sms.sms_outbound_message
ORDER BY created_at DESC
LIMIT 5;
После проверки удалите секретные данные:
shred -u "$REQUEST_FILE" 2>/dev/null || rm -f "$REQUEST_FILE"
unset SMS_TOKEN OTP_CODE TEST_PHONE CHALLENGE_ID REQUEST_FILE
@@ -11,14 +11,62 @@
Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5432 root@135.106.164.58 -N Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5432 root@135.106.164.58 -N
#Обновление проекта #Обновление проекта
mkdir -p ~/.ssh
cp /mnt/c/Users/MI/.ssh/hansel ~/.ssh/hansel
chmod 600 ~/.ssh/hansel
'''bash'''
rsync -rltD --no-perms --no-owner --no-group -invc --delete \
--exclude='.env' \
--exclude='*.crt' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='secrets/' \
-e "ssh -i ~/.ssh/hansel" \
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \
root@135.106.164.58:/opt/han-chat/backend/
-r — рекурсивно.
-l — сохранять символические ссылки.
-t — сохранять время модификации (важно для будущих проверок).
-D — сохранять устройства (на всякий случай, как в -a).
--no-perms --no-owner --no-group — главное исправление: не пытаться копировать права, владельца и группу с Windows на Linux. Это избавит от ложных срабатываний.
-i — покажет только реально измененные файлы (можно заменить на -v, если хотите просто список).
-a (archive) — сохраняет права, время и рекурсивно копирует.
-v (verbose) — выводит список файлов.
-n (dry-run) — главный флаг, показывает, что бы произошло, но не делает этого.
--delete — решение вашей проблемы. Говорит rsync удалять на приемнике (ВМ) файлы, которых нет в источнике (локально).
Важно: не забудьте поставить слэш / в конце пути к локальному проекту, иначе rsync скопирует саму папку внутрь папки на ВМ.
2. Скопировать и автоматически почистить артефакты
Когда вы убедитесь, что вывод предыдущей команды вас устраивает, просто уберите флаг -n:
rsync -rltD --no-perms --no-owner --no-group -ivc --delete \
--exclude='.env' \
--exclude='*.crt' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='secrets/' \
-e "ssh -i ~/.ssh/hansel" \
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \
root@135.106.164.58:/opt/han-chat/backend/
cd /opt/han-chat/backend
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh
docker compose --env-file .env build frontend-static keycloak
docker compose --env-file .env up -d \
--no-deps \
--force-recreate frontend-static keycloak
# Архивный способ копирования:
cd /tmp cd /tmp
rm han-chat-backend.tar.gz rm han-chat-backend.tar.gz
cd /opt/han-chat/backend cd /opt/han-chat/backend
rm C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz
#команда складывает архив в ту папку, из которой запускается команда #команда складывает архив в ту папку, из которой запускается команда
cd C:\Users\MI\Documents\Assistent\ cd C:\Users\MI\Documents\Assistent\
rm C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz
tar -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\backend -czf han-chat-backend.tar.gz . tar -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\backend -czf han-chat-backend.tar.gz .
scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz root@135.106.164.58:/tmp/han-chat-backend.tar.gz scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz root@135.106.164.58:/tmp/han-chat-backend.tar.gz
@@ -141,6 +189,10 @@ psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru \
GRANT CREATE ON DATABASE han_chat TO bitrix_sync_user; GRANT CREATE ON DATABASE han_chat TO bitrix_sync_user;
GRANT CREATE ON DATABASE han_chat TO message_safety_app; GRANT CREATE ON DATABASE han_chat TO message_safety_app;
GRANT CREATE ON DATABASE han_chat TO keycloak_user; GRANT CREATE ON DATABASE han_chat TO keycloak_user;
GRANT CREATE ON DATABASE han_chat TO sms_user
Если создаем пользователей после того как отозвали права from public, надо давать гранты на коннект:
GRANT CONNECT ON DATABASE han_chat TO sms_user
Схемы создаем от лица пользователей, заходя каждым из них в БД. Схемы создаем от лица пользователей, заходя каждым из них в БД.
+ Запрещаем всем посторонним входить в схему han_app и др. + Запрещаем всем посторонним входить в схему han_app и др.
@@ -169,6 +221,12 @@ REVOKE ALL ON SCHEMA keycloak FROM PUBLIC;
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO keycloak; ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO keycloak;
SHOW search_path; --чтобы заработало надо переподключиться SHOW search_path; --чтобы заработало надо переподключиться
CREATE SCHEMA IF NOT EXISTS sms AUTHORIZATION sms_user;
REVOKE ALL ON SCHEMA sms FROM PUBLIC;
ALTER ROLE sms_user IN DATABASE han_chat SET search_path TO sms, public;
SHOW search_path;
Проверка search_path Проверка search_path
SELECT r.rolname, d.datname, s.setconfig SELECT r.rolname, d.datname, s.setconfig
FROM pg_db_role_setting s FROM pg_db_role_setting s
@@ -449,3 +507,28 @@ request = urllib.request.Request(
) )
print(urllib.request.urlopen(request).read().decode()) print(urllib.request.urlopen(request).read().decode())
PY PY
## План включения реальной SMS-авторизации
Этот раздел — чек-лист будущего release из `modules/module-11-idgtl-sms.md`, а не подтверждение готовности текущего Compose. Пока отсутствуют реализованные `sms-service`/worker, migrations, callback route и env validation, оставлять `KEYCLOAK_OTP_MOCK_ENABLED=true`.
Prerequisites без placeholders:
- согласованные i-Digital sender и active approved template `auth_otp` с placeholders `code`, `ttl_min`;
- выданный Direct `TOKEN_1` (`IDGTL_SMS_API_KEY`, без повторного Base64);
- отдельные random callback username/password и публичный HTTPS URL;
- повторно подтверждённый source IP callback Direct;
- фактический статический egress IP, измеренный из `sms-worker`, записанный в inventory и переданный Direct для allowlist; при динамическом IP сначала настроить NAT/static IP.
Rollout:
1. Seed новых `otp.phone.*` в App DB.
2. Создать schema/role `sms`, применить versioned migrations и seed template/settings.
3. Проверить Keycloak→`sms-service`→локальный mock Direct в test environment.
4. Развернуть production `sms-service`/worker и nginx callback route, не выключая mock.
5. Применить Keycloak expand migration/SPI; прежние незавершённые challenges истечь по module-11.
6. Выполнить provider smoke на контролируемом номере; проверить `sms_message_id`, journal, callback и redaction.
7. Переключить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
8. Проверить resend→`superseded`, expiry snapshot, limits и то, что Direct reject/timeout после durable order не меняет verify.
Rollback: вернуть Keycloak в mock mode; не удалять schema/journal. Остановить новые real orders, дать worker завершить либо зафиксировать in-flight/`uncertain`. Schema downgrade только при доказанной backward compatibility, иначе forward-fix.
+405
View File
@@ -0,0 +1,405 @@
# #1 SMS OTP deploy
Безопасный порядок развёртывания `sms-service`/worker на существующей ВМ и включения реальной OTP-доставки: сначала подготовить PostgreSQL и секреты, затем запустить новый контур при `mock=true`, проверить i-Digital и только после этого переключить Keycloak.
Старую сборку Keycloak после expand-миграции возвращать нельзя. Аварийный откат выполняется переключением новой сборки обратно в mock-режим.
## 0. До начала
- Получить у i-Digital:
- `TOKEN_1`;
- согласованное имя отправителя;
- согласованный текст `auth_otp`;
- подтверждённый source IP для callback;
- регистрацию статического egress IP ВМ.
- Создать PITR marker/backup managed PostgreSQL.
- Скопировать `/opt/han-chat/backend/.env` в защищённое место вне каталога релиза.
- Оставить `KEYCLOAK_OTP_MOCK_ENABLED=true` до последнего этапа.
- На production-like при mock-режиме оставить `KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true`.
- Подтвердить у Direct актуальность IP `185.203.96.7`, указанного в `codebase/backend/nginx/templates/site-tls.conf.template`. Если IP другой — обновить allowlist до сборки nginx.
## 1. Создать пользователя и схему PostgreSQL
### 1.1. Создать пользователя
В интерфейсе Selectel создать отдельного пользователя:
```text
sms_user
```
Использовать случайный пароль не короче 32 символов.
### 1.2. Создать схему
Подключиться к `han_chat` под `dbAdmin` и выполнить:
```sql
GRANT CONNECT ON DATABASE han_chat TO sms_user;
GRANT CREATE ON DATABASE han_chat TO sms_user;
CREATE SCHEMA IF NOT EXISTS sms AUTHORIZATION sms_user;
REVOKE ALL ON SCHEMA sms FROM PUBLIC;
ALTER ROLE sms_user IN DATABASE han_chat SET search_path TO sms, public;
```
### 1.3. Проверить
Переподключиться к БД как `sms_user`:
```sql
SELECT current_user;
SHOW search_path;
SELECT
nspname,
pg_get_userbyid(nspowner) AS owner
FROM pg_namespace
WHERE nspname = 'sms';
```
Ожидаемый результат:
- `current_user = sms_user`;
- `search_path = sms, public`;
- владелец схемы `sms``sms_user`.
Не выдавать `sms_user` права на схемы `han_app` и `keycloak`.
## 2. Заполнить `.env` на ВМ
Файл:
```text
/opt/han-chat/backend/.env
```
Добавить или обновить:
```dotenv
SMS_SERVICE_IMAGE=han-chat-sms-service:local
SMS_DATABASE_URL=postgresql+asyncpg://sms_user:<URL_ENCODED_PASSWORD>@<PG_HOST>:<PG_PORT>/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
SMS_SERVICE_TOKEN=<openssl rand -hex 32>
KEYCLOAK_SMS_SERVICE_TOKEN=<ТОЧНО ТО ЖЕ ЗНАЧЕНИЕ>
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=<ГОТОВЫЙ TOKEN_1 БЕЗ ПОВТОРНОГО BASE64>
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://<PUBLIC_HOST>/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=<openssl rand -hex 16>
IDGTL_SMS_CALLBACK_PASSWORD=<openssl rand -hex 32>
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
```
Важно:
- пароль БД необходимо URL-encode, если он содержит специальные символы;
- `SMS_SERVICE_TOKEN` и `KEYCLOAK_SMS_SERVICE_TOKEN` должны совпадать;
- `IDGTL_SMS_API_KEY` — уже готовое значение Basic API key `TOKEN_1`, повторно кодировать его нельзя;
- `KEYCLOAK_OTP_HMAC_KEY` во время rollout не менять.
### 2.1. Проверить egress IP
Из каталога `/opt/han-chat/backend`:
```bash
docker compose --env-file .env --profile ops run --rm \
--entrypoint curl toolbox -fsS https://api.ipify.org
```
Полученный IP передать Direct для allowlist. При динамическом IP сначала настроить статический IP/NAT.
### 2.2. Проверить конфигурацию
```bash
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env config --quiet
docker compose --env-file .env config --services
```
## 3. Скопировать и собрать release
Копирование проекта выполняется по инструкции `deploy-steps.md`.
Сначала выполнить `rsync` с флагом `-n` и проверить список изменений. Убедиться, что исключены:
```text
.env
secrets/
*.crt
*.pem
*.key
```
После проверки повторить `rsync` без `-n`.
На ВМ:
```bash
cd /opt/han-chat/backend
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh nginx/scripts/*.sh
./scripts/validate-env .env
docker compose --env-file .env build --pull \
api-backend sms-service keycloak frontend-static nginx
```
На этом этапе `KEYCLOAK_OTP_MOCK_ENABLED` всё ещё должен быть `true`.
## 4. Применить миграции
Перед миграцией создать PITR marker у провайдера БД.
```bash
cd /opt/han-chat/backend
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
```
Команда применит:
- migration `0005_otp_settings` для `han_app`;
- migration `0001_initial` для схемы `sms`;
- migration `0002_seed` для схемы `sms`;
- остальные штатные migrations проекта.
При необходимости применить production-like settings:
```bash
docker compose --env-file .env --profile ops run --rm seed-settings
```
Проверить версии:
```sql
SELECT version_num FROM han_app.alembic_version;
SELECT version_num FROM sms.alembic_version;
```
Ожидается:
```text
han_app: 0005_otp_settings
sms: 0002_seed
```
## 5. Записать согласованные sender и SMS-шаблон
Миграция намеренно создаёт placeholder. Пока он не заменён, `sms-service` будет возвращать `not_ready`.
Подключиться как `sms_user` и выполнить, подставив согласованные значения:
```sql
UPDATE sms.sms_setting
SET setting_value = to_jsonb('<APPROVED_SENDER>'::text),
updated_at = now()
WHERE setting_key = 'provider.idgtl.default_sender_name';
UPDATE sms.sms_template
SET body_template = 'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
sender_name = NULL,
approved_at = now(),
updated_at = now(),
created_by = 'ops-approved'
WHERE code = 'auth_otp'
AND channel = 'SMS'
AND locale = 'ru'
AND version = 1;
```
Если оператор согласовал другой текст, использовать именно его. В тексте должны остаться ровно два placeholders:
```text
{code}
{ttl_min}
```
Для OTP должно сохраняться:
```text
max_parts = 1
```
Проверить:
```sql
SELECT
code,
channel,
locale,
version,
body_template,
sender_name,
max_parts,
is_active,
approved_at
FROM sms.sms_template
WHERE code = 'auth_otp';
SELECT setting_key, setting_value
FROM sms.sms_setting
ORDER BY setting_key;
```
Должна существовать ровно одна active+approved версия `auth_otp`, а placeholder имени отправителя должен быть заменён.
## 6. Запустить SMS-контур при `mock=true`
```bash
cd /opt/han-chat/backend
docker compose --env-file .env up -d sms-service sms-worker
docker compose --env-file .env ps sms-service sms-worker
docker compose --env-file .env logs --since=10m sms-service sms-worker
```
Ожидается:
- `sms-service` — healthy;
- worker запущен;
- отсутствуют ошибки Direct `401`/`402`;
- отсутствуют contract errors;
- отсутствуют необъяснённые `uncertain`.
Затем запустить обновлённые смежные сервисы, не выключая mock:
```bash
docker compose --env-file .env up -d --force-recreate \
api-backend keycloak frontend-static nginx
docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf
deployment/scripts/smoke.sh
```
Первый запуск новой сборки Keycloak применит Liquibase expand migration. Старые незавершённые OTP challenges будут помечены истёкшими, поэтому запускать Keycloak лучше в период низкой активности.
## 7. Проверить i-Digital до включения real mode
Через внутренний endpoint:
```text
POST /internal/sms/v1/send
```
заказать одну SMS на контролируемый номер.
Требования к тесту:
- использовать уникальный `idempotency_key`;
- не записывать service token и OTP в shell history;
- JSON body создать во временном файле с правами `600`;
- после теста удалить временный файл.
Проверить журнал:
```sql
SELECT
id,
created_at,
phone_masked,
send_status,
delivery_status,
provider_message_id,
provider_error_code,
attempt_count,
callback_last_at
FROM sms.sms_outbound_message
ORDER BY created_at DESC
LIMIT 10;
```
Ожидается:
1. После заказа создана одна строка.
2. `send_status` переходит в `accepted`.
3. `provider_message_id` заполнен.
4. Callback меняет `delivery_status` на `sent`/`delivered`.
5. Повтор идентичного запроса возвращает тот же `sms_message_id` и не создаёт вторую SMS.
Проверить edge:
- публичный `/internal/sms/*` возвращает `404`;
- callback не с IP Direct возвращает `403`;
- реальный callback Direct проходит IP allowlist и Basic auth.
## 8. Включить реальные SMS
Только после успешной тестовой отправки изменить:
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=false
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
KEYCLOAK_OTP_MOCK_CODE=
```
Применить:
```bash
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env up -d \
--no-deps \
--force-recreate keycloak
docker compose --env-file .env ps keycloak
docker compose --env-file .env logs --since=10m \
keycloak sms-service sms-worker
```
Проверить полный пользовательский сценарий:
1. Ввод номера телефона.
2. Получение реальной SMS.
3. Неверный OTP отклоняется.
4. Верный OTP авторизует пользователя.
5. Resend создаёт новый challenge.
6. Старый challenge получает `superseded`.
7. Старый код больше не принимается.
8. OTP истекает через 60 секунд.
9. Работают лимиты отправок и проверок.
10. Уже active challenge продолжает локально проверяться при временно остановленном worker.
## 9. Аварийный откат
Не выполнять:
- downgrade Alembic;
- downgrade Liquibase;
- возврат старой сборки Keycloak.
После expand migration старая сборка Keycloak несовместима с новыми обязательными полями challenge.
Безопасный rollback — оставить новую сборку и вернуть mock:
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=<НЕПУБЛИЧНЫЙ 6-ЗНАЧНЫЙ КОД>
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
```
```bash
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env up -d \
--no-deps \
--force-recreate keycloak
```
`sms-service` и worker можно оставить запущенными для обработки callback и reconciliation. Новые SMS-заказы от Keycloak прекратятся.
Записи со статусом `uncertain` автоматически не переотправлять — их необходимо разбирать вручную.