Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
| Тема | Где зафиксировано |
|
||||
|---|---|
|
||||
| Доставка документов компании из 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 |
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
| `text_resources` | `han_app` | Тексты UI по мнемоникам |
|
||||
| `popular_questions` | `han_app` | Популярные вопросы главного экрана |
|
||||
| `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`) |
|
||||
| `task_id` | ID async-проверки Message Safety |
|
||||
| `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**.
|
||||
|
||||
@@ -127,9 +133,16 @@ Realtime-событие `message.status` передаёт актуальные `
|
||||
|
||||
## Строковые 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).
|
||||
|
||||
### 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/{service_mnemonic}/v1/`**. Health: **`/health/*`**.
|
||||
@@ -140,6 +153,14 @@ Realtime-событие `message.status` передаёт актуальные `
|
||||
| `openlines` | `bitrix-local-app`, приёмник inbox на `api-backend` |
|
||||
| `sync` | `bitrix-sync` |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ HAN Chat - приложение для мигрантов, где стартов
|
||||
- Мультиязычность в первом релизе не нужна, но тексты должны храниться по мнемоникам для будущих переводов.
|
||||
- Среда на первом этапе одна и проектируется как боевая.
|
||||
- Вложения чата 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, затем отправка.
|
||||
- Перечень таблиц и миграций App DB проектирует модуль `database` (и владельцы схем других сервисов); arch фиксирует только **разделение схем** PostgreSQL и контракты между сервисами.
|
||||
|
||||
@@ -42,12 +42,13 @@ HAN Chat - приложение для мигрантов, где стартов
|
||||
|
||||
- Expo App: единая frontend-кодовая база для iOS, Android и web.
|
||||
- Keycloak: identity provider, OTP-only авторизация по номеру телефона.
|
||||
- SMS Service: internal durable order API, шаблоны и бессрочный журнал SMS; отдельный worker вызывает i-Digital Direct, callback обновляет только журнал.
|
||||
- api-backend: Python-приложение с REST API, realtime-доставкой сообщений и бизнес-логикой.
|
||||
- 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).
|
||||
- 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).
|
||||
- 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);
|
||||
- S3-data: production-хранилище проверенных файлов чата (`han-chat-attachments`) и документов компании (`han-chat-documents`).
|
||||
- S3-quarantine: временное хранилище загруженных файлов до вердикта Message Safety Service (`han-chat-quarantine`); read-only для `message-safety`.
|
||||
@@ -57,7 +58,7 @@ HAN Chat - приложение для мигрантов, где стартов
|
||||
|
||||
На первом этапе весь 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);
|
||||
- внутренние сервисы общаются по Docker-сети на localhost VM.
|
||||
|
||||
@@ -72,6 +73,7 @@ HAN Chat - приложение для мигрантов, где стартов
|
||||
| одна база / `message_safety` | `message-safety` | verdict cache, safety_task, rule config |
|
||||
| одна база / `bitrix_local` | `bitrix-local-app` | OAuth, inbox, `dialog_sessions` |
|
||||
| одна база / `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).
|
||||
|
||||
@@ -82,6 +84,9 @@ flowchart LR
|
||||
Client[Expo Mobile/Web App]
|
||||
Nginx[Nginx Reverse Proxy]
|
||||
Keycloak[Keycloak OTP]
|
||||
SMS[SMS Service]
|
||||
SMSWorker[SMS Worker]
|
||||
Direct[i-Digital Direct]
|
||||
API[Python api-backend]
|
||||
Safety[Message Safety Service]
|
||||
DB[(PostgreSQL)]
|
||||
@@ -95,8 +100,14 @@ flowchart LR
|
||||
|
||||
Client -->|HTTPS REST + Realtime| Nginx
|
||||
Nginx -->|/auth| Keycloak
|
||||
Nginx -->|exact POST /callbacks/idgtl/sms| SMS
|
||||
Nginx -->|"/api REST + WS realtime"| API
|
||||
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 --> Redis
|
||||
Client -->|presigned PUT| S3Q
|
||||
@@ -222,8 +233,9 @@ Frontend не должен:
|
||||
Отвечает за:
|
||||
|
||||
- OTP-only регистрацию и вход;
|
||||
- OTP по номеру телефона; проверка кода — в Keycloak (заглушка `KEYCLOAK_OTP_MOCK_*` или SMS-провайдер, см. arch-04 и «Поток авторизации»);
|
||||
- продуктовые лимиты OTP (`otp.phone.*` из `app_settings`) через authenticator/SPI и settings bridge `api-backend` (см. arch-04); счётчики попыток — в зоне Keycloak (Redis DB Keycloak/SPI или in-memory Keycloak), **не** в `api-backend`;
|
||||
- OTP по номеру телефона; генерация и локальная проверка кода, challenge lifecycle, limits и verify audit — в Keycloak;
|
||||
- в 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);
|
||||
- настройку realm, clients, roles, policies;
|
||||
@@ -238,7 +250,7 @@ Frontend не должен:
|
||||
| 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 |
|
||||
| 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 сценариев.
|
||||
|
||||
@@ -253,6 +265,7 @@ Confidential **backend client** Keycloak (client credentials) в MVP **не об
|
||||
- маршрутизацию `/auth/*` или выделенного auth-домена в Keycloak;
|
||||
- маршрутизацию публичных `/bitrix/*` endpoint в `bitrix-local-app`;
|
||||
- маршрутизацию `/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`;
|
||||
- отсутствие публичной маршрутизации к `message-safety` — сервис доступен только из внутренней Docker-сети;
|
||||
- передачу `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. Клиент может опционально согласиться на рекламные коммуникации.
|
||||
6. Если обязательные согласия не даны, отправка блокируется.
|
||||
7. Frontend запрашивает публичные настройки и показывает форму ввода номера телефона (единственный канал MVP).
|
||||
8. Keycloak запускает OTP-flow по телефону: клиент вводит номер, инициируется «отправка» OTP (при заглушке SMS фактически не уходит — см. arch-04).
|
||||
9. Лимиты OTP на **edge** — `nginx` (`NGINX_RATE_LIMIT_AUTH`); продуктовые лимиты `otp.phone.*` из `app_settings` применяются на стороне **Keycloak authenticator / SPI** (или обёртки OTP), не в `api-backend`. До интеграции SMS (mock OTP) достаточно edge + mock code.
|
||||
8. Keycloak запускает OTP-flow: в mock mode challenge сразу активен без SMS; в real mode Keycloak создаёт `ordering`, генерирует OTP, заказывает SMS в `sms-service` и активирует challenge только после durable order.
|
||||
9. Лимиты OTP на **edge** — `nginx`; продуктовые `otp.phone.*` применяет Keycloak. HTTP retry одного durable order использует прежние challenge/idempotency key и не увеличивает send counter.
|
||||
10. Клиент вводит OTP и отправляет его в Keycloak.
|
||||
11. **Keycloak проверяет корректность введённого OTP**:
|
||||
- при **`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 не выполняется.
|
||||
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` → минимальный профиль.
|
||||
@@ -539,7 +552,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
|
||||
|
||||
### Состав 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-репозитория
|
||||
|
||||
@@ -583,6 +596,11 @@ backend/
|
||||
realm/
|
||||
themes/
|
||||
providers/
|
||||
sms-service/
|
||||
app/
|
||||
migrations/
|
||||
openapi.yaml
|
||||
Dockerfile
|
||||
redis/
|
||||
docker-compose.yml
|
||||
observability/
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
| `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` |
|
||||
| `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_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`. Секреты не коммитить.
|
||||
|
||||
@@ -340,6 +342,7 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
|
||||
| `message-safety` | `message-safety/openapi.yaml` | нет (internal) |
|
||||
| `bitrix-local-app` | `bitrix-local-app/openapi.yaml` | частично (`/bitrix/*`, health) |
|
||||
| `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
|
||||
|
||||
@@ -368,14 +420,14 @@ Keycloak **обязателен** в production-like контуре с перв
|
||||
| OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens |
|
||||
| OIDC Discovery (`/.well-known/openid-configuration`) | Keycloak | Expo frontend, `api-backend` | issuer, token/jwks endpoints |
|
||||
| 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 |
|
||||
|
||||
Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена.
|
||||
|
||||
**`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):**
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
- `/auth/*` → `keycloak`;
|
||||
- `/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`;
|
||||
- exact `POST /callbacks/idgtl/sms` → `sms-service`; остальные методы и SMS paths не публикуются;
|
||||
- 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` контейнера, а не на порты отдельных сервисов напрямую.
|
||||
|
||||
### Структура compose через `include`
|
||||
@@ -65,12 +66,14 @@ include:
|
||||
- bitrix-sync/docker-compose.yml
|
||||
- bitrix-local-app/docker-compose.yml
|
||||
- keycloak/docker-compose.yml
|
||||
- sms-service/docker-compose.yml
|
||||
- redis/docker-compose.yml
|
||||
- observability/docker-compose.yml
|
||||
|
||||
networks:
|
||||
public:
|
||||
backend:
|
||||
egress:
|
||||
observability:
|
||||
|
||||
volumes:
|
||||
@@ -122,6 +125,7 @@ Reverse proxy и единственная публичная точка вход
|
||||
- маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен;
|
||||
- маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`;
|
||||
- маршрутизирует `/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;
|
||||
- **не публикует** `message-safety` наружу;
|
||||
- **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);
|
||||
- одна 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);
|
||||
- TLS к managed PostgreSQL обязателен;
|
||||
- миграции Alembic выполняются отдельной командой при деплое;
|
||||
@@ -236,10 +240,19 @@ Identity provider. **Обязателен** в compose-контуре с пер
|
||||
- включены proxy settings для работы за `nginx`;
|
||||
- импорт realm в local/dev;
|
||||
- использует 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;
|
||||
- взаимодействия — [`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
|
||||
|
||||
Кэш, rate limiting, coordination (не единственное хранилище бизнес-событий).
|
||||
@@ -269,6 +282,7 @@ Identity provider. **Обязателен** в compose-контуре с пер
|
||||
|
||||
- `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).
|
||||
- `egress`: только сервисы с утверждёнными исходящими интеграциями; для SMS — `sms-worker`, но не Keycloak. Production real mode требует фактический статический egress IP/NAT, записанный в inventory и переданный Direct для allowlist.
|
||||
- `observability`: `otel-collector` + сервисы, экспортирующие telemetry.
|
||||
|
||||
Базы данных, 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-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`;
|
||||
- `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`;
|
||||
|
||||
Наружу через `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 должна быть доступна до старта зависимых сервисов).
|
||||
2. `keycloak`.
|
||||
3. `otel-collector`.
|
||||
4. `message-safety`.
|
||||
5. `api-backend`.
|
||||
6. `bitrix-local-app`.
|
||||
7. `bitrix-sync`.
|
||||
8. `nginx`.
|
||||
2. `otel-collector`.
|
||||
3. `api-backend` и seed OTP settings.
|
||||
4. `sms-service`/worker после migrations/seed (Keycloak пока mock).
|
||||
5. `keycloak`.
|
||||
6. `message-safety`.
|
||||
7. `bitrix-local-app`.
|
||||
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`.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|---|---|---|
|
||||
| **Инфраструктура** | `.env` | подключения, URL, секреты, nginx/TLS, service tokens |
|
||||
| **Бизнес-логика** | таблица **`app_settings`** | лимиты, флаги, телефоны, типы файлов, CORS, consent URLs |
|
||||
| **Настройки SMS runtime** | таблица **`sms.sms_setting`** | sender default, provider timeouts, callback flag, worker intervals |
|
||||
| **Контент** | `text_resources`, `popular_questions` | тексты UI |
|
||||
|
||||
Managed PostgreSQL **поднимается до** развёртывания приложения. Бизнес-настройки **не дублируются** в `.env`: seed в `app_settings` выполняется миграцией/скриптом модуля `database` **до** первого запуска `api-backend`.
|
||||
@@ -27,8 +28,8 @@ Managed PostgreSQL **поднимается до** развёртывания п
|
||||
- секреты: S3, Bitrix OAuth, service tokens, webhook-тokens;
|
||||
- параметры **nginx/TLS** и edge rate limits (`NGINX_RATE_LIMIT_*`);
|
||||
- идентификация Keycloak: realm, audience, public/internal URL;
|
||||
- **OTP-заглушка MVP** (`KEYCLOAK_OTP_MOCK_*`) — infra/dev-секрет, не бизнес-настройка;
|
||||
- технические таймауты worker-ов (`MESSAGE_SAFETY_*`, интервалы `bitrix-sync`).
|
||||
- переключатель и секрет временного OTP mock (`KEYCLOAK_OTP_MOCK_*`); mock обязателен до прохождения real-SMS rollout gates и запрещён как незаявленный fallback;
|
||||
- технические параметры сервисов, пока профильная спецификация не определила service-owned settings; для `sms-service` runtime-параметры уже вынесены в `sms.sms_setting`.
|
||||
|
||||
**Запрещено в `.env` (→ только `app_settings`):**
|
||||
|
||||
@@ -85,7 +86,7 @@ Managed PostgreSQL **поднимается до** развёртывания п
|
||||
| Группа | Ключи |
|
||||
|---|---|
|
||||
| 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` |
|
||||
| Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` |
|
||||
| Файлы чата | `chat.attachments.*` |
|
||||
@@ -102,6 +103,9 @@ auth.password.enabled=false
|
||||
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
|
||||
|
||||
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`
|
||||
|
||||
Только инфраструктура. Бизнес-параметры — в 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_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>
|
||||
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
|
||||
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
# Selectel PgBouncer 5433: pool_mode=session; search_path задаётся на уровне ролей.
|
||||
@@ -190,7 +216,7 @@ NGINX_RATE_LIMIT_PUBLIC=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_INTERNAL_URL=http://keycloak:8080
|
||||
@@ -198,6 +224,7 @@ KEYCLOAK_REALM=han-chat
|
||||
KEYCLOAK_AUDIENCE=han-chat-api
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=1234
|
||||
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
|
||||
|
||||
# =============================================================================
|
||||
# Redis (I4: раздельные DB index)
|
||||
@@ -219,6 +246,17 @@ BITRIX_INTERNAL_API_TOKEN=change-me
|
||||
BITRIX_API_FORWARD_TOKEN=change-me
|
||||
BITRIX_SYNC_SERVICE_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)
|
||||
@@ -297,6 +335,8 @@ presigned URL и CORS Selectel; path-style адресация не поддер
|
||||
|
||||
Все переменные — **только** в `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`.
|
||||
|
||||
## Namespace переменных Bitrix
|
||||
@@ -317,16 +357,16 @@ presigned URL и CORS Selectel; path-style адресация не поддер
|
||||
|
||||
## Keycloak settings bridge для OTP
|
||||
|
||||
Product limits OTP (`otp.phone.*`) хранятся в `app_settings`, но Keycloak не получает прямой доступ к схеме `han_app`.
|
||||
OTP settings (`otp.phone.*`) хранятся в `app_settings`, но Keycloak не получает прямой доступ к схеме `han_app`.
|
||||
|
||||
MVP-механизм:
|
||||
|
||||
1. `api-backend` читает публичные/служебные настройки из `app_settings` и кэширует их.
|
||||
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.
|
||||
|
||||
Счётчики попыток OTP остаются в зоне Keycloak/SPI, не в `api-backend`.
|
||||
Challenge сохраняет snapshot TTL, длины кода и `settings_version`; изменение settings влияет только на новые challenges. Счётчики попыток OTP остаются в зоне Keycloak/SPI, не в `api-backend`.
|
||||
|
||||
## Разрешённые типы файлов чата (MVP)
|
||||
|
||||
|
||||
+12
-3
@@ -10,12 +10,21 @@
|
||||
9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
|
||||
10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
|
||||
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
|
||||
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 заливка мнемоник в БД (?)
|
||||
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_SYNC_IMAGE=han-chat-bitrix-sync: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.
|
||||
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_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
|
||||
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_SCHEMA=keycloak
|
||||
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_CLIENT_MAX_BODY_SIZE=8m
|
||||
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_POLLING=60r/m
|
||||
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
|
||||
NGINX_RATE_LIMIT_BITRIX=120r/m
|
||||
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
|
||||
NGINX_RATE_LIMIT_WS=30r/m
|
||||
NGINX_MESSAGE_READ_TIMEOUT_SEC=330
|
||||
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
|
||||
# (openssl rand -hex 32)
|
||||
KEYCLOAK_OTP_HMAC_KEY=change-me
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
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
|
||||
# (openssl rand -hex 32)
|
||||
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||
@@ -101,6 +106,15 @@ BITRIX_API_INBOX_TOKEN=change-me
|
||||
BITRIX_SYNC_SERVICE_TOKEN=change-me
|
||||
#token5 (openssl rand -hex 32)
|
||||
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_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 app.db import AppSetting, Database
|
||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||
from app.settings import get_settings
|
||||
|
||||
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")
|
||||
if value_type not in VALUE_TYPES:
|
||||
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):
|
||||
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")
|
||||
if description is not None and not isinstance(description, str):
|
||||
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",
|
||||
}
|
||||
)
|
||||
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.schemas import (
|
||||
ConsentsRequest,
|
||||
MessageRequest,
|
||||
OpenLinesInbox,
|
||||
OtpSettingsResponse,
|
||||
SessionStartRequest,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
@@ -415,7 +416,7 @@ async def ready(request: Request, db: Session):
|
||||
try:
|
||||
await db.execute(text("SELECT 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")
|
||||
await load_settings(db)
|
||||
components["postgres"] = "ok"
|
||||
@@ -963,7 +964,12 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
|
||||
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(
|
||||
request: Request,
|
||||
settings: SnapshotDep,
|
||||
@@ -980,6 +986,9 @@ async def otp_settings(
|
||||
"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"),
|
||||
"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,
|
||||
"cache_ttl_seconds": 60,
|
||||
}, 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")
|
||||
|
||||
|
||||
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):
|
||||
platform: Literal["ios", "android", "web"]
|
||||
app_version: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.integrations import (
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.schemas import (
|
||||
AttachmentCompleteRequest,
|
||||
@@ -84,7 +85,7 @@ REQUIRED_SETTINGS = {
|
||||
"ux.session.idle_timeout_minutes",
|
||||
"security.cors.allowed_origins",
|
||||
"security.public_cache.max_age_seconds",
|
||||
}
|
||||
} | OTP_SETTING_KEYS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -126,9 +127,11 @@ class AuditContext:
|
||||
|
||||
|
||||
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||
rows = (
|
||||
rows = list(
|
||||
(
|
||||
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
|
||||
).scalars()
|
||||
)
|
||||
values = {row.setting_key: row.setting_value for row in rows}
|
||||
missing = REQUIRED_SETTINGS - values.keys()
|
||||
if missing:
|
||||
@@ -138,6 +141,25 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||
"Required settings are unavailable",
|
||||
{"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]
|
||||
return SettingsSnapshot(values, version)
|
||||
|
||||
|
||||
@@ -196,7 +196,12 @@ paths:
|
||||
operationId: getOtpSettings
|
||||
security: [{serviceBearer: []}]
|
||||
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"}
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -218,6 +223,27 @@ components:
|
||||
description: Required dependency is unavailable
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
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:
|
||||
type: object
|
||||
required: [error]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 = {
|
||||
"/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:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
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 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:
|
||||
@@ -24,3 +28,37 @@ def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="integer value expected"):
|
||||
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
|
||||
|
||||
В MVP реализован только mock OTP. Для запуска:
|
||||
В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout:
|
||||
|
||||
```dotenv
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
@@ -383,6 +383,10 @@ KEYCLOAK_OTP_MOCK_CODE=<ТЕСТОВЫЙ_КОД_НЕ_КОРОЧЕ_16_СИМВО
|
||||
Этот код будет вводиться пользователем при тестовой авторизации. Не используйте
|
||||
его как 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
|
||||
|
||||
```dotenv
|
||||
|
||||
@@ -211,6 +211,12 @@ SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||
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
|
||||
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
|
||||
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
|
||||
```
|
||||
|
||||
## Дополнение: 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. После обратно несовместимой миграции используйте
|
||||
исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время
|
||||
технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение
|
||||
|
||||
@@ -5,6 +5,9 @@ settings:
|
||||
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.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}
|
||||
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}
|
||||
|
||||
@@ -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:
|
||||
migrate-api:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
@@ -5,6 +13,7 @@ services:
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
environment: *no-sms-secrets
|
||||
entrypoint: []
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
@@ -19,6 +28,7 @@ services:
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
environment: *no-sms-secrets
|
||||
entrypoint: []
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
@@ -33,6 +43,25 @@ services:
|
||||
env_file:
|
||||
- path: ../.env
|
||||
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: []
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
@@ -47,6 +76,7 @@ services:
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
environment: *no-sms-secrets
|
||||
entrypoint: []
|
||||
command:
|
||||
- /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-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-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-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-sms
|
||||
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" = "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}/")
|
||||
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 { Platform } from "react-native";
|
||||
import { env, oidcIssuer } from "./config";
|
||||
import { buildOidcDeviceMetadata } from "./oidc-device";
|
||||
import { SingleFlight } from "./single-flight";
|
||||
import type { TokenSet } from "./types";
|
||||
|
||||
@@ -84,6 +85,7 @@ export async function beginAuthorization() {
|
||||
encoding: Crypto.CryptoEncoding.BASE64,
|
||||
});
|
||||
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
|
||||
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
|
||||
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
@@ -94,6 +96,7 @@ export async function beginAuthorization() {
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
...deviceMetadata,
|
||||
})}`;
|
||||
if (Platform.OS === "web" && typeof window !== "undefined") {
|
||||
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 { sessionMemory } from "../../src/session";
|
||||
import { SingleFlight } from "../../src/single-flight";
|
||||
@@ -95,3 +109,26 @@ describe("WebSocket authentication protocol", () => {
|
||||
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
|
||||
build:
|
||||
context: ../../api-backend
|
||||
@@ -5,6 +13,7 @@ x-api-runtime: &api-runtime
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
environment: *no-sms-secrets
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
@@ -16,6 +25,29 @@ x-api-runtime: &api-runtime
|
||||
driver: json-file
|
||||
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:
|
||||
frontend-static:
|
||||
build:
|
||||
@@ -45,6 +77,7 @@ services:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
environment:
|
||||
<<: *no-sms-secrets
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: ${KEYCLOAK_DB_URL}
|
||||
KC_DB_SCHEMA: ${KEYCLOAK_DB_SCHEMA:-keycloak}
|
||||
@@ -59,12 +92,13 @@ services:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
|
||||
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_TTL_SEC: ${KEYCLOAK_OTP_TTL_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_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"]
|
||||
expose: ["8080", "9000"]
|
||||
volumes:
|
||||
@@ -85,6 +119,43 @@ services:
|
||||
driver: json-file
|
||||
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:
|
||||
build:
|
||||
context: ../../message-safety
|
||||
@@ -92,6 +163,8 @@ services:
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
environment:
|
||||
<<: *no-sms-secrets
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -179,6 +252,8 @@ services:
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
environment:
|
||||
<<: *no-sms-secrets
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -207,6 +282,8 @@ services:
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
environment:
|
||||
<<: *no-sms-secrets
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -7,10 +7,11 @@ KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
|
||||
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
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_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
|
||||
COPY src ./src
|
||||
COPY realm ./realm
|
||||
COPY themes ./themes
|
||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
||||
|
||||
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 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -73,14 +75,22 @@ Private signing keys are generated and stored by Keycloak and are absent from th
|
||||
|
||||
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_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.
|
||||
|
||||
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
|
||||
|
||||
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_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
|
||||
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_TTL_SEC: ${KEYCLOAK_OTP_TTL_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_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_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
|
||||
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
|
||||
|
||||
@@ -5,21 +5,26 @@ import java.time.Duration;
|
||||
|
||||
final class Config {
|
||||
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 Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
|
||||
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
|
||||
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
|
||||
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"http://api-backend:8000/internal/settings/v1/otp"));
|
||||
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 {
|
||||
if (!MOCK_ENABLED) {
|
||||
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start");
|
||||
if (MOCK_ENABLED && (!MOCK_CODE.matches("\\d{6,10}") || "1234".equals(MOCK_CODE))) {
|
||||
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) {
|
||||
throw new IllegalStateException("KEYCLOAK_OTP_MOCK_CODE must be a non-default secret of at least 6 characters");
|
||||
if (!MOCK_ENABLED
|
||||
&& 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) {
|
||||
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
|
||||
@@ -28,6 +33,10 @@ final class Config {
|
||||
|
||||
private Config() {}
|
||||
|
||||
static void validate() {
|
||||
// Class initialization performs the fail-closed validation.
|
||||
}
|
||||
|
||||
private static String required(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
|
||||
@@ -18,6 +18,17 @@ final class Crypto {
|
||||
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) {
|
||||
try {
|
||||
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 java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
|
||||
@@ -17,7 +18,7 @@ final class OtpStore {
|
||||
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();
|
||||
String phoneHmac = Crypto.hmac("phone", phone);
|
||||
OtpSendCounterEntity counter = entityManager.find(
|
||||
@@ -34,77 +35,169 @@ final class OtpStore {
|
||||
counter.windowStart = now;
|
||||
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");
|
||||
}
|
||||
if (counter.lastSentAt.plusSeconds(limits.minSecondsBetween()).isAfter(now)) {
|
||||
event("otp_send", phoneHmac, null, "limited", "cooldown");
|
||||
throw new OtpLimitException("otp_send_limited");
|
||||
if (counter.lastSentAt.plusSeconds(settings.minSecondsBetween()).isAfter(now)) {
|
||||
event("otp_send", phoneHmac, null, null, "limited", "cooldown", device);
|
||||
throw new OtpLimitException("otp_send_cooldown");
|
||||
}
|
||||
counter.sendCount++;
|
||||
counter.lastSentAt = now;
|
||||
|
||||
entityManager.createQuery("""
|
||||
update OtpChallengeEntity c set c.consumedAt = :now, c.providerStatus = 'superseded'
|
||||
where c.phoneHmac = :phone and c.consumedAt is null and c.expiresAt > :now
|
||||
""").setParameter("now", now).setParameter("phone", phoneHmac).executeUpdate();
|
||||
update OtpChallengeEntity c set c.challengeStatus = 'superseded'
|
||||
where c.phoneHmac = :phone and c.challengeStatus in ('active', 'ordering')
|
||||
""").setParameter("phone", phoneHmac).executeUpdate();
|
||||
|
||||
OtpChallengeEntity challenge = new OtpChallengeEntity();
|
||||
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.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.expiresAt = now.plus(Config.OTP_TTL);
|
||||
challenge.expiresAt = now.plusSeconds(settings.ttlSeconds());
|
||||
challenge.verifyAttempts = 0;
|
||||
challenge.maxVerifyAttempts = limits.maxVerifyAttempts();
|
||||
challenge.settingsVersion = limits.version();
|
||||
challenge.providerId = "mock-" + Crypto.randomId();
|
||||
challenge.providerStatus = "accepted";
|
||||
challenge.maxVerifyAttempts = settings.maxVerifyAttempts();
|
||||
challenge.settingsVersion = settings.version();
|
||||
challenge.deliveryMode = Config.MOCK_ENABLED ? "mock" : "sms";
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
|
||||
Instant now = Instant.now();
|
||||
if (challenge == null || challenge.consumedAt != null || !challenge.expiresAt.isAfter(now)) {
|
||||
if (challenge != null) event("otp_verify", challenge.phoneHmac, challengeId, "failure", "expired_or_used");
|
||||
if (challenge == null) return false;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
challenge.verifyAttempts++;
|
||||
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
|
||||
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
|
||||
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;
|
||||
}
|
||||
challenge.consumedAt = now;
|
||||
challenge.providerStatus = "consumed";
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, "success", "verified");
|
||||
challenge.challengeStatus = "consumed";
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
|
||||
"success", "verified", device);
|
||||
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();
|
||||
event.id = Crypto.randomId();
|
||||
event.occurredAt = Instant.now();
|
||||
event.eventType = type;
|
||||
event.phoneHmac = phoneHmac;
|
||||
event.challengeId = challengeId;
|
||||
event.smsMessageId = smsMessageId;
|
||||
event.outcome = outcome;
|
||||
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);
|
||||
}
|
||||
|
||||
record Reservation(OtpChallengeEntity challenge, String otp) {}
|
||||
|
||||
static final class OtpLimitException extends RuntimeException {
|
||||
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 CHALLENGE_NOTE = "han.otp.challenge";
|
||||
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();
|
||||
|
||||
@Override
|
||||
public void authenticate(AuthenticationFlowContext context) {
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) {
|
||||
context.success();
|
||||
return;
|
||||
}
|
||||
context.challenge(context.form().createForm("phone.ftl"));
|
||||
context.challenge(phoneForm(context, null, device));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
try {
|
||||
String phone = normalizer.normalize(rawPhone);
|
||||
SettingsBridge.Limits limits = SettingsBridge.get();
|
||||
var challenge = new OtpStore(context.getSession()).reserve(phone, limits);
|
||||
SettingsBridge.Settings settings = SettingsBridge.get();
|
||||
var challenge = OtpFlow.start(context, phone, settings, device);
|
||||
context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone);
|
||||
context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id);
|
||||
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();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
Response response = context.form().setError("phoneInvalid").createForm("phone.ftl");
|
||||
Response response = phoneForm(context, "phoneInvalid", device);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
|
||||
} 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);
|
||||
} catch (RuntimeException exception) {
|
||||
Response response = context.form().setError("otpUnavailable").createForm("phone.ftl");
|
||||
Response response = phoneForm(context, "otpUnavailable", device);
|
||||
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 configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
|
||||
@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()
|
||||
.getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE);
|
||||
context.challenge(context.form().setAttribute("maskedPhone", masked).createForm("otp.ftl"));
|
||||
context.challenge(otpForm(context, masked, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -27,16 +27,37 @@ public final class PhoneOtpAuthenticator implements Authenticator {
|
||||
String challengeId = context.getAuthenticationSession()
|
||||
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
|
||||
String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE);
|
||||
String action = context.getHttpRequest().getDecodedFormParameters().getFirst("otp_action");
|
||||
String code = context.getHttpRequest().getDecodedFormParameters().getFirst("otp");
|
||||
if (challengeId == null || phone == null) {
|
||||
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
|
||||
return;
|
||||
}
|
||||
if (!new OtpStore(context.getSession()).consume(challengeId, code)) {
|
||||
Response response = context.form()
|
||||
.setAttribute("maskedPhone", PhoneNormalizer.mask(phone))
|
||||
.setError("otpInvalid")
|
||||
.createForm("otp.ftl");
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
if ("resend".equals(action)) {
|
||||
try {
|
||||
var challenge = OtpFlow.start(context, phone, SettingsBridge.get(), device);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -62,6 +83,26 @@ public final class PhoneOtpAuthenticator implements Authenticator {
|
||||
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 configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
|
||||
@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.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.timer.TimerProvider;
|
||||
|
||||
public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory {
|
||||
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 String getHelpText() { return "Verifies and atomically consumes a durable phone OTP challenge."; }
|
||||
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
|
||||
@Override public void init(Config.Scope config) {
|
||||
if (!ru.han.chat.keycloak.Config.MOCK_ENABLED) {
|
||||
throw new IllegalStateException("OTP delivery provider is not configured");
|
||||
@Override public void init(Config.Scope config) { ru.han.chat.keycloak.Config.validate(); }
|
||||
@Override
|
||||
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() {}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,25 @@ final class SettingsBridge {
|
||||
.connectTimeout(Duration.ofSeconds(2)).build();
|
||||
private static volatile Cached cached;
|
||||
|
||||
record Limits(int maxSendsPer24h, int minSecondsBetween, int maxVerifyAttempts, String version) {}
|
||||
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {}
|
||||
record Settings(
|
||||
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() {}
|
||||
|
||||
static Limits get() {
|
||||
static Settings get() {
|
||||
Cached local = cached;
|
||||
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) {
|
||||
local = cached;
|
||||
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
|
||||
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
|
||||
try {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL)
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
@@ -38,27 +45,35 @@ final class SettingsBridge {
|
||||
if (local != null && local.etag != null) builder.header("If-None-Match", local.etag);
|
||||
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 304 && local != null) {
|
||||
cached = new Cached(local.limits, now, now.plusSeconds(60), local.etag);
|
||||
return local.limits;
|
||||
cached = new Cached(local.settings, now, now.plusSeconds(60), local.etag);
|
||||
return local.settings;
|
||||
}
|
||||
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
|
||||
int max = integer(response.body(), "max_send_attempts_per_24h");
|
||||
int minimum = integer(response.body(), "min_seconds_between_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");
|
||||
String version = string(response.body(), "version");
|
||||
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");
|
||||
}
|
||||
Limits limits = new Limits(max, minimum, maxVerify, version);
|
||||
cached = new Cached(limits, now, now.plusSeconds(ttl),
|
||||
Settings settings = new Settings(
|
||||
max, minimum, maxVerify, codeLength, otpTtl, orderTimeout, version);
|
||||
cached = new Cached(settings, now, now.plusSeconds(ttl),
|
||||
response.headers().firstValue("ETag").orElse(null));
|
||||
return limits;
|
||||
return settings;
|
||||
} catch (Exception exception) {
|
||||
if (local != null && now.isBefore(local.fetchedAt.plus(Config.SETTINGS_MAX_STALE))) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.Version;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "han_otp_challenge")
|
||||
@@ -20,7 +21,13 @@ public class OtpChallengeEntity {
|
||||
@Column(name = "verify_attempts", nullable = false) public int verifyAttempts;
|
||||
@Column(name = "max_verify_attempts", nullable = false) public int maxVerifyAttempts;
|
||||
@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_status", nullable = false, length = 32) public String providerStatus;
|
||||
@Column(name = "provider_id", length = 128) public String providerId;
|
||||
@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;
|
||||
}
|
||||
|
||||
+13
@@ -5,6 +5,8 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.hibernate.annotations.ColumnTransformer;
|
||||
|
||||
@Entity
|
||||
@Table(name = "han_otp_security_event")
|
||||
@@ -16,4 +18,15 @@ public class OtpSecurityEventEntity {
|
||||
@Column(name = "challenge_id", length = 32) public String challengeId;
|
||||
@Column(name = "outcome", nullable = false, length = 32) public String outcome;
|
||||
@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"/>
|
||||
</createIndex>
|
||||
</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>
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.han.chat.keycloak;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -19,4 +20,12 @@ class CryptoTest {
|
||||
assertTrue(Crypto.constantTimeEquals("same-value", "same-value"));
|
||||
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=Тестовый режим отправки кода
|
||||
phoneInvalid=Проверьте формат номера телефона.
|
||||
otpInvalid=Код неверен, истёк или уже использован.
|
||||
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
|
||||
otpLimited=Слишком много попыток. Повторите позже.
|
||||
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
|
||||
|
||||
@@ -16,8 +16,15 @@
|
||||
|
||||
<form id="kc-otp-form" action="${url.loginAction}" method="post">
|
||||
<input id="otp" name="otp" type="hidden" value=""/>
|
||||
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>">
|
||||
<#list 0..5 as index>
|
||||
<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 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"
|
||||
aria-label="${msg("otpDigit", index + 1)}"
|
||||
<#if index == 0>autocomplete="one-time-code" autofocus</#if>
|
||||
@@ -33,8 +40,10 @@
|
||||
</#if>
|
||||
|
||||
<div class="han-resend">
|
||||
<p id="han-resend-countdown">${msg("otpResendCountdown")} <strong>0:59</strong></p>
|
||||
<button id="han-resend-button" type="button" hidden onclick="window.history.back()">
|
||||
<p id="han-resend-countdown" data-expires-at="${(otpExpiresAt!0)?c}">
|
||||
${msg("otpResendCountdown")} <strong>—</strong>
|
||||
</p>
|
||||
<button id="han-resend-button" type="submit" name="otp_action" value="resend" hidden>
|
||||
<span aria-hidden="true">↻</span>
|
||||
<span>${msg("otpResend")}</span>
|
||||
</button>
|
||||
@@ -45,6 +54,6 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
|
||||
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label for="phone">${msg("phoneLabel")}</label>
|
||||
<input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel"
|
||||
@@ -46,6 +52,6 @@
|
||||
<span>${msg("privacyPolicy")}</span>
|
||||
</p>
|
||||
</div>
|
||||
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
|
||||
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
(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() {
|
||||
var input = document.getElementById("phone");
|
||||
var submit = document.getElementById("han-phone-submit");
|
||||
@@ -81,23 +107,31 @@
|
||||
|
||||
syncOtp();
|
||||
|
||||
var seconds = 59;
|
||||
var countdown = document.getElementById("han-resend-countdown");
|
||||
var countdownValue = countdown && countdown.querySelector("strong");
|
||||
var resend = document.getElementById("han-resend-button");
|
||||
if (!countdown || !countdownValue || !resend) return;
|
||||
|
||||
var timer = window.setInterval(function () {
|
||||
seconds -= 1;
|
||||
countdownValue.textContent = "0:" + String(seconds).padStart(2, "0");
|
||||
var expiresAt = Number(countdown.getAttribute("data-expires-at"));
|
||||
if (!Number.isFinite(expiresAt)) expiresAt = Date.now();
|
||||
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) {
|
||||
window.clearInterval(timer);
|
||||
if (timer) window.clearInterval(timer);
|
||||
countdown.hidden = true;
|
||||
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();
|
||||
initOtpForm();
|
||||
})();
|
||||
|
||||
@@ -12,11 +12,12 @@ services:
|
||||
NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m}
|
||||
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_POLLING: ${NGINX_RATE_LIMIT_POLLING:-60r/m}
|
||||
NGINX_RATE_LIMIT_DOWNLOADS: ${NGINX_RATE_LIMIT_DOWNLOADS:-30r/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_MESSAGE_READ_TIMEOUT_SEC: ${NGINX_MESSAGE_READ_TIMEOUT_SEC:-330}
|
||||
FRONTEND_DEV_PROXY_ENABLED: ${FRONTEND_DEV_PROXY_ENABLED:-false}
|
||||
@@ -37,6 +38,7 @@ services:
|
||||
frontend-static: {condition: service_completed_successfully}
|
||||
api-backend: {condition: service_healthy}
|
||||
keycloak: {condition: service_healthy}
|
||||
sms-service: {condition: service_healthy}
|
||||
bitrix-local-app: {condition: service_healthy}
|
||||
bitrix-sync: {condition: service_healthy}
|
||||
healthcheck:
|
||||
|
||||
@@ -50,6 +50,7 @@ http {
|
||||
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=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_conn_zone $binary_remote_addr zone=connections:10m;
|
||||
|
||||
@@ -61,6 +62,7 @@ http {
|
||||
|
||||
upstream api_backend { server api-backend:8000; keepalive 32; }
|
||||
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_sync_upstream { server bitrix-sync:8080; keepalive 8; }
|
||||
upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
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
|
||||
eval "value=\${$name:-}"
|
||||
if [ -z "$value" ]; then
|
||||
@@ -24,7 +24,7 @@ if [ "${FRONTEND_DEV_PROXY_ENABLED:-false}" = "true" ] \
|
||||
fi
|
||||
|
||||
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}'
|
||||
security_vars='${NGINX_HSTS_MAX_AGE} ${S3_CONNECT_SRC}'
|
||||
|
||||
|
||||
@@ -120,6 +120,20 @@ server {
|
||||
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 {
|
||||
limit_req zone=bitrix_callbacks burst=60 nodelay;
|
||||
include /etc/nginx/snippets/proxy-common.conf;
|
||||
|
||||
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
|
||||
REQUIRED = {
|
||||
"APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "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",
|
||||
"KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL",
|
||||
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL",
|
||||
@@ -18,6 +18,9 @@ REQUIRED = {
|
||||
"BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN",
|
||||
"BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN",
|
||||
"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",
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
"SELECTEL_S3_ENDPOINT_URL",
|
||||
@@ -29,7 +32,10 @@ REQUIRED = {
|
||||
SECRET_KEYS = {
|
||||
key for key in REQUIRED
|
||||
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)
|
||||
|
||||
|
||||
@@ -64,16 +70,25 @@ def main() -> int:
|
||||
value = env.get(key, "")
|
||||
if value and (len(value) < 16 or PLACEHOLDER.search(value)):
|
||||
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"}
|
||||
if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false":
|
||||
errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false")
|
||||
if production and env.get("NGINX_TLS_ENABLED", "").lower() != "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":
|
||||
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, ""))
|
||||
if parsed.scheme != "http" or "." in (parsed.hostname or ""):
|
||||
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}")
|
||||
for key in (
|
||||
"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, "")
|
||||
if "sslmode=verify-full" not in value or "sslrootcert=" not in value:
|
||||
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 = (
|
||||
("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"),
|
||||
("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"),
|
||||
("KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN"),
|
||||
)
|
||||
for left, right in pairs:
|
||||
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 env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true":
|
||||
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"}:
|
||||
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")
|
||||
self.assertIn("networks: [backend, observability, egress]", 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")
|
||||
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")
|
||||
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/resources/", 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("X-Frame-Options", proxy_keycloak)
|
||||
|
||||
@@ -134,6 +144,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertIn("frontend-test-site", application)
|
||||
self.assertIn("frontend-static:/output", application)
|
||||
for service, command in (
|
||||
("sms-worker:", "han-sms-worker"),
|
||||
("delivery-worker:", "han-delivery-worker"),
|
||||
("safety-recovery-worker:", "han-safety-worker"),
|
||||
("cleanup-worker:", "han-cleanup-worker"),
|
||||
@@ -169,10 +180,13 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
"api-backend/alembic/env.py",
|
||||
"bitrix-local-app/alembic/env.py",
|
||||
"bitrix-sync/alembic/env.py",
|
||||
"sms-service/migrations/env.py",
|
||||
):
|
||||
env_script = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
self.assertIn('.replace("%", "%%")', 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:
|
||||
initial = (
|
||||
@@ -185,7 +199,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertIn("public.digest(", initial)
|
||||
self.assertIn("public.digest(", 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:
|
||||
migration = (
|
||||
@@ -212,10 +226,11 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
"KEYCLOAK_OTP_MOCK_ENABLED",
|
||||
"KEYCLOAK_OTP_MOCK_CODE",
|
||||
"KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_OTP_TTL_SEC",
|
||||
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
"KEYCLOAK_SMS_SERVICE_URL",
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN",
|
||||
):
|
||||
self.assertIn(f" {variable}:", application)
|
||||
|
||||
@@ -229,6 +244,8 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
"CURSOR_HMAC_SECRET=",
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY=",
|
||||
"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)
|
||||
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.
|
||||
|
||||
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. Чат
|
||||
|
||||
@@ -185,6 +187,7 @@ Presigned URL не сохраняется и редактируется из д
|
||||
| 422 blocked | нейтральное сообщение, контент не отправлен |
|
||||
| 429 | countdown по `Retry-After` |
|
||||
| 503/504 | зависимость недоступна; retry с тем же key |
|
||||
| OTP invalid/expired/superseded/limited | показать соответствующий безопасный Keycloak UX; generic order unavailable не раскрывает provider |
|
||||
| S3 PUT error | оставить attachment intent, предложить повтор |
|
||||
| WS failure | polling badge, чат остаётся usable |
|
||||
|
||||
@@ -245,7 +248,7 @@ Production: статический export монтируется в корнев
|
||||
| Сценарий | Варианты |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| text safety | allow, block, pending-to-final, timeout |
|
||||
| file | PDF/image allow, deny, wrong MIME/size/checksum, expired URL |
|
||||
@@ -253,8 +256,9 @@ Production: статический export монтируется в корнев
|
||||
| concurrency | два send click, несколько 401, две вкладки |
|
||||
| profile | filled/null fields, empty documents, download failure |
|
||||
| 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
|
||||
|
||||
@@ -271,6 +275,7 @@ Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный K
|
||||
- accessibility checks и keyboard сценарии проходят;
|
||||
- unit/component/contract/E2E matrix зелёная;
|
||||
- production static и dev proxy режимы проверены через единственный nginx.
|
||||
- frontend bundle/config/analytics не содержит raw OTP, `sms-service`/Direct credentials или provider status; resend/expiry/limits проверены для real-mode контракта.
|
||||
|
||||
## 18. Решения, допущения и TBD
|
||||
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
|---|---|---|
|
||||
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
||||
| `/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/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
||||
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
||||
| `/` | 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
|
||||
|
||||
Именованные 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.
|
||||
|
||||
@@ -100,6 +101,7 @@ traceparent: входной валидный либо новый согласн
|
||||
| обычный API | 3s / 30s / 30s |
|
||||
| auth | 3s / 30s / 60s |
|
||||
| Bitrix callback | 3s / 30s / 60s |
|
||||
| Direct SMS callback | 3s / 30s / 60s |
|
||||
| WS | 3s / 30s / 75s+ |
|
||||
| message POST | 3s / 30s / `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s` минимум |
|
||||
|
||||
@@ -117,6 +119,7 @@ traceparent: входной валидный либо новый согласн
|
||||
- `polling`: GET messages fallback;
|
||||
- `downloads`: issuance URL;
|
||||
- `bitrix_callbacks`: мягкий burst для повторов;
|
||||
- `idgtl_callbacks`: отдельный bounded burst, учитывающий повтор каждые 5 минут в течение суток;
|
||||
- `ws_connect`: handshake;
|
||||
- `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.
|
||||
|
||||
### 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
|
||||
|
||||
- внутренний `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;
|
||||
- upstream down/timeout, failed reload, renewal rehearsal;
|
||||
- logs не содержат secrets/query tokens.
|
||||
- allowed Direct callback проходит; wrong IP/method и любой `/internal/sms/*` отклоняются; Authorization отсутствует в логах.
|
||||
|
||||
## 19. Definition of Done
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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).
|
||||
|
||||
## 1. Назначение и границы
|
||||
@@ -12,7 +12,7 @@ Keycloak отвечает за:
|
||||
- realm, users, credentials, auth sessions и token lifecycle;
|
||||
- Authorization Code Flow with PKCE для Expo web/iOS/Android;
|
||||
- нормализацию/уникальность телефона и claims;
|
||||
- OTP authenticator/SPI, mock verification и продуктовые limits;
|
||||
- OTP authenticator/SPI, генерацию и локальную проверку OTP, challenge lifecycle, продуктовые limits и verify audit;
|
||||
- brute-force, sessions, logout/revocation;
|
||||
- keys/JWKS rotation и health/metrics.
|
||||
|
||||
@@ -22,9 +22,10 @@ Keycloak отвечает за:
|
||||
- App DB/profile/chat и CRM sync;
|
||||
- API service-to-service tokens;
|
||||
- пользовательскую 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
|
||||
|
||||
@@ -130,9 +131,9 @@ Internal API по-прежнему используют service tokens из arch
|
||||
3. `Phone Identity Authenticator` нормализует номер.
|
||||
4. Проверяются realm brute-force и product send limits.
|
||||
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.
|
||||
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.
|
||||
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 нет.
|
||||
|
||||
## 7. Mock OTP
|
||||
## 7. Mock и real delivery mode
|
||||
|
||||
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;
|
||||
- code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit;
|
||||
- сравнение constant-time;
|
||||
- challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production;
|
||||
- code не сохраняется per-user в открытом виде;
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -218,14 +219,14 @@ interface OtpDeliveryProvider {
|
||||
|
||||
- challenge id random ≥128 bit;
|
||||
- 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;
|
||||
- max verification attempts per challenge;
|
||||
- resend invalidates либо version-binds предыдущий challenge;
|
||||
- resend всегда переводит предыдущий `active`/`ordering` challenge в `superseded`;
|
||||
- replay/parallel verify безопасны;
|
||||
- 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
|
||||
|
||||
@@ -242,6 +243,10 @@ Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||
{
|
||||
"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-10T08:00:00Z",
|
||||
"cache_ttl_seconds": 60
|
||||
}
|
||||
@@ -270,11 +275,27 @@ Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/in
|
||||
|
||||
Минимальные 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_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
|
||||
|
||||
@@ -472,15 +493,24 @@ KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=<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
|
||||
```
|
||||
|
||||
Дополнительные 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
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_OTP_HMAC_KEY=<secret>
|
||||
```
|
||||
@@ -494,7 +524,7 @@ Keycloak management health endpoints включены. Compose проверяе
|
||||
- realm/client/auth flow/provider loaded;
|
||||
- active signing key;
|
||||
- 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.
|
||||
|
||||
@@ -527,7 +557,7 @@ Keycloak access log должен редактировать sensitive query. TRA
|
||||
- active sessions/token refresh/error;
|
||||
- DB pool/JVM/GC/HTTP;
|
||||
- JWKS/key age;
|
||||
- provider mode info (`mock`, later vendor), без phone labels.
|
||||
- delivery mode info (`mock`, `sms`) и provider dependency `idgtl`, без phone labels.
|
||||
|
||||
### 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 empty/stale | new OTP send fail-closed |
|
||||
| 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 |
|
||||
| too many sends/verifies | temporary reject/lockout, safe UX |
|
||||
| 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;
|
||||
- phone HMAC/counter cleanup;
|
||||
- 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
|
||||
|
||||
@@ -629,6 +664,8 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
### E2E
|
||||
|
||||
- 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;
|
||||
- expired/revoked/rotated refresh → re-auth;
|
||||
- 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;
|
||||
- claims соответствуют module-01 (`sub`, `phone_number`, audience);
|
||||
- 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;
|
||||
- brute-force, TTL, verify attempts и enumeration protection работают;
|
||||
- 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;
|
||||
- container hardening/root Compose без published port;
|
||||
- test matrix зелёная;
|
||||
- реальный SMS явно остаётся extension point, не скрытой заглушкой.
|
||||
- mock и real mutually exclusive; real mode вызывает только durable-order API `sms-service`, Direct/Verifier/status polling отсутствуют.
|
||||
|
||||
## 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.
|
||||
- K6: product limits только `/internal/settings/v1/otp` + `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
**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-TBD6: signing-key rotation interval/HSM и emergency revocation.
|
||||
- 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-TBD10: добавить proposed OTP technical env в arch-04 до реализации.
|
||||
|
||||
@@ -39,6 +39,9 @@ Placeholders:
|
||||
<RELEASE> immutable tag/git SHA
|
||||
<ACME_EMAIL> адрес ops, не placeholder в реальном запуске
|
||||
<BITRIX_PORTAL> разрешённый портал
|
||||
<IDGTL_SENDER_NAME> согласованное в Direct имя отправителя
|
||||
<IDGTL_STATIC_EGRESS_IP> фактический статический egress IP `sms-worker`
|
||||
<IDGTL_TEST_PHONE> контролируемый номер для provider smoke
|
||||
```
|
||||
|
||||
## 3. Stage 0 — решения до provisioning
|
||||
@@ -72,7 +75,7 @@ Placeholders:
|
||||
- [ ] RPO/RTO приняты хотя бы временно: ориентир RPO PG ≤15 минут/PITR, RTO ≤4 часа.
|
||||
- [ ] Решено: images pull из registry или build на VM.
|
||||
- [ ] Remote telemetry backend выбран либо явно принят ограниченный debug-only режим.
|
||||
- [ ] Риск mock OTP и Safety stub письменно принят.
|
||||
- [ ] Риск mock OTP до SMS cutover и Safety stub письменно принят; real SMS не включается без gates module-11.
|
||||
|
||||
**Ожидаемый результат:** есть release checklist с конкретными values; не создано ни одной публичной БД/Redis.
|
||||
|
||||
@@ -90,7 +93,7 @@ Security groups:
|
||||
| internet | VM | TCP 80 | allow для redirect/ACME |
|
||||
| internet | VM | TCP 443 | 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 | 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 адаптировать:
|
||||
|
||||
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;
|
||||
3. создать migration roles либо controlled admin job;
|
||||
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`;
|
||||
3. `message-safety` stub не создаёт PG tables до production implementation;
|
||||
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.
|
||||
|
||||
### Gate 3
|
||||
|
||||
- [ ] Backups/PITR/TLS/deletion protection включены.
|
||||
- [ ] Пять schemas/roles созданы.
|
||||
- [ ] Шесть schemas/roles созданы, включая `sms`/`sms_user`.
|
||||
- [ ] Runtime roles не имеют DDL/чужого доступа.
|
||||
- [ ] Migration credentials отделены от runtime.
|
||||
- [ ] Empty/previous-version migration test успешен.
|
||||
@@ -366,6 +370,7 @@ openssl rand -hex 32
|
||||
- Redis ACL credentials/URLs DB0/1/2;
|
||||
- public web/API/auth URLs;
|
||||
- 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;
|
||||
- Bitrix client/application/webhook/encryption secrets;
|
||||
- S3 endpoint/buckets/API and read-only Safety credentials;
|
||||
@@ -378,6 +383,7 @@ openssl rand -hex 32
|
||||
```text
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN
|
||||
BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN
|
||||
KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN
|
||||
```
|
||||
|
||||
Service token и webhook token — разные secrets.
|
||||
@@ -398,6 +404,7 @@ Service token и webhook token — разные secrets.
|
||||
- Safety timeout согласован с nginx;
|
||||
- secrets minimum length;
|
||||
- mock OTP risk flag explicitly accepted.
|
||||
- placeholders `change-me`/`<...>` запрещены; real mode требует sender/template/API key/callback credentials и recorded static egress IP;
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
@@ -466,12 +473,13 @@ cd <BACKEND_ROOT>
|
||||
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:
|
||||
|
||||
- `public`: nginx и минимально Keycloak/frontend path;
|
||||
- `backend`: internal services/Redis;
|
||||
- `egress`: только утверждённые outbound workers; Keycloak в неё не входит, `sms-worker` входит;
|
||||
- `observability`: services + Collector.
|
||||
|
||||
Volumes:
|
||||
@@ -661,6 +669,25 @@ docker compose run --rm api-backend python -m app.cli.validate_settings
|
||||
- [ ] Mandatory settings valid; secrets отсутствуют в `app_settings`.
|
||||
- [ ] 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.1. Первый старт
|
||||
@@ -713,22 +740,25 @@ Custom OTP tables мигрируются versioned mechanism до включен
|
||||
Архитектурный порядок:
|
||||
|
||||
1. Redis;
|
||||
2. Keycloak;
|
||||
3. OTEL Collector;
|
||||
4. Message Safety;
|
||||
5. API backend;
|
||||
6. Bitrix local app;
|
||||
7. Bitrix sync;
|
||||
8. nginx.
|
||||
2. OTEL Collector;
|
||||
3. API backend/settings;
|
||||
4. SMS service/worker после migrations (при SMS release; Keycloak пока mock);
|
||||
5. Keycloak;
|
||||
6. Message Safety;
|
||||
7. Bitrix local app;
|
||||
8. Bitrix sync;
|
||||
9. nginx.
|
||||
|
||||
Команды:
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d otel-collector
|
||||
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 nginx
|
||||
docker compose ps
|
||||
@@ -827,6 +857,7 @@ Expected: 308; public 200 strict DTO; discovery 200; internal 404; valid cert.
|
||||
- silent refresh работает без OTP;
|
||||
- logout очищает 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
|
||||
|
||||
@@ -955,13 +986,14 @@ DB backup включает realm/users/signing keys/provider data. Secret-free r
|
||||
### Application-only
|
||||
|
||||
1. объявить incident/maintenance;
|
||||
2. сохранить diagnostics и current state;
|
||||
3. остановить новые claims/send при возможности;
|
||||
4. переключить image tags на previous digests;
|
||||
5. не выполнять Alembic downgrade;
|
||||
6. `docker compose up -d`;
|
||||
7. health/smoke/idempotency;
|
||||
8. проверить outbox/inbox/recovery.
|
||||
2. при SMS incident вернуть `KEYCLOAK_OTP_MOCK_ENABLED=true`, прекратить новые real orders и сохранить journal/in-flight state;
|
||||
3. сохранить diagnostics и current state;
|
||||
4. остановить новые claims/send при возможности;
|
||||
5. переключить image tags на previous digests;
|
||||
6. не выполнять Alembic downgrade;
|
||||
7. `docker compose up -d`;
|
||||
8. health/smoke/idempotency;
|
||||
9. проверить outbox/inbox/SMS pending/uncertain/recovery.
|
||||
|
||||
### После backward-incompatible migration
|
||||
|
||||
@@ -1162,7 +1194,7 @@ certbot delete active cert
|
||||
- D-A2: обязательный минимум — `otel-collector`; доступность remote backend не предполагается до закрытия D-TBD11, local Grafana stack не обязателен.
|
||||
- D-A3: managed provider даёт private network, TLS, backups/PITR.
|
||||
- D-A4: Bitrix portal/connector/line остаются разрешёнными значениями architecture.
|
||||
- D-A5: mock OTP временно разрешён как documented risk.
|
||||
- D-A5: mock OTP временно разрешён до controlled SMS cutover как documented risk.
|
||||
|
||||
### TBD до production
|
||||
|
||||
@@ -1186,8 +1218,9 @@ certbot delete active cert
|
||||
4. Прототипные TLS scripts используют отдельный service Compose/standalone downtime, тогда как целевая архитектура требует root Compose и two-phase webroot.
|
||||
5. Prototype публиковал `/bitrix-internal/*` и использовал `/internal/v1/*`; целевой контур это запрещает и использует `/internal/openlines/v1/*`.
|
||||
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 обязателен.
|
||||
9. Текущие Compose/env/config artifacts могут ещё не содержать `sms-service`; документация не разрешает real mode до реализации и прохождения rollout gates.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
# КОнтроль места
|
||||
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
|
||||
|
||||
#Обновление проекта
|
||||
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
|
||||
rm han-chat-backend.tar.gz
|
||||
cd /opt/han-chat/backend
|
||||
|
||||
rm C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz
|
||||
#команда складывает архив в ту папку, из которой запускается команда
|
||||
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 .
|
||||
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 message_safety_app;
|
||||
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 и др.
|
||||
@@ -169,6 +221,12 @@ REVOKE ALL ON SCHEMA keycloak FROM PUBLIC;
|
||||
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO keycloak;
|
||||
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
|
||||
SELECT r.rolname, d.datname, s.setconfig
|
||||
FROM pg_db_role_setting s
|
||||
@@ -449,3 +507,28 @@ request = urllib.request.Request(
|
||||
)
|
||||
print(urllib.request.urlopen(request).read().decode())
|
||||
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