Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -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 до реализации.
|
||||
|
||||
Reference in New Issue
Block a user