Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -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 |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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¤tSchema=keycloak
|
KEYCLOAK_DB_URL=jdbc:postgresql://<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?user=keycloak_user&password=change-me¤tSchema=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
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-6
@@ -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) {}
|
||||||
|
|||||||
+47
-6
@@ -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) {}
|
||||||
|
|||||||
+11
-4
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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."""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 до реализации.
|
|
||||||
|
|||||||
@@ -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-04–09; production `.env.example` должен быть синхронизирован до реализации.
|
6. `arch-04` не содержит ряд proposed env из module-04–09; 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. Ссылки на прототип
|
||||||
|
|
||||||
|
|||||||
@@ -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, а не параметр приложения или открытое архитектурное решение.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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` автоматически не переотправлять — их необходимо разбирать вручную.
|
||||||
Reference in New Issue
Block a user