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

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
+65 -29
View File
@@ -1,6 +1,6 @@
# module-08. Проектная спецификация `keycloak`
> Статус: целевая production-спецификация MVP; реальный SMS provider не входит в scope.
> Статус: целевая production-спецификация OTP; mock действует до controlled rollout, real mode интегрируется только через `sms-service` по [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md).
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-02-frontend-test-site.md`](module-02-frontend-test-site.md), [`module-03-nginx.md`](module-03-nginx.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
## 1. Назначение и границы
@@ -12,7 +12,7 @@ Keycloak отвечает за:
- realm, users, credentials, auth sessions и token lifecycle;
- Authorization Code Flow with PKCE для Expo web/iOS/Android;
- нормализацию/уникальность телефона и claims;
- OTP authenticator/SPI, mock verification и продуктовые limits;
- OTP authenticator/SPI, генерацию и локальную проверку OTP, challenge lifecycle, продуктовые limits и verify audit;
- brute-force, sessions, logout/revocation;
- keys/JWKS rotation и health/metrics.
@@ -22,9 +22,10 @@ Keycloak отвечает за:
- App DB/profile/chat и CRM sync;
- API service-to-service tokens;
- пользовательскую UX-сессию;
- реальную отправку SMS в MVP.
- шаблоны, отправку и provider delivery journal (это `sms-service`);
- прямой вызов i-Digital Direct и обработку delivery callback.
Реальный SMS provider — строго extension point/TBD. Mock code является секретом окружения, не контентом UI и не логируется.
Mock code является секретом окружения, не контентом UI и не логируется. В real mode Keycloak вызывает только закрытый durable-order API `sms-service`; API Direct Verifier не используется.
## 2. Топология и публичный URL
@@ -130,9 +131,9 @@ Internal API по-прежнему используют service tokens из arch
3. `Phone Identity Authenticator` нормализует номер.
4. Проверяются realm brute-force и product send limits.
5. Создаётся/находится user по canonical phone identity.
6. `Phone OTP Challenge` инициирует mock/provider send.
6. `Phone OTP Challenge` создаёт `ordering`: mock активирует его локально, real mode заказывает SMS через `sms-service`.
7. Показывается форма OTP.
8. Проверяются TTL/attempt limits/constant-time hash or mock compare.
8. Проверяются только локальные status/TTL/attempt limits и constant-time HMAC/mock compare; provider status не читается.
9. При успехе user enabled/phone verified, flow завершается code.
10. Frontend меняет code+verifier на tokens.
@@ -182,7 +183,7 @@ Required actions не должны предлагать пароль/email. По
Изменение телефона требует re-auth + OTP нового номера и invalidation sessions/tokens по policy. `api-backend` обновляет cached phone при следующем bootstrap/login claim; прямого вызова Keycloak DB нет.
## 7. Mock OTP
## 7. Mock и real delivery mode
Env:
@@ -193,24 +194,24 @@ KEYCLOAK_OTP_MOCK_CODE=<secret>
Правила:
- mock разрешён MVP production-like только как явно принятый риск;
- mock временно разрешён до controlled SMS rollout только как явно принятый риск;
- пустой/default `1234` запрещён startup policy для production-like, если не согласован secret;
- code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit;
- сравнение constant-time;
- challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production;
- code не сохраняется per-user в открытом виде;
- UI сообщает только «тестовый режим», без кода;
- `KEYCLOAK_OTP_MOCK_ENABLED=false` при отсутствии configured provider делает OTP flow fail-closed/not-ready, а не пропускает проверку.
- `KEYCLOAK_OTP_MOCK_ENABLED=false` при недоступном/неконфигурированном `sms-service` завершает новый order generic unavailable; уже active challenges продолжают локальный verify до TTL.
Реальный provider interface:
Реальный delivery interface:
```java
interface OtpDeliveryProvider {
DeliveryResult send(E164Phone phone, String otp, Duration ttl, Correlation ctx);
SmsOrderResult order(E164Phone phone, String otp, Duration ttl, String challengeId);
}
```
Будущий provider обязан вернуть `provider_message_id`; raw OTP не логируется. Выбор provider, template, sender, delivery status webhook и vendor credentials — TBD.
Реализация real mode — `SmsOrderClient` к `POST /internal/sms/v1/send`. Успех — только `200/202` с `sms_message_id`; один HTTP retry использует тот же challenge и `idempotency_key=keycloak:challenge:{challenge_id}`. Keycloak не получает `provider_message_id`, template/sender/status/callback и не хранит vendor credentials.
## 8. OTP challenge и counters
@@ -218,14 +219,14 @@ interface OtpDeliveryProvider {
- challenge id random ≥128 bit;
- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper;
- TTL (предлагается 5 минут) — technical security parameter;
- TTL — snapshot `app_settings["otp.phone.ttl_seconds"]`, диапазон `60..900`, кратен 60; real mode считается от `ordered_at`;
- one-time use; success atomically consumes challenge;
- max verification attempts per challenge;
- resend invalidates либо version-binds предыдущий challenge;
- resend всегда переводит предыдущий `active`/`ordering` challenge в `superseded`;
- replay/parallel verify безопасны;
- destination stored masked/hash where possible.
Audit fields по arch-05: provider message id (для mock — synthetic non-secret), sent_at, destination_masked, otp_hash/reference, attempts, outcome. Никогда raw code.
Audit хранит `sms_message_id` (nullable для mock/order_failed), `ordered_at`, destination masked/HMAC, attempts, outcome и device context. Provider send/delivery status и полный SMS journal в schema `keycloak` запрещены.
### 8.1. Product send limits bridge
@@ -242,6 +243,10 @@ Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
{
"max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "2026-07-10T08:00:00Z",
"cache_ttl_seconds": 60
}
@@ -270,11 +275,27 @@ Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/in
Минимальные records:
- `han_otp_challenge`: id, phone_hmac, otp_hash/mock marker, created/expires/consumed, verify attempts, settings version, provider id/status;
- `han_otp_challenge`: id, phone_hmac, destination_masked, otp_hash, `sms_message_id`, `delivery_mode`, `challenge_status`, `ordered_at`, `expires_at`, `otp_ttl_sec`, `otp_code_length`, verify attempts, settings version;
- `han_otp_send_counter`: phone_hmac, window_start, count, last_sent_at;
- `han_otp_security_event`: append-only minimal outcome/retention.
- `han_otp_security_event`: append-only событие на каждую send/verify попытку, `sms_message_id`, outcome/details и device context.
Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(expires_at)`. Cleanup bounded job. Доступ только `keycloak_user`.
Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(challenge_status,expires_at)`, partial `sms_message_id` и event `sms_message_id`. Periodic expiry переводит active в `expired`; автоматическое удаление SMS journal выполняться здесь не может. Доступ только `keycloak_user`.
### 8.3. Lifecycle и границы транзакций
1. После limits/counter reservation прежние `active`/`ordering` становятся `superseded`; создаётся новый `ordering` с crypto-random numeric OTP и immutable settings snapshot.
2. В real mode HTTP order выполняется вне transaction с DB locks. Потерянный ответ повторяется с тем же challenge/idempotency key, без нового OTP/counter.
3. `200/202` + `sms_message_id` → короткая transaction устанавливает `ordered_at`, `expires_at=ordered_at+otp_ttl_sec`, status `active` и event `otp_send/ordered`.
4. Невозможность durable order → `order_failed`; прежний challenge не восстанавливается. В mock mode challenge сразу `active`, `sms_message_id=null`.
5. Verify разрешён только для `active`: success → `consumed`, неверный код увеличивает attempts/event, лимит → `limited`, TTL → `expired`. Никакой переход не зависит от Direct `send_status`/`delivery_status`.
Миграция существующих mock rows: дождаться прежнего max TTL либо истечь незавершённые challenges; установить `delivery_mode=mock`, `sms_message_id=null`, `ordered_at=created_at`, consumed rows → `consumed`, остальные → `expired`, backfill TTL/length текущими seed. Прежние `provider_id`/`provider_status` сначала nullable/неиспользуемые и удаляются только отдельной backward-incompatible migration после стабилизации.
### 8.4. Device context и verify events
`han_otp_security_event` содержит `client_ip`, `user_agent`, `device_id`, `fingerprint`, `os_name`, `os_version`, `platform`, `app_version`; `sms_message_id` копируется для корреляции. Событие `otp_verify` пишется на каждую попытку с outcome `success|failure|limited|expired|already_used`.
Frontend передаёт необязательные `han_device_id`, `han_fingerprint`, `han_platform`, `han_os_name`, `han_os_version`, `han_app_version` в OIDC request/hidden fields. Значения недоверенные audit metadata: id/fingerprint ≤256, OS/app ≤64, platform только `web|ios|android`, control characters запрещены. IP берётся только из trusted nginx chain, UA — из текущего запроса. Query/form/OTP/device identifiers редактируются в access logs.
## 9. Brute-force и abuse
@@ -472,15 +493,24 @@ KC_DB_URL_PROPERTIES=currentSchema=keycloak
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=<secret>
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<secret>
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
KEYCLOAK_SMS_SERVICE_TOKEN=<secret>
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
```
Дополнительные Keycloak-standard env version-specific (`KC_DB`, hostname/proxy/health/metrics/pool) фиксируются в `.env.example` после выбора image. Секреты только root `.env`/secret mounts с минимальными permissions.
Product limits `otp.phone.*`, включая `otp.phone.max_verify_attempts`, не дублируются env и поступают через settings bridge. OTP TTL остаётся security technical config provider-а:
Все изменяемые OTP-параметры, включая limits, длину кода, TTL и timeout durable SMS order, не дублируются в env и поступают через settings bridge:
```text
otp.phone.code_length
otp.phone.ttl_seconds
otp.phone.sms_order_timeout_ms
```
Challenge сохраняет snapshot этих значений и `settings_version`; изменение настроек влияет только на новые challenges. В env остаются только secret/bootstrap-параметры:
```text
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_OTP_HMAC_KEY=<secret>
```
@@ -494,7 +524,7 @@ Keycloak management health endpoints включены. Compose проверяе
- realm/client/auth flow/provider loaded;
- active signing key;
- settings bridge last-known-good для OTP send;
- mock enabled с valid secret либо реальный provider configured.
- mock enabled с valid secret либо real-mode `sms-service` URL/token configured. Общая readiness Keycloak не зависит от Direct/provider status; недоступность `sms-service` отражается отдельным degraded dependency indicator и блокирует только новый real order.
Стандартный Keycloak health сам не знает business provider state; custom provider readiness check/sidecar/synthetic internal check дополняет его. Public synthetic проверяет discovery/JWKS и authorization endpoint без отправки OTP.
@@ -527,7 +557,7 @@ Keycloak access log должен редактировать sensitive query. TRA
- active sessions/token refresh/error;
- DB pool/JVM/GC/HTTP;
- JWKS/key age;
- provider mode info (`mock`, later vendor), без phone labels.
- delivery mode info (`mock`, `sms`) и provider dependency `idgtl`, без phone labels.
### Tracing
@@ -582,7 +612,9 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
| settings bridge down, cache valid | send limits по last-known-good |
| settings bridge down, cache empty/stale | new OTP send fail-closed |
| mock secret missing/invalid | startup/not-ready; OTP не bypass |
| SMS mode без provider | not-ready `otp_provider_unconfigured` |
| real mode без URL/token `sms-service` | новый OTP order fail-closed `otp_provider_unconfigured`; startup/config gate не пройден |
| `sms-service` timeout/5xx | один retry с тем же idempotency key; затем `order_failed`, generic unavailable |
| Direct reject/timeout после durable order | active challenge не меняется; Keycloak provider status не читает |
| wrong OTP | generic error, increment counter |
| too many sends/verifies | temporary reject/lockout, safe UX |
| token signing key rotation | old keys passive в JWKS grace |
@@ -604,6 +636,9 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- settings cache/ETag/stale/fail-closed;
- phone HMAC/counter cleanup;
- provider SPI error mapping.
- durable order `200/202`, idempotent retry, `409` reuse и `order_failed`;
- lifecycle `ordering/active/superseded/expired/limited/consumed`, periodic/lazy expiry;
- device metadata validation и append-only event на каждую verify.
### Realm/config contract
@@ -629,6 +664,8 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
### E2E
- new phone → mock OTP → PKCE tokens → API bootstrap;
- real mode: durable order открывает OTP form до ответа Direct; `sms_message_id` совпадает в обеих БД;
- resend отклоняет старый код; provider reject/timeout не меняет active challenge;
- existing user login; valid refresh without OTP;
- expired/revoked/rotated refresh → re-auth;
- wrong/expired/replayed code;
@@ -657,7 +694,7 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- phone canonical E.164 и storage-level unique;
- claims соответствуют module-01 (`sub`, `phone_number`, audience);
- mock secret only env, не логируется/не отдаётся;
- OTP challenges/counters durable в Keycloak schema;
- OTP challenges/counters/verify events durable в Keycloak schema; SMS journal/template/provider statuses там отсутствуют;
- product limits читаются только через canonical settings bridge/token;
- brute-force, TTL, verify attempts и enumeration protection работают;
- refresh rotation/reuse detection/logout/revocation покрыты;
@@ -666,7 +703,7 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- health/metrics/logging/tracing не раскрывают secrets/PII;
- container hardening/root Compose без published port;
- test matrix зелёная;
- реальный SMS явно остаётся extension point, не скрытой заглушкой.
- mock и real mutually exclusive; real mode вызывает только durable-order API `sms-service`, Direct/Verifier/status polling отсутствуют.
## 27. Решения, допущения и TBD
@@ -679,13 +716,13 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- K5: counters/challenges в provider-owned PostgreSQL schema `keycloak`, не API Redis.
- K6: product limits только `/internal/settings/v1/otp` + `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`.
- K7: refresh rotation/revoke-on-use; frontend single-flight.
- K8: real SMS provider — extension point/TBD.
- K8: real delivery`Keycloak → sms-service → i-Digital Direct`; verify остаётся локальным.
**Допущения:**
- A1: единый public host `tohin.ru` и relative path `/auth`.
- A2: Keycloak version поддерживает нужные hostname/proxy/health options; точные names pin после выбора image.
- A3: product допускает mock OTP в первой production-like среде как временный риск.
- A3: product допускает mock OTP до прохождения controlled real-SMS rollout как временный риск.
- A4: телефон в access token необходим API bootstrap и защищён TLS/short token TTL.
**TBD:**
@@ -697,6 +734,5 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
- K-TBD5: admin MFA/ops access topology и отдельный admin hostname.
- K-TBD6: signing-key rotation interval/HSM и emergency revocation.
- K-TBD7: RPO/RTO/event retention/legal deletion.
- K-TBD8: SMS vendor, credentials, templates, sender, delivery receipts and failover.
- K-TBD8 закрыт module-11 для v1: vendor i-Digital Direct, credentials/template/sender/callback принадлежат `sms-service`; failover вне v1.
- K-TBD9: CAPTCHA/risk scoring после mock.
- K-TBD10: добавить proposed OTP technical env в arch-04 до реализации.