From b1ed714d5beacdff32c95bcff9c07a702a0f5b1c Mon Sep 17 00:00:00 2001 From: mi Date: Thu, 23 Jul 2026 11:49:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20=D0=A1=D0=9C=D0=A1=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architectory/README.md | 2 +- architectory/arch-00-glossary.md | 23 +- architectory/arch-01-system-architecture.md | 38 +- architectory/arch-02-api-contracts.md | 60 +- .../arch-03-docker-compose-blueprint.md | 38 +- architectory/arch-04-settings-and-content.md | 54 +- backlog.md | 17 +- codebase/backend/.env.example | 18 +- .../versions/0005_otp_runtime_settings.py | 37 + .../api-backend/app/cli/seed_settings.py | 6 + codebase/backend/api-backend/app/main.py | 13 +- .../backend/api-backend/app/otp_settings.py | 44 + codebase/backend/api-backend/app/schemas.py | 11 + codebase/backend/api-backend/app/services.py | 30 +- codebase/backend/api-backend/openapi.yaml | 28 +- .../tests/contract/test_openapi.py | 70 +- .../tests/unit/test_cli_settings.py | 38 + .../backend/deployment/DEPLOYMENT_GUIDE.ru.md | 6 +- codebase/backend/deployment/RUNBOOK.md | 6 + codebase/backend/deployment/RUNBOOK.ru.md | 6 + .../app-settings.production-like.yaml | 3 + .../deployment/docker-compose.jobs.yml | 30 + .../backend/deployment/scripts/migrate.sh | 2 + codebase/backend/deployment/scripts/smoke.sh | 6 + .../backend/frontend-test-site/src/auth.ts | 3 + .../frontend-test-site/src/oidc-device.ts | 94 ++ .../tests/unit/core.test.ts | 39 +- .../backend/infra/compose/application.yml | 81 +- codebase/backend/keycloak/.env.example | 3 +- codebase/backend/keycloak/Dockerfile | 1 + codebase/backend/keycloak/README.md | 20 +- codebase/backend/keycloak/docker-compose.yml | 5 +- .../java/ru/han/chat/keycloak/Config.java | 21 +- .../java/ru/han/chat/keycloak/Crypto.java | 11 + .../ru/han/chat/keycloak/DeviceMetadata.java | 66 ++ .../java/ru/han/chat/keycloak/OtpFlow.java | 38 + .../java/ru/han/chat/keycloak/OtpStore.java | 141 ++- .../keycloak/PhoneIdentityAuthenticator.java | 34 +- .../chat/keycloak/PhoneOtpAuthenticator.java | 53 +- .../PhoneOtpAuthenticatorFactory.java | 15 +- .../ru/han/chat/keycloak/SettingsBridge.java | 39 +- .../ru/han/chat/keycloak/SmsOrderClient.java | 109 +++ .../keycloak/entity/OtpChallengeEntity.java | 11 +- .../entity/OtpSecurityEventEntity.java | 13 + .../resources/META-INF/han-otp-changelog.xml | 61 ++ .../java/ru/han/chat/keycloak/CryptoTest.java | 9 + .../keycloak/SmsLifecycleContractTest.java | 41 + .../han/chat/keycloak/SmsOrderClientTest.java | 75 ++ .../login/messages/messages_ru.properties | 1 + .../keycloak/themes/han-phone/login/otp.ftl | 19 +- .../keycloak/themes/han-phone/login/phone.ftl | 8 +- .../han-phone/login/resources/js/han-login.js | 46 +- codebase/backend/nginx/docker-compose.yml | 4 +- codebase/backend/nginx/nginx.conf.template | 2 + codebase/backend/nginx/scripts/entrypoint.sh | 4 +- .../nginx/templates/site-tls.conf.template | 14 + codebase/backend/scripts/validate-env | 41 +- codebase/backend/sms-service/Dockerfile | 18 + codebase/backend/sms-service/alembic.ini | 38 + codebase/backend/sms-service/app/__init__.py | 1 + codebase/backend/sms-service/app/db.py | 253 +++++ codebase/backend/sms-service/app/domain.py | 154 +++ codebase/backend/sms-service/app/main.py | 322 +++++++ codebase/backend/sms-service/app/metrics.py | 39 + codebase/backend/sms-service/app/provider.py | 161 ++++ codebase/backend/sms-service/app/schemas.py | 93 ++ codebase/backend/sms-service/app/service.py | 308 ++++++ codebase/backend/sms-service/app/settings.py | 53 ++ codebase/backend/sms-service/app/worker.py | 217 +++++ .../backend/sms-service/migrations/env.py | 56 ++ .../migrations/versions/0001_initial.py | 231 +++++ .../migrations/versions/0002_seed.py | 108 +++ codebase/backend/sms-service/openapi.yaml | 314 +++++++ codebase/backend/sms-service/pyproject.toml | 59 ++ .../tests/contract/test_openapi.py | 25 + .../sms-service/tests/unit/test_auth.py | 35 + .../sms-service/tests/unit/test_domain.py | 85 ++ .../sms-service/tests/unit/test_provider.py | 90 ++ .../sms-service/tests/unit/test_schemas.py | 53 ++ codebase/backend/tests/test_config.py | 23 +- modules/module-02-frontend-test-site.md | 11 +- modules/module-03-nginx.md | 16 +- modules/module-08-keycloak.md | 94 +- modules/module-10-deployment-runbook.md | 81 +- modules/module-11-idgtl-sms.md | 889 ++++++++++++++++++ ops-monitoring/instructions.md | 18 + ops-monitoring/send_sms.md | 91 ++ .../#0 deploy-steps.md | 87 +- releases/#1 SMS OTP deploy.md | 405 ++++++++ 89 files changed, 5934 insertions(+), 202 deletions(-) create mode 100644 codebase/backend/api-backend/alembic/versions/0005_otp_runtime_settings.py create mode 100644 codebase/backend/api-backend/app/otp_settings.py create mode 100644 codebase/backend/frontend-test-site/src/oidc-device.ts create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/DeviceMetadata.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpFlow.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SmsOrderClient.java create mode 100644 codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java create mode 100644 codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsOrderClientTest.java create mode 100644 codebase/backend/sms-service/Dockerfile create mode 100644 codebase/backend/sms-service/alembic.ini create mode 100644 codebase/backend/sms-service/app/__init__.py create mode 100644 codebase/backend/sms-service/app/db.py create mode 100644 codebase/backend/sms-service/app/domain.py create mode 100644 codebase/backend/sms-service/app/main.py create mode 100644 codebase/backend/sms-service/app/metrics.py create mode 100644 codebase/backend/sms-service/app/provider.py create mode 100644 codebase/backend/sms-service/app/schemas.py create mode 100644 codebase/backend/sms-service/app/service.py create mode 100644 codebase/backend/sms-service/app/settings.py create mode 100644 codebase/backend/sms-service/app/worker.py create mode 100644 codebase/backend/sms-service/migrations/env.py create mode 100644 codebase/backend/sms-service/migrations/versions/0001_initial.py create mode 100644 codebase/backend/sms-service/migrations/versions/0002_seed.py create mode 100644 codebase/backend/sms-service/openapi.yaml create mode 100644 codebase/backend/sms-service/pyproject.toml create mode 100644 codebase/backend/sms-service/tests/contract/test_openapi.py create mode 100644 codebase/backend/sms-service/tests/unit/test_auth.py create mode 100644 codebase/backend/sms-service/tests/unit/test_domain.py create mode 100644 codebase/backend/sms-service/tests/unit/test_provider.py create mode 100644 codebase/backend/sms-service/tests/unit/test_schemas.py create mode 100644 modules/module-11-idgtl-sms.md create mode 100644 ops-monitoring/send_sms.md rename deploy-steps.md => releases/#0 deploy-steps.md (75%) create mode 100644 releases/#1 SMS OTP deploy.md diff --git a/architectory/README.md b/architectory/README.md index d581160..5b1b649 100644 --- a/architectory/README.md +++ b/architectory/README.md @@ -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 | diff --git a/architectory/arch-00-glossary.md b/architectory/arch-00-glossary.md index 20d6b22..4414d0f 100644 --- a/architectory/arch-00-glossary.md +++ b/architectory/arch-00-glossary.md @@ -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 diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index bff1368..dd7147c 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -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/ diff --git a/architectory/arch-02-api-contracts.md b/architectory/arch-02-api-contracts.md index 884127a..d3c9d87 100644 --- a/architectory/arch-02-api-contracts.md +++ b/architectory/arch-02-api-contracts.md @@ -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:", + "template_code": "auth_otp", + "locale": "ru", + "phone_e164": "+79001234567", + "substitutions": {"code": "", "ttl_min": ""}, + "customer_ref": "", + "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):** diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md index e087595..4821415 100644 --- a/architectory/arch-03-docker-compose-blueprint.md +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -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`. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index e877796..84eb4c6 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -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= +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`. + +`` — обязательный 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@::/ BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/ MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@:/ +SMS_DATABASE_URL=postgresql://sms_user:change-me@:/ KEYCLOAK_DB_URL=jdbc:postgresql://:/?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) diff --git a/backlog.md b/backlog.md index 6282dbb..9b1a348 100644 --- a/backlog.md +++ b/backlog.md @@ -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. Моделирование профиля клиента/ \ No newline at end of file +4. Моделирование профиля клиента/ +5. Моделирование уведомлений. + +На анализ: +debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts) \ No newline at end of file diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index b00395b..b809af1 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -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 diff --git a/codebase/backend/api-backend/alembic/versions/0005_otp_runtime_settings.py b/codebase/backend/api-backend/alembic/versions/0005_otp_runtime_settings.py new file mode 100644 index 0000000..2d8833c --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0005_otp_runtime_settings.py @@ -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") diff --git a/codebase/backend/api-backend/app/cli/seed_settings.py b/codebase/backend/api-backend/app/cli/seed_settings.py index 60154cd..e3b3909 100644 --- a/codebase/backend/api-backend/app/cli/seed_settings.py +++ b/codebase/backend/api-backend/app/cli/seed_settings.py @@ -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 diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index 76a5e2f..9cdfb92 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -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) diff --git a/codebase/backend/api-backend/app/otp_settings.py b/codebase/backend/api-backend/app/otp_settings.py new file mode 100644 index 0000000..00f7cec --- /dev/null +++ b/codebase/backend/api-backend/app/otp_settings.py @@ -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" + ) diff --git a/codebase/backend/api-backend/app/schemas.py b/codebase/backend/api-backend/app/schemas.py index 2c80763..5a5f1a0 100644 --- a/codebase/backend/api-backend/app/schemas.py +++ b/codebase/backend/api-backend/app/schemas.py @@ -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) diff --git a/codebase/backend/api-backend/app/services.py b/codebase/backend/api-backend/app/services.py index a86f6bb..a9f060c 100644 --- a/codebase/backend/api-backend/app/services.py +++ b/codebase/backend/api-backend/app/services.py @@ -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 = ( - await session.execute(select(AppSetting).where(AppSetting.record_status == "A")) - ).scalars() + 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) diff --git a/codebase/backend/api-backend/openapi.yaml b/codebase/backend/api-backend/openapi.yaml index 878cd68..393f423 100644 --- a/codebase/backend/api-backend/openapi.yaml +++ b/codebase/backend/api-backend/openapi.yaml @@ -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] diff --git a/codebase/backend/api-backend/tests/contract/test_openapi.py b/codebase/backend/api-backend/tests/contract/test_openapi.py index 9b5ee49..c18bfb3 100644 --- a/codebase/backend/api-backend/tests/contract/test_openapi.py +++ b/codebase/backend/api-backend/tests/contract/test_openapi.py @@ -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 diff --git a/codebase/backend/api-backend/tests/unit/test_cli_settings.py b/codebase/backend/api-backend/tests/unit/test_cli_settings.py index 8d03140..ca59886 100644 --- a/codebase/backend/api-backend/tests/unit/test_cli_settings.py +++ b/codebase/backend/api-backend/tests/unit/test_cli_settings.py @@ -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) diff --git a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md index 6a886da..ab52269 100644 --- a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md +++ b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md @@ -372,7 +372,7 @@ MESSAGE_SAFETY_REDIS_URL=redis://message_safety:@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 diff --git a/codebase/backend/deployment/RUNBOOK.md b/codebase/backend/deployment/RUNBOOK.md index 9aa8067..a161155 100644 --- a/codebase/backend/deployment/RUNBOOK.md +++ b/codebase/backend/deployment/RUNBOOK.md @@ -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. diff --git a/codebase/backend/deployment/RUNBOOK.ru.md b/codebase/backend/deployment/RUNBOOK.ru.md index d596c9d..60e9e65 100644 --- a/codebase/backend/deployment/RUNBOOK.ru.md +++ b/codebase/backend/deployment/RUNBOOK.ru.md @@ -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, чтобы сообщение diff --git a/codebase/backend/deployment/app-settings.production-like.yaml b/codebase/backend/deployment/app-settings.production-like.yaml index ef031f2..84c8974 100644 --- a/codebase/backend/deployment/app-settings.production-like.yaml +++ b/codebase/backend/deployment/app-settings.production-like.yaml @@ -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} diff --git a/codebase/backend/deployment/docker-compose.jobs.yml b/codebase/backend/deployment/docker-compose.jobs.yml index 7edeb51..789de1b 100644 --- a/codebase/backend/deployment/docker-compose.jobs.yml +++ b/codebase/backend/deployment/docker-compose.jobs.yml @@ -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 diff --git a/codebase/backend/deployment/scripts/migrate.sh b/codebase/backend/deployment/scripts/migrate.sh index ab37e40..9edd82a 100644 --- a/codebase/backend/deployment/scripts/migrate.sh +++ b/codebase/backend/deployment/scripts/migrate.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." diff --git a/codebase/backend/deployment/scripts/smoke.sh b/codebase/backend/deployment/scripts/smoke.sh index b9496b7..ffe96f6 100644 --- a/codebase/backend/deployment/scripts/smoke.sh +++ b/codebase/backend/deployment/scripts/smoke.sh @@ -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' diff --git a/codebase/backend/frontend-test-site/src/auth.ts b/codebase/backend/frontend-test-site/src/auth.ts index 61b3352..fd9b563 100644 --- a/codebase/backend/frontend-test-site/src/auth.ts +++ b/codebase/backend/frontend-test-site/src/auth.ts @@ -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); diff --git a/codebase/backend/frontend-test-site/src/oidc-device.ts b/codebase/backend/frontend-test-site/src/oidc-device.ts new file mode 100644 index 0000000..ef9d00b --- /dev/null +++ b/codebase/backend/frontend-test-site/src/oidc-device.ts @@ -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; + set(key: string, value: string): Promise; +}; + +export type OidcDeviceMetadata = Partial>; + +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 { + 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; + 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 } : {}), + }; +} diff --git a/codebase/backend/frontend-test-site/tests/unit/core.test.ts b/codebase/backend/frontend-test-site/tests/unit/core.test.ts index f4d46be..9cff8d2 100644 --- a/codebase/backend/frontend-test-site/tests/unit/core.test.ts +++ b/codebase/backend/frontend-test-site/tests/unit/core.test.ts @@ -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(); + 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", + }); + }); +}); diff --git a/codebase/backend/infra/compose/application.yml b/codebase/backend/infra/compose/application.yml index 0acd250..2304c46 100644 --- a/codebase/backend/infra/compose/application.yml +++ b/codebase/backend/infra/compose/application.yml @@ -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 diff --git a/codebase/backend/keycloak/.env.example b/codebase/backend/keycloak/.env.example index d36b39c..047fd9f 100644 --- a/codebase/backend/keycloak/.env.example +++ b/codebase/backend/keycloak/.env.example @@ -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 diff --git a/codebase/backend/keycloak/Dockerfile b/codebase/backend/keycloak/Dockerfile index 3a9f3bf..744c367 100644 --- a/codebase/backend/keycloak/Dockerfile +++ b/codebase/backend/keycloak/Dockerfile @@ -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 diff --git a/codebase/backend/keycloak/README.md b/codebase/backend/keycloak/README.md index 66ed83c..b9aafe1 100644 --- a/codebase/backend/keycloak/README.md +++ b/codebase/backend/keycloak/README.md @@ -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. diff --git a/codebase/backend/keycloak/docker-compose.yml b/codebase/backend/keycloak/docker-compose.yml index a24a47c..1242449 100644 --- a/codebase/backend/keycloak/docker-compose.yml +++ b/codebase/backend/keycloak/docker-compose.yml @@ -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} diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Config.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Config.java index ac3bafc..f4f9c1b 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Config.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Config.java @@ -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()) { diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Crypto.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Crypto.java index 96ec260..296bc89 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Crypto.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Crypto.java @@ -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"); diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/DeviceMetadata.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/DeviceMetadata.java new file mode 100644 index 0000000..a9bbdf3 --- /dev/null +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/DeviceMetadata.java @@ -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 PLATFORMS = Set.of("web", "ios", "android"); + + static DeviceMetadata capture(AuthenticationFlowContext context) { + MultivaluedMap form = context.getHttpRequest().getDecodedFormParameters(); + var session = context.getAuthenticationSession(); + MultivaluedMap 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 form, + MultivaluedMap 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; + } +} diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpFlow.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpFlow.java new file mode 100644 index 0000000..d5d5475 --- /dev/null +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpFlow.java @@ -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; + } +} diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java index 0ee3767..635da90 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java @@ -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()); + } } } diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java index b8f4f6d..0ea5c81 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java @@ -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) {} diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java index c4f0b56..c3d79c9 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java @@ -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) {} diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticatorFactory.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticatorFactory.java index e2d9f78..81b319d 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticatorFactory.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticatorFactory.java @@ -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 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() {} } diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SettingsBridge.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SettingsBridge.java index f988134..fb96a22 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SettingsBridge.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SettingsBridge.java @@ -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 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); } diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SmsOrderClient.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SmsOrderClient.java new file mode 100644 index 0000000..b8a4852 --- /dev/null +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SmsOrderClient.java @@ -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 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); } + } +} diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpChallengeEntity.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpChallengeEntity.java index 20f0a28..a77461d 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpChallengeEntity.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpChallengeEntity.java @@ -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; } diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpSecurityEventEntity.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpSecurityEventEntity.java index 6c470d4..a606081 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpSecurityEventEntity.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpSecurityEventEntity.java @@ -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; } diff --git a/codebase/backend/keycloak/src/main/resources/META-INF/han-otp-changelog.xml b/codebase/backend/keycloak/src/main/resources/META-INF/han-otp-changelog.xml index 07373fb..20ff0d5 100644 --- a/codebase/backend/keycloak/src/main/resources/META-INF/han-otp-changelog.xml +++ b/codebase/backend/keycloak/src/main/resources/META-INF/han-otp-changelog.xml @@ -50,4 +50,65 @@ + + + + + + + + + + + + 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); + + + + + + CREATE INDEX ix_han_otp_challenge_sms_message + ON han_otp_challenge (sms_message_id) WHERE sms_message_id IS NOT NULL; + + + + + + + + + + + + + + + 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; + + diff --git a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/CryptoTest.java b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/CryptoTest.java index fb75ec1..6d2e750 100644 --- a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/CryptoTest.java +++ b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/CryptoTest.java @@ -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)); + } } diff --git a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java new file mode 100644 index 0000000..63adda5 --- /dev/null +++ b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java @@ -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)")); + } +} diff --git a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsOrderClientTest.java b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsOrderClientTest.java new file mode 100644 index 0000000..fecb3c6 --- /dev/null +++ b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsOrderClientTest.java @@ -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); + } + } +} diff --git a/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties b/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties index a62c4ef..ee5a43e 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties +++ b/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties @@ -20,5 +20,6 @@ verifyOtp=Подтвердить mockMode=Тестовый режим отправки кода phoneInvalid=Проверьте формат номера телефона. otpInvalid=Код неверен, истёк или уже использован. +otpCooldown=Повторно отправить СМС можно после обнуления таймера. otpLimited=Слишком много попыток. Повторите позже. otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже. diff --git a/codebase/backend/keycloak/themes/han-phone/login/otp.ftl b/codebase/backend/keycloak/themes/han-phone/login/otp.ftl index e731d83..adc0949 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/otp.ftl +++ b/codebase/backend/keycloak/themes/han-phone/login/otp.ftl @@ -16,8 +16,15 @@
-
- <#list 0..5 as index> + + + + + + +
+ <#list 0..((otpCodeLength!6) - 1) as index> autocomplete="one-time-code" autofocus @@ -33,8 +40,10 @@
-

${msg("otpResendCountdown")} 0:59

- @@ -45,6 +54,6 @@
- + diff --git a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl index 5caf740..953e152 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl +++ b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl @@ -17,6 +17,12 @@
+ + + + + +
${msg("privacyPolicy")}

- + diff --git a/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js b/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js index 54c8eb7..b9e20b8 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js +++ b/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js @@ -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(); })(); diff --git a/codebase/backend/nginx/docker-compose.yml b/codebase/backend/nginx/docker-compose.yml index 6712349..7f62504 100644 --- a/codebase/backend/nginx/docker-compose.yml +++ b/codebase/backend/nginx/docker-compose.yml @@ -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: diff --git a/codebase/backend/nginx/nginx.conf.template b/codebase/backend/nginx/nginx.conf.template index 4e7c3cb..37c03d0 100644 --- a/codebase/backend/nginx/nginx.conf.template +++ b/codebase/backend/nginx/nginx.conf.template @@ -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; } diff --git a/codebase/backend/nginx/scripts/entrypoint.sh b/codebase/backend/nginx/scripts/entrypoint.sh index 29ef0e0..a8d05e5 100644 --- a/codebase/backend/nginx/scripts/entrypoint.sh +++ b/codebase/backend/nginx/scripts/entrypoint.sh @@ -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}' diff --git a/codebase/backend/nginx/templates/site-tls.conf.template b/codebase/backend/nginx/templates/site-tls.conf.template index ca2ce9a..fe15115 100644 --- a/codebase/backend/nginx/templates/site-tls.conf.template +++ b/codebase/backend/nginx/templates/site-tls.conf.template @@ -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; diff --git a/codebase/backend/scripts/validate-env b/codebase/backend/scripts/validate-env index 4a2cb13..dbfe0bb 100644 --- a/codebase/backend/scripts/validate-env +++ b/codebase/backend/scripts/validate-env @@ -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") diff --git a/codebase/backend/sms-service/Dockerfile b/codebase/backend/sms-service/Dockerfile new file mode 100644 index 0000000..3aade60 --- /dev/null +++ b/codebase/backend/sms-service/Dockerfile @@ -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"] diff --git a/codebase/backend/sms-service/alembic.ini b/codebase/backend/sms-service/alembic.ini new file mode 100644 index 0000000..8b800ec --- /dev/null +++ b/codebase/backend/sms-service/alembic.ini @@ -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 diff --git a/codebase/backend/sms-service/app/__init__.py b/codebase/backend/sms-service/app/__init__.py new file mode 100644 index 0000000..cbcffb4 --- /dev/null +++ b/codebase/backend/sms-service/app/__init__.py @@ -0,0 +1 @@ +"""HAN SMS service.""" diff --git a/codebase/backend/sms-service/app/db.py b/codebase/backend/sms-service/app/db.py new file mode 100644 index 0000000..863eabc --- /dev/null +++ b/codebase/backend/sms-service/app/db.py @@ -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() diff --git a/codebase/backend/sms-service/app/domain.py b/codebase/backend/sms-service/app/domain.py new file mode 100644 index 0000000..03ad139 --- /dev/null +++ b/codebase/backend/sms-service/app/domain.py @@ -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 diff --git a/codebase/backend/sms-service/app/main.py b/codebase/backend/sms-service/app/main.py new file mode 100644 index 0000000..6ed74bc --- /dev/null +++ b/codebase/backend/sms-service/app/main.py @@ -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, + ) diff --git a/codebase/backend/sms-service/app/metrics.py b/codebase/backend/sms-service/app/metrics.py new file mode 100644 index 0000000..9a6b7f4 --- /dev/null +++ b/codebase/backend/sms-service/app/metrics.py @@ -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", +) diff --git a/codebase/backend/sms-service/app/provider.py b/codebase/backend/sms-service/app/provider.py new file mode 100644 index 0000000..450c2a7 --- /dev/null +++ b/codebase/backend/sms-service/app/provider.py @@ -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)) diff --git a/codebase/backend/sms-service/app/schemas.py b/codebase/backend/sms-service/app/schemas.py new file mode 100644 index 0000000..4fae85b --- /dev/null +++ b/codebase/backend/sms-service/app/schemas.py @@ -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 diff --git a/codebase/backend/sms-service/app/service.py b/codebase/backend/sms-service/app/service.py new file mode 100644 index 0000000..225adc2 --- /dev/null +++ b/codebase/backend/sms-service/app/service.py @@ -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 diff --git a/codebase/backend/sms-service/app/settings.py b/codebase/backend/sms-service/app/settings.py new file mode 100644 index 0000000..390bf49 --- /dev/null +++ b/codebase/backend/sms-service/app/settings.py @@ -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", ""}: + 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() diff --git a/codebase/backend/sms-service/app/worker.py b/codebase/backend/sms-service/app/worker.py new file mode 100644 index 0000000..afe7360 --- /dev/null +++ b/codebase/backend/sms-service/app/worker.py @@ -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() diff --git a/codebase/backend/sms-service/migrations/env.py b/codebase/backend/sms-service/migrations/env.py new file mode 100644 index 0000000..b06c821 --- /dev/null +++ b/codebase/backend/sms-service/migrations/env.py @@ -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()) diff --git a/codebase/backend/sms-service/migrations/versions/0001_initial.py b/codebase/backend/sms-service/migrations/versions/0001_initial.py new file mode 100644 index 0000000..af3b2e5 --- /dev/null +++ b/codebase/backend/sms-service/migrations/versions/0001_initial.py @@ -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()) diff --git a/codebase/backend/sms-service/migrations/versions/0002_seed.py b/codebase/backend/sms-service/migrations/versions/0002_seed.py new file mode 100644 index 0000000..7778c1f --- /dev/null +++ b/codebase/backend/sms-service/migrations/versions/0002_seed.py @@ -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' + ) + """ + ) diff --git a/codebase/backend/sms-service/openapi.yaml b/codebase/backend/sms-service/openapi.yaml new file mode 100644 index 0000000..eb46ff6 --- /dev/null +++ b/codebase/backend/sms-service/openapi.yaml @@ -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"} diff --git a/codebase/backend/sms-service/pyproject.toml b/codebase/backend/sms-service/pyproject.toml new file mode 100644 index 0000000..740124a --- /dev/null +++ b/codebase/backend/sms-service/pyproject.toml @@ -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/"] diff --git a/codebase/backend/sms-service/tests/contract/test_openapi.py b/codebase/backend/sms-service/tests/contract/test_openapi.py new file mode 100644 index 0000000..4230825 --- /dev/null +++ b/codebase/backend/sms-service/tests/contract/test_openapi.py @@ -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() diff --git a/codebase/backend/sms-service/tests/unit/test_auth.py b/codebase/backend/sms-service/tests/unit/test_auth.py new file mode 100644 index 0000000..4b29f86 --- /dev/null +++ b/codebase/backend/sms-service/tests/unit/test_auth.py @@ -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")) diff --git a/codebase/backend/sms-service/tests/unit/test_domain.py b/codebase/backend/sms-service/tests/unit/test_domain.py new file mode 100644 index 0000000..9345b66 --- /dev/null +++ b/codebase/backend/sms-service/tests/unit/test_domain.py @@ -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 diff --git a/codebase/backend/sms-service/tests/unit/test_provider.py b/codebase/backend/sms-service/tests/unit/test_provider.py new file mode 100644 index 0000000..97678ab --- /dev/null +++ b/codebase/backend/sms-service/tests/unit/test_provider.py @@ -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" + ) diff --git a/codebase/backend/sms-service/tests/unit/test_schemas.py b/codebase/backend/sms-service/tests/unit/test_schemas.py new file mode 100644 index 0000000..37cbd3f --- /dev/null +++ b/codebase/backend/sms-service/tests/unit/test_schemas.py @@ -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 diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index f752b69..7183548 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -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") diff --git a/modules/module-02-frontend-test-site.md b/modules/module-02-frontend-test-site.md index c913f1b..326d904 100644 --- a/modules/module-02-frontend-test-site.md +++ b/modules/module-02-frontend-test-site.md @@ -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 diff --git a/modules/module-03-nginx.md b/modules/module-03-nginx.md index 485fff8..d43d587 100644 --- a/modules/module-03-nginx.md +++ b/modules/module-03-nginx.md @@ -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 diff --git a/modules/module-08-keycloak.md b/modules/module-08-keycloak.md index 839971f..b9b0dc5 100644 --- a/modules/module-08-keycloak.md +++ b/modules/module-08-keycloak.md @@ -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= Правила: -- 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= KEYCLOAK_SETTINGS_BRIDGE_TOKEN= +KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080 +KEYCLOAK_SMS_SERVICE_TOKEN= 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= ``` @@ -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 до реализации. diff --git a/modules/module-10-deployment-runbook.md b/modules/module-10-deployment-runbook.md index 5f66ad8..d55fc57 100644 --- a/modules/module-10-deployment-runbook.md +++ b/modules/module-10-deployment-runbook.md @@ -39,6 +39,9 @@ Placeholders: immutable tag/git SHA адрес ops, не placeholder в реальном запуске разрешённый портал + согласованное в Direct имя отправителя + фактический статический egress IP `sms-worker` + контролируемый номер для 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 | `` | 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= port= dbname= user=` запрещены; real mode требует sender/template/API key/callback credentials и recorded static egress IP; ```bash cd @@ -466,12 +473,13 @@ cd 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-командой на ``; проверить 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 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. Ссылки на прототип diff --git a/modules/module-11-idgtl-sms.md b/modules/module-11-idgtl-sms.md new file mode 100644 index 0000000..a32e9c1 --- /dev/null +++ b/modules/module-11-idgtl-sms.md @@ -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 `. + +- `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": "" + }, + "customer_ref": "01JABCDEF", + "message_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": "", + "destination": "79001234567", + "content": "", + "externalMessageId": "", + "ttl": , + "callbackUrl": "https://@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= +KEYCLOAK_SMS_SERVICE_TOKEN= +SMS_DATABASE_URL=postgresql://sms_user:...@/?... +IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru +IDGTL_SMS_API_KEY= +IDGTL_SMS_CALLBACK_PUBLIC_URL=https://tohin.ru/callbacks/idgtl/sms +IDGTL_SMS_CALLBACK_USERNAME= +IDGTL_SMS_CALLBACK_PASSWORD= +``` + +Здесь намеренно отсутствуют 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, а не параметр приложения или открытое архитектурное решение. diff --git a/ops-monitoring/instructions.md b/ops-monitoring/instructions.md index 1fc1a64..14bd414 100644 --- a/ops-monitoring/instructions.md +++ b/ops-monitoring/instructions.md @@ -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 \ No newline at end of file diff --git a/ops-monitoring/send_sms.md b/ops-monitoring/send_sms.md new file mode 100644 index 0000000..d8eed77 --- /dev/null +++ b/ops-monitoring/send_sms.md @@ -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 \ No newline at end of file diff --git a/deploy-steps.md b/releases/#0 deploy-steps.md similarity index 75% rename from deploy-steps.md rename to releases/#0 deploy-steps.md index e6e7e55..16fef04 100644 --- a/deploy-steps.md +++ b/releases/#0 deploy-steps.md @@ -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 @@ -448,4 +506,29 @@ request = urllib.request.Request( headers={"Authorization": "Bearer " + token}, ) print(urllib.request.urlopen(request).read().decode()) -PY \ No newline at end of file +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. \ No newline at end of file diff --git a/releases/#1 SMS OTP deploy.md b/releases/#1 SMS OTP deploy.md new file mode 100644 index 0000000..a8cd99a --- /dev/null +++ b/releases/#1 SMS OTP deploy.md @@ -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:@:/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem + +KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080 +SMS_SERVICE_TOKEN= +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:///callbacks/idgtl/sms +IDGTL_SMS_CALLBACK_USERNAME= +IDGTL_SMS_CALLBACK_PASSWORD= + +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(''::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` автоматически не переотправлять — их необходимо разбирать вручную.