Files
han-app/modules/module-08-keycloak.md
T

741 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# module-08. Проектная спецификация `keycloak`
> Статус: целевая 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. Назначение и границы
Keycloak — единственный IdP HAN Chat. MVP предоставляет регистрацию/вход только по подтверждённому номеру телефона и OTP, OIDC tokens, refresh/logout, discovery/JWKS и защиту auth flow.
Keycloak отвечает за:
- realm, users, credentials, auth sessions и token lifecycle;
- Authorization Code Flow with PKCE для Expo web/iOS/Android;
- нормализацию/уникальность телефона и claims;
- OTP authenticator/SPI, генерацию и локальную проверку OTP, challenge lifecycle, продуктовые limits и verify audit;
- brute-force, sessions, logout/revocation;
- keys/JWKS rotation и health/metrics.
Не отвечает за:
- `api-backend` bootstrap/consents/UserIdentity;
- App DB/profile/chat и CRM sync;
- API service-to-service tokens;
- пользовательскую UX-сессию;
- шаблоны, отправку и provider delivery journal (это `sms-service`);
- прямой вызов i-Digital Direct и обработку delivery callback.
Mock code является секретом окружения, не контентом UI и не логируется. В real mode Keycloak вызывает только закрытый durable-order API `sms-service`; API Direct Verifier не используется.
## 2. Топология и публичный URL
Keycloak работает за единственным root nginx:
```text
Client HTTPS https://tohin.ru/auth/*
→ nginx TLS termination
→ HTTP keycloak:8080 в закрытой Docker network
→ managed PostgreSQL schema keycloak по TLS
```
Публичный issuer обязан быть стабильным:
```text
https://tohin.ru/auth/realms/han-chat
```
OIDC discovery:
```text
https://tohin.ru/auth/realms/han-chat/.well-known/openid-configuration
```
JWKS — URI из discovery. `api-backend` проверяет `iss`, audience, signature, `exp/nbf` и `sub`, не вызывает Admin API в hot path.
## 3. Версия, image и providers
- Keycloak Quarkus distribution, поддерживаемая LTS/stable версия, закреплённая image digest.
- PostgreSQL JDBC driver из image.
- Custom Java provider JAR для phone OTP authenticator/settings bridge/counters.
- Сборка provider reproducible, зависимости pinned, SBOM/signature/security scan.
- Build-stage выполняет `kc.sh build`; runtime image immutable/non-root.
- Перед upgrade читаются Keycloak migration notes и SPI compatibility.
Версия Keycloak фиксируется в deployment manifest; `latest` запрещён.
## 4. Realm и clients
Realm: `han-chat`. Master realm не используется приложением.
### 4.1. Public frontend client
Канонический client id:
```text
han-chat-frontend
```
Настройки:
- public client; client authentication off;
- standard flow on;
- Authorization Code + PKCE `S256` обязательно;
- implicit flow off;
- direct access grants/password grant off;
- service accounts off;
- device flow off, если не нужен;
- consent screen Keycloak не заменяет продуктовые согласия API;
- exact redirect URIs и web origins;
- full scope allowed off; только назначенные scopes/mappers.
Примеры redirect URI должны перечисляться отдельно:
```text
https://tohin.ru/auth/callback
han-chat://auth/callback
<Expo native scheme/callback, exact value после сборки>
```
Production redirect URI задаются exact; wildcard не используется до отдельного security review. Development localhost origins/redirects находятся в отдельном dev realm/client либо profile и запрещены production.
### 4.2. API audience
Audience:
```text
han-chat-api
```
Client scope/audience mapper добавляет `aud=han-chat-api` в access token frontend. `api-backend` не принимает token только по `azp` без audience.
### 4.3. Optional confidential client
`han-chat-backend` можно импортировать disabled/optional для будущих admin/ops S2S:
- client authentication on, service account only при явном включении;
- secret не хранится в realm export;
- минимальные roles;
- не используется между текущими сервисами и не требуется для JWT validation;
- не участвует в пользовательском hot path.
Internal API по-прежнему используют service tokens из arch-02.
## 5. OTP-only phone flow
### 5.1. Browser flow
Отдельный flow `han-phone-otp-browser`:
1. Cookie/SSO authenticator проверяет действующую Keycloak session.
2. При отсутствии session показывается форма телефона.
3. `Phone Identity Authenticator` нормализует номер.
4. Проверяются realm brute-force и product send limits.
5. Создаётся/находится user по canonical phone identity.
6. `Phone OTP Challenge` создаёт `ordering`: mock активирует его локально, real mode заказывает SMS через `sms-service`.
7. Показывается форма OTP.
8. Проверяются только локальные status/TTL/attempt limits и constant-time HMAC/mock compare; provider status не читается.
9. При успехе user enabled/phone verified, flow завершается code.
10. Frontend меняет code+verifier на tokens.
Password form, registration password, reset password, email OTP, social login и magic link отсутствуют.
### 5.2. Регистрация/find-or-create
До выдачи OTP новый user может существовать как short-lived pending identity либо создаваться после успешной проверки. Предпочтительное решение:
- normalized phone reservation/counter создаётся в SPI store;
- permanent Keycloak user создаётся/активируется только после успешного OTP;
- concurrent flow защищён unique phone index/transaction;
- abandoned pending challenges очищаются TTL.
Если Keycloak storage не позволяет безопасный custom unique index в managed schema, user создаётся disabled с deterministic username и очищается job; точная реализация покрывается concurrency tests.
### 5.3. Required actions
Используются только при реальной необходимости:
- `VERIFY_PHONE` — если user импортирован/номер изменён вне текущего verified flow;
- `UPDATE_PHONE` — будущий controlled flow с повторной OTP;
- terms/product consents не required action: версии и факт согласия хранит `api-backend`.
Required actions не должны предлагать пароль/email. После phone OTP обычный вход завершается без лишнего profile screen.
## 6. Нормализация и уникальная identity
Телефон парсится libphonenumber:
- Unicode digits/NFKC input normalization;
- default region `RU` допустим только для национального ввода; международные номера поддерживаются по product policy;
- canonical storage/claim — E.164, например `+79001234567`;
- invalid/impossible number отклоняется до send;
- отображение только masked;
- canonical phone comparison exact.
Рекомендуемая модель:
- `username` = canonical E.164 либо irreversible deterministic identifier;
- user attribute `phone_number` = E.164;
- `phone_number_verified=true`;
- unique phone enforced storage-level, не только pre-check;
- email nullable/не используется.
Утечка существования номера запрещена: initiate/challenge возвращают одинаковый внешний текст/timing class для нового/существующего пользователя. Один verified phone соответствует одному active `sub`. Merge/reassignment — отдельная administrative policy, не автоматический side effect login.
Изменение телефона требует re-auth + OTP нового номера и invalidation sessions/tokens по policy. `api-backend` обновляет cached phone при следующем bootstrap/login claim; прямого вызова Keycloak DB нет.
## 7. Mock и real delivery mode
Env:
```text
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=<secret>
```
Правила:
- 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` при недоступном/неконфигурированном `sms-service` завершает новый order generic unavailable; уже active challenges продолжают локальный verify до TTL.
Реальный delivery interface:
```java
interface OtpDeliveryProvider {
SmsOrderResult order(E164Phone phone, String otp, Duration ttl, String challengeId);
}
```
Реализация 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
Даже в mock:
- challenge id random ≥128 bit;
- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper;
- 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 всегда переводит предыдущий `active`/`ordering` challenge в `superseded`;
- replay/parallel verify безопасны;
- destination stored masked/hash where possible.
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
SPI вызывает:
```text
GET http://api-backend:8000/internal/settings/v1/otp
Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
```
Ответ:
```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-10T08:00:00Z",
"cache_ttl_seconds": 60
}
```
Это единственный путь к `otp.phone.*`; Keycloak не получает GRANT на `han_app`. SPI поддерживает ETag/cache, single-flight refresh. Bridge down:
- использовать last-known-good до bounded max stale;
- если cache пуст/слишком стар — fail-closed для send;
- verify уже выданного challenge может продолжаться по snapshot, с которым challenge создан.
Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/internal/settings/v1/otp`.
### 8.2. Где хранятся counters
Решение MVP: counters/challenges хранятся в Keycloak-owned PostgreSQL tables/provider storage в схеме `keycloak`, а не в Redis API и не в `han_app`.
Причины:
- durable across restart;
- одна transaction для reserve/send-attempt/consume;
- не добавляет Keycloak credentials к общему Redis;
- соответствует границе «счётчики в зоне Keycloak/SPI».
Используются phone HMAC, не E.164 в key/index для rate data. Tables provider-owned создаются versioned migration provider-а, не ручным DDL-on-start.
Минимальные records:
- `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 событие на каждую send/verify попытку, `sms_message_id`, outcome/details и device context.
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
Слои:
1. nginx `/auth` IP rate limit (`NGINX_RATE_LIMIT_AUTH`);
2. Keycloak realm brute-force detection;
3. SPI product send limits per phone HMAC;
4. verify-attempt limit per challenge/phone/IP hash;
5. cooldown after repeated failures;
6. невидимая Yandex SmartCaptcha перед каждым первичным и повторным заказом OTP SMS.
Realm включает brute-force protection с temporary lockout и bounded wait. Permanent lockout для consumer phone login без recovery runbook нежелателен. Error messages не различают unknown phone/wrong code/locked account сверх безопасной UX причины. `Retry-After`/remaining time выдаётся только если не помогает enumeration.
IP берётся только из trusted proxy chain; Keycloak настроен доверять forwarded headers от root nginx.
SmartCaptcha включается только через `KEYCLOAK_YANDEX_CAPTCHA_ENABLED`; client/server keys обязательны при `true`. Одноразовый token проверяется server-side до `OtpFlow.start()`/counter reservation. `status=failed`, отсутствующий token и non-temporary HTTP 4xx блокируют SMS; timeout, I/O, HTTP 408/429/5xx и malformed response работают fail-open с безопасным логом. Сложность и traffic rules принадлежат одной CAPTCHA в Yandex Cloud. CSP с доменами SmartCaptcha задаётся точечно в nginx только для login endpoints; custom realm CSP запрещён из-за риска поломки Admin Console/`3p-cookies`.
## 10. Claims и token contract
Access token минимум:
| Claim | Значение |
|---|---|
| `iss` | `https://tohin.ru/auth/realms/han-chat` |
| `sub` | immutable Keycloak user id |
| `aud` | включает `han-chat-api` |
| `azp` | `han-chat-frontend` |
| `exp`, `iat`, `nbf` | стандартные |
| `sid` | session id, если поддерживается |
| `auth_time` | время auth |
| `acr`/`amr` | отражает phone OTP |
| `phone_number` | canonical E.164 |
| `phone_number_verified` | `true` |
| `scope` | only allowed scopes |
`preferred_username` может совпадать с phone для compatibility, но канонический claim API — `phone_number`; module-01 допускает fallback только если E.164.
ID token предназначен client login state; API принимает access token, не ID token. Refresh token непрозрачен для приложения и хранится frontend secure storage.
PII minimization: full phone нужен API bootstrap по зафиксированному контракту, но не добавляется в service tokens/metrics/logs. Roles/groups выдаются только если используются authorization policy.
## 11. Signing keys, JWKS и rotation
- asymmetric signing, RS256 MVP; `none`/HS algorithms запрещены;
- active signing key + passive previous keys до истечения всех выпущенных tokens/grace;
- keys генерируются/хранятся Keycloak, private material не в realm export/repo;
- JWKS публичен через issuer;
- rotation rehearsed; `kid` меняется, API controlled-refresh cache;
- emergency compromise: disable key, revoke sessions, force re-login, alert/runbook;
- backup/restore учитывает realm keys.
Rotation interval и HSM/keystore — ops TBD. Изменение algorithm требует совместного rollout API verifier.
## 12. Token и session lifecycle
Предлагаемые MVP значения, окончательно принять security/product review:
- access token lifespan: 5 минут;
- SSO session idle: 30 дней;
- SSO session max: 90 дней;
- refresh token следует session limits;
- authorization code: 1 минута;
- login action: 5 минут;
- client session idle/max согласованы с SSO;
- clock skew минимальный.
Refresh:
- revoke refresh token on use / refresh token rotation включены;
- max reuse `0` или минимально поддерживаемое значение;
- frontend применяет single-flight, поэтому parallel refresh не требуется;
- reuse старого refresh token → `invalid_grant`, возможная session revocation/security event;
- offline tokens не выдаются.
Access token не хранится server-side и живёт до exp; критическая блокировка пользователя сопровождается logout/revocation/not-before policy.
## 13. Logout, revocation и browser cookies
Frontend вызывает OIDC end-session/logout с valid post-logout redirect, затем всегда очищает local tokens. Back-channel logout можно включить для clients, которые его поддержат; API JWT hot path не хранит browser session.
Cookies Keycloak:
- `Secure`, `HttpOnly`;
- SameSite согласно redirect/iframe requirements, по умолчанию `Lax`;
- domain/path минимальны (`/auth`/host);
- third-party cookie dependency не закладывается;
- session fixation предотвращается Keycloak;
- admin console cookies не расширяются на frontend origins.
Front-channel iframe checks не должны заставлять ослабить CSP всего сайта. Native logout использует system browser и app-link/custom scheme validation.
## 14. CORS, origins и redirects
- exact `Web Origins`: `https://tohin.ru`;
- no wildcard `*` with credentials;
- native apps не получают произвольные web origins;
- valid redirects exact/safely scoped;
- redirect URI comparison не допускает open redirect;
- post-logout redirects отдельно allow-listed;
- nginx и Keycloak CORS не должны дублировать противоречащие headers;
- token endpoint используется PKCE client без client secret;
- admin endpoints не CORS-доступны приложению.
Любой новый environment имеет отдельный host/client config, а не production wildcard.
## 15. Reverse proxy и hostname
Ключевые настройки (точные CLI names проверяются по закреплённой версии):
```text
KC_HTTP_ENABLED=true
KC_HTTP_PORT=8080
KC_PROXY_HEADERS=xforwarded
KC_HOSTNAME=https://tohin.ru/auth
KC_HTTP_RELATIVE_PATH=/auth
KC_HOSTNAME_STRICT=true
KC_HOSTNAME_STRICT_HTTPS=true
```
Если выбран другой поддержанный pattern (`hostname` без path + relative path), итоговые issuer/endpoints обязаны совпасть с `KEYCLOAK_PUBLIC_URL`.
Nginx передаёт trusted `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443`, real IP. Keycloak не доступен напрямую с host/public network, поэтому spoofed forwarded headers не принимаются извне.
Admin hostname/path рекомендуется ограничить ops network/VPN; публично нужны только realm/OIDC/login assets. Если разделить admin hostname невозможно MVP, admin console защищается network allow-list и сильным admin auth.
## 16. PostgreSQL `keycloak`
Используется:
```text
KEYCLOAK_DB_URL=jdbc:postgresql://.../han_chat?...&currentSchema=keycloak
KC_DB_URL_PROPERTIES=currentSchema=keycloak
```
Role `keycloak_user` имеет доступ только к schema `keycloak`; нет доступа `han_app`, `bitrix_*`, `message_safety`. Connection только private VPC + TLS verify.
Pool:
- bounded initial/min/max;
- acquisition/query/connect timeout;
- leak detection/metrics;
- pool max определяется load test и managed PG limit;
- `application_name=keycloak`.
Keycloak управляет своей стандартной schema migration. Custom provider tables имеют отдельную versioned migration strategy, совместимую с startup/rolling upgrade; DDL не выполняется бесконтрольно каждым replica.
Нельзя редактировать стандартные Keycloak tables вручную или Alembic-миграциями Python-сервисов.
## 17. Admin bootstrap и realm import
### 17.1. Bootstrap admin
- `KC_BOOTSTRAP_ADMIN_USERNAME`/password или актуальный bootstrap mechanism только на первом запуске;
- password генерируется strong secret, не коммитится и после bootstrap ротируется/удаляется из runtime env;
- admin user не используется приложением;
- отдельные named admin accounts/least privilege для ops;
- MFA для admin обязательно до production, независимо от consumer phone flow;
- admin events audit включён.
### 17.2. Realm import
Репозиторий:
```text
keycloak/
realm/han-chat-realm.json.template
providers/han-phone-otp-provider.jar
themes/han-phone/
migrations/
scripts/{render-realm,validate-realm,export-realm}.sh
tests/
Dockerfile
docker-compose.yml
```
Export/template содержит realm/client/flow/scopes/policies, но не:
- client/admin/provider secrets;
- mock code;
- private signing keys;
- environment-specific production credentials.
Import автоматически допустим для clean local/test. Production changes применяются controlled declarative job/Admin API procedure с diff/backup, не `--import-realm` поверх живого realm без проверки. Drift detection сравнивает безопасный desired subset.
## 18. Settings и secrets
Канонические:
```text
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
KEYCLOAK_INTERNAL_URL=http://keycloak:8080
KEYCLOAK_REALM=han-chat
KEYCLOAK_AUDIENCE=han-chat-api
KEYCLOAK_DB_URL=jdbc:postgresql://...
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.
Все изменяемые 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_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_OTP_HMAC_KEY=<secret>
```
## 19. Health, readiness и startup
Keycloak management health endpoints включены. Compose проверяет liveness/startup; readiness требует:
- server started;
- DB reachable/schema migration complete;
- realm/client/auth flow/provider loaded;
- active signing key;
- settings bridge last-known-good для OTP send;
- 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.
DB/settings failure не должен приводить к выдаче tokens без OTP. OTEL/metrics outage не блокирует login.
## 20. Logging, metrics, tracing и audit
### Logs
JSON/stdout:
- service/version/environment, event/category;
- request/trace id, realm/client, safe flow step;
- result/error code, duration;
- phone только HMAC/masked при необходимости.
Запрещены raw OTP/mock code, phone, access/refresh/code, cookies, Authorization, client/admin secret, password, form body, redirect query с `code`, DB URL.
Keycloak access log должен редактировать sensitive query. TRACE/DEBUG production выключены.
### Events/audit
Включаются login/login_error, logout, refresh/revoke, user create/disable, phone verify/change, brute-force/OTP limit, admin config changes. Retention/consumer определяется ops/legal; event payload минимален.
### Metrics
- login/OTP send/verify success/failure/latency;
- limit/lockout rejects;
- settings cache age/refresh failures;
- active sessions/token refresh/error;
- DB pool/JVM/GC/HTTP;
- JWKS/key age;
- delivery mode info (`mock`, `sms`) и provider dependency `idgtl`, без phone labels.
### Tracing
OTEL support зависит от версии; HTTP/provider/settings bridge spans добавляются instrumentation без secrets. Если native tracing недостаточно, сохраняются request/trace correlation headers. Наблюдаемость не меняет auth outcome.
## 21. Backup, restore и disaster recovery
- managed PostgreSQL daily backup + PITR;
- realm config export хранится versioned и secret-free;
- signing key/private realm state входит в protected DB backup;
- provider JAR/theme/image reproducible из repo/artifacts;
- restore rehearsal в isolated environment;
- после restore проверяются issuer, keys/JWKS, clients/flows, user/session consistency, provider tables;
- RPO/RTO фиксируются ops до production.
При восстановлении в другой host нельзя случайно выдать production tokens с неверным issuer. DNS/TLS/hostname проверяются до открытия traffic. Backup encrypted/access-controlled; OTP expired rows очищаются по TTL.
## 22. Миграции и upgrades
Порядок:
1. прочитать release notes и supported DB upgrade path;
2. backup/PITR checkpoint;
3. проверить provider SPI/API compatibility и пересобрать JAR;
4. прогнать upgrade clone БД;
5. contract/E2E login+refresh+logout;
6. staged maintenance/rolling rollout только если версия поддерживает cluster compatibility;
7. проверить schema migration, realm drift, JWKS;
8. rollback приложения возможен только если DB schema backward-compatible; иначе restore/forward-fix runbook.
Нельзя пропускать major versions произвольно. Realm changes versioned отдельно. Custom provider migration имеет собственный version table/compatibility matrix.
## 23. Docker/runtime hardening
- service `keycloak`, `expose: 8080` и management port только internal;
- networks `public` (только nginx access при необходимости), `backend`, `observability`;
- без host `ports`;
- non-root, read-only rootfs где совместимо, tmpfs для temp;
- no-new-privileges/drop capabilities;
- memory/CPU/JVM heap limits, graceful termination;
- startup/readiness probes с достаточным initial period;
- immutable provider/theme mounts/image;
- no local persistent DB volume.
TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP допустим только на закрытой сети одной VM.
## 24. Failure semantics
| Сбой | Поведение |
|---|---|
| DB down | not-ready; login/refresh fail; existing access tokens проверяются API до exp по cached JWKS |
| 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 |
| 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 |
| OTEL down | auth работает, telemetry drop metric/local log |
| provider exception | flow fail-closed, generic error/request id |
Не должно быть fallback на password или «успешный OTP» при инфраструктурной ошибке.
## 25. Тестовая матрица
### Unit provider
- E.164 normalization across RU/international/Unicode;
- invalid/impossible phone;
- unique concurrent reservation;
- mock constant-time compare/redaction;
- challenge TTL/one-time/replay/concurrent verify;
- send/verify limits and window boundaries;
- 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
- only standard code+PKCE S256;
- password/direct/implicit/social disabled;
- exact origins/redirect/logout URIs;
- audience/claims/issuer;
- token/session TTL and refresh rotation;
- browser flow executions/required actions;
- no secrets/private keys in realm export.
### Integration
- managed/test PostgreSQL schema/currentSchema/TLS;
- restart preserves counters/challenges;
- Keycloak upgrade/provider migration;
- settings bridge token/path with module-01;
- JWKS rotation and API validation;
- disabled user/revocation/not-before;
- brute-force lockout/recovery;
- proxy hostname/path builds correct external URLs.
### 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;
- max sends/min interval/max verifies;
- concurrent tabs/refresh single-flight assumptions;
- logout web/native;
- DB/settings outage;
- no phone/OTP/token in logs, URLs or metrics;
- admin endpoint inaccessible publicly.
### Security
- redirect/open redirect, PKCE downgrade, state/nonce;
- user enumeration/timing;
- cookie flags/CSRF on auth forms;
- forwarded header spoofing;
- brute-force/IP/phone distributed attempts;
- JWT alg/aud/iss/kid attacks;
- secret scanning/image/SBOM/provider dependency review.
## 26. Definition of Done
- Keycloak доступен за `/auth`, issuer/discovery/JWKS стабильны;
- realm/client topology и PKCE S256 зафиксированы declaratively;
- только phone OTP; password/implicit/direct/social отключены;
- phone canonical E.164 и storage-level unique;
- claims соответствуют module-01 (`sub`, `phone_number`, audience);
- mock secret only env, не логируется/не отдаётся;
- 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 покрыты;
- proxy/redirect/origin/CORS/cookies/TLS boundaries проверены;
- DB role/schema/backup/restore/upgrade runbooks готовы;
- health/metrics/logging/tracing не раскрывают secrets/PII;
- container hardening/root Compose без published port;
- test matrix зелёная;
- mock и real mutually exclusive; real mode вызывает только durable-order API `sms-service`, Direct/Verifier/status polling отсутствуют.
## 27. Решения, допущения и TBD
**Решения:**
- K1: realm `han-chat`, public client `han-chat-frontend`, audience `han-chat-api`.
- K2: Authorization Code + PKCE S256; остальные user grants выключены.
- K3: canonical identity/claim — E.164 `phone_number`; `sub` immutable.
- K4: OTP authenticator/provider SPI; mock code только secret env.
- 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 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 до прохождения controlled real-SMS rollout как временный риск.
- A4: телефон в access token необходим API bootstrap и защищён TLS/short token TTL.
**TBD:**
- K-TBD1: выбрать/pin Keycloak version и проверить custom SPI compatibility.
- K-TBD2: окончательные redirect URI для Expo iOS/Android и universal/app links.
- K-TBD3: финальные token/session TTL и brute-force thresholds после security review.
- K-TBD4: exact schema/migration mechanism provider tables без вмешательства в standard schema.
- 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 закрыт module-11 для v1: vendor i-Digital Direct, credentials/template/sender/callback принадлежат `sms-service`; failover вне v1.
- K-TBD9 закрыт для v1: одна невидимая Yandex SmartCaptcha защищает initial send и resend; динамический risk scoring остаётся вне scope.