From 8c3c454998c1a490030837267d76af1d3af3a80f Mon Sep 17 00:00:00 2001 From: mi Date: Thu, 23 Jul 2026 18:53:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81.=D0=9A?= =?UTF-8?q?=D0=B0=D0=BF=D1=87=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../arch-03-docker-compose-blueprint.md | 4 +- architectory/arch-04-settings-and-content.md | 4 + backlog.md | 2 + codebase/backend/.env.example | 4 + codebase/backend/deployment/RUNBOOK.ru.md | 18 ++ codebase/backend/frontend-test-site/README.md | 10 +- .../backend/frontend-test-site/app/+html.tsx | 14 +- .../frontend-test-site/playwright.config.ts | 2 +- .../frontend-test-site/public/manifest.json | 11 +- .../frontend-test-site/public/register-sw.js | 7 + .../tests/e2e/smoke.spec.ts | 18 ++ .../frontend-test-site/workbox-config.js | 3 +- .../backend/infra/compose/application.yml | 8 +- codebase/backend/keycloak/.env.example | 3 + codebase/backend/keycloak/README.md | 14 ++ codebase/backend/keycloak/docker-compose.yml | 5 + codebase/backend/keycloak/pom.xml | 6 + .../java/ru/han/chat/keycloak/Config.java | 16 +- .../keycloak/PhoneIdentityAuthenticator.java | 14 +- .../chat/keycloak/PhoneOtpAuthenticator.java | 14 +- .../keycloak/YandexSmartCaptchaClient.java | 121 ++++++++++++ .../han/chat/keycloak/RealmContractTest.java | 2 + .../keycloak/SmsLifecycleContractTest.java | 28 +++ .../YandexSmartCaptchaClientTest.java | 130 +++++++++++++ .../login/messages/messages_ru.properties | 2 + .../keycloak/themes/han-phone/login/otp.ftl | 20 +- .../keycloak/themes/han-phone/login/phone.ftl | 20 +- .../login/resources/css/han-login.css | 12 ++ .../han-phone/login/resources/js/han-login.js | 86 +++++++++ .../snippets/proxy-keycloak-captcha-csp.conf | 2 + .../templates/frontend-static.conf.template | 5 + .../nginx/templates/site-tls.conf.template | 14 ++ codebase/backend/scripts/validate-env | 11 ++ codebase/backend/tests/test_config.py | 63 ++++++- modules/module-08-keycloak.md | 6 +- releases/notification-requirements.md | 172 ++++++++++++++++++ 36 files changed, 836 insertions(+), 35 deletions(-) create mode 100644 codebase/backend/frontend-test-site/public/register-sw.js create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/YandexSmartCaptchaClient.java create mode 100644 codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/YandexSmartCaptchaClientTest.java create mode 100644 codebase/backend/nginx/snippets/proxy-keycloak-captcha-csp.conf create mode 100644 releases/notification-requirements.md diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md index 4821415..1f24070 100644 --- a/architectory/arch-03-docker-compose-blueprint.md +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -240,7 +240,7 @@ Identity provider. **Обязателен** в compose-контуре с пер - включены proxy settings для работы за `nginx`; - импорт realm в local/dev; - использует managed PostgreSQL, схема `keycloak` (см. раздел «Managed PostgreSQL» выше); -- OTP mock / SMS SPI — см. arch-04; real mode вызывает только `sms-service` по сети `backend`, сам Keycloak к Direct/`egress` не подключён; +- OTP mock / SMS SPI — см. arch-04; real mode вызывает `sms-service` по сети `backend`, а единственный утверждённый внешний вызов Keycloak через `egress` — server-side validation Yandex SmartCaptcha; - healthcheck; - взаимодействия — [`arch-02-api-contracts.md`](arch-02-api-contracts.md), «Frontend ↔ Keycloak», и [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Keycloak». @@ -282,7 +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. +- `egress`: только сервисы с утверждёнными исходящими интеграциями; `sms-worker` обращается к Direct, Keycloak — только к `smartcaptcha.cloud.yandex.ru` для server-side validation. 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. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index f15b706..3f514c6 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -29,6 +29,7 @@ Managed PostgreSQL **поднимается до** развёртывания п - параметры **nginx/TLS** и edge rate limits (`NGINX_RATE_LIMIT_*`); - идентификация Keycloak: realm, audience, public/internal URL; - переключатель и секрет временного OTP mock (`KEYCLOAK_OTP_MOCK_*`); mock обязателен до прохождения real-SMS rollout gates и запрещён как незаявленный fallback; +- переключатель и client/server keys Yandex SmartCaptcha (`KEYCLOAK_YANDEX_CAPTCHA_*`); сложность остаётся в Yandex Cloud, а server key не попадает в тему/логи; - технические параметры сервисов, пока профильная спецификация не определила service-owned settings; для `sms-service` runtime-параметры уже вынесены в `sms.sms_setting`. **Запрещено в `.env` (→ только `app_settings`):** @@ -226,6 +227,9 @@ KEYCLOAK_REALM=han-chat KEYCLOAK_AUDIENCE=han-chat-api KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_CODE=1234 +KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false +KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY= +KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY= KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080 # ============================================================================= diff --git a/backlog.md b/backlog.md index a7a7dd0..c117631 100644 --- a/backlog.md +++ b/backlog.md @@ -17,6 +17,8 @@ 16. Сделать тестового пользователя с фиксированным СМС-входом ~~17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)~~ ~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~ +19. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) +20. Убрать с экрана при запросе OTP тексты согласий (внизу экрана) На будущее (после доработки отдельных функциональностей): 1. Разработка message-safety diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index b809af1..b4a8880 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -68,6 +68,10 @@ KEYCLOAK_AUDIENCE=han-chat-api KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_CODE=change-me KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false +KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false +# Обязательны только при KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true. +KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY= +KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY= # (openssl rand -hex 32) KEYCLOAK_OTP_HMAC_KEY=change-me KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300 diff --git a/codebase/backend/deployment/RUNBOOK.ru.md b/codebase/backend/deployment/RUNBOOK.ru.md index 60e9e65..e5687f0 100644 --- a/codebase/backend/deployment/RUNBOOK.ru.md +++ b/codebase/backend/deployment/RUNBOOK.ru.md @@ -138,8 +138,26 @@ docker compose ps keycloak - [ ] Issuer discovery/JWKS точно совпадает с публичным HTTPS URL `/auth`. - [ ] Frontend-клиент является публичным PKCE S256; implicit, password и social flows отключены. - [ ] Неверный или повторно использованный OTP и превышение лимитов безопасно отклоняются; settings bridge работает fail-closed. +- [ ] При `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` initial send и resend требуют свежий SmartCaptcha token; техническая недоступность Yandex подтверждена как fail-open в логах. +- [ ] CSP login-страницы содержит `smartcaptcha.cloud.yandex.ru`/`yastatic.net`, а `/auth/realms/master/protocol/openid-connect/3p-cookies/step2.html` и Admin Console работают без CAPTCHA CSP. - [ ] Временный администратор удален либо его пароль изменен; для именного администратора включена MFA. +Если предыдущая попытка сохранила custom CSP в realm, сбросьте только это поле через `kcadm`; `.env` как shell-файл не загружать: + +```sh +docker compose exec -T keycloak sh -lc ' +set -eu +cfg=/tmp/han-kcadm.config +/opt/keycloak/bin/kcadm.sh config credentials --config "$cfg" \ + --server http://127.0.0.1:8080/auth --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" +/opt/keycloak/bin/kcadm.sh update realms/han-chat --config "$cfg" \ + -s "browserSecurityHeaders.contentSecurityPolicy=" +rm -f "$cfg" +' +``` + ## Этап 12 — последовательный запуск и готовность ```sh diff --git a/codebase/backend/frontend-test-site/README.md b/codebase/backend/frontend-test-site/README.md index 9a66248..be73dab 100644 --- a/codebase/backend/frontend-test-site/README.md +++ b/codebase/backend/frontend-test-site/README.md @@ -18,10 +18,10 @@ npm run web | Файл | Размер | |---|---| -| `favicon.png` | 64×64 | -| `icon-192.png` | 360×360 | -| `icon-512.png` | 1024×1024 | -| `apple-touch-icon.png` | 360×360 | +| `favicon.png` | 32×32 | +| `icon-192.png` | 192×192 | +| `icon-512.png` | 512×512 | +| `apple-touch-icon.png` | 180×180 | Manifest: `public/manifest.json`. Корневой HTML: `app/+html.tsx`. @@ -34,7 +34,7 @@ Chrome DevTools → Application → Manifest / Service Workers. API и `/auth/` ## Production -`npm run build:pwa` создаёт `dist/` с manifest, иконками и `service-worker.js`. Каталог монтируется в корневой nginx; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html`, `manifest.json`, `service-worker.js` и бессрочно кэшировать hashed assets. +`npm run build:pwa` создаёт `dist/` с manifest, иконками и `service-worker.js`. Каталог монтируется в корневой nginx; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html`, `manifest.json`, `register-sw.js`, `service-worker.js` и бессрочно кэшировать hashed assets. Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера: diff --git a/codebase/backend/frontend-test-site/app/+html.tsx b/codebase/backend/frontend-test-site/app/+html.tsx index c35b697..59c3c87 100644 --- a/codebase/backend/frontend-test-site/app/+html.tsx +++ b/codebase/backend/frontend-test-site/app/+html.tsx @@ -1,14 +1,6 @@ import { ScrollViewStyleReset } from "expo-router/html"; import type { PropsWithChildren } from "react"; -const serviceWorkerBootstrap = ` -if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('/service-worker.js').catch(() => {}); - }); -} -`; - export default function Root({ children }: PropsWithChildren) { return ( @@ -19,9 +11,9 @@ export default function Root({ children }: PropsWithChildren) { - - - + + <#if captchaEnabled!false> + + diff --git a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl index 953e152..a6088b9 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl +++ b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl @@ -2,7 +2,7 @@ <@layout.registrationLayout displayMessage=false; section> <#if section = "header">${msg("phoneTitle")} <#elseif section = "form"> - +
@@ -31,6 +31,18 @@

${msg("phoneCountry")}

+ <#if captchaEnabled!false> + +
+ + + <#if message?has_content> - + + <#if captchaEnabled!false> + + diff --git a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css index ade3f84..439ee6b 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css +++ b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css @@ -238,6 +238,14 @@ body.login-pf { line-height: 1; } +.han-captcha { + min-height: 1px; +} + +.han-captcha + .han-error { + margin-top: 16px; +} + .han-legal { margin: 32px 0 0; color: var(--han-muted); @@ -267,6 +275,10 @@ body.login-pf { line-height: 1.4; } +.han-error[hidden] { + display: none !important; +} + .han-error-icon { display: inline-flex; width: 17px; 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 b9e20b8..ea9e1e8 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 @@ -127,11 +127,97 @@ updateCountdown(); if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000); resend.addEventListener("click", function () { + if (document.getElementById("han-captcha-container")) return; window.setTimeout(function () { resend.disabled = true; }, 0); }); } + function initCaptcha() { + var container = document.getElementById("han-captcha-container"); + if (!container) return; + var form = document.getElementById(container.getAttribute("data-form-id")); + var tokenInput = document.getElementById("han-captcha-token"); + var error = document.getElementById("han-captcha-client-error"); + var resendOnly = container.getAttribute("data-resend-only") === "true"; + var widgetId = null; + var executing = false; + var pendingSubmitter = null; + + function showError() { + executing = false; + if (pendingSubmitter) pendingSubmitter.disabled = false; + pendingSubmitter = null; + if (tokenInput) tokenInput.value = ""; + if (error) error.hidden = false; + } + + window.hanCaptchaOnload = function () { + if (!window.smartCaptcha || !form || !tokenInput) { + showError(); + return; + } + try { + widgetId = window.smartCaptcha.render(container, { + sitekey: container.getAttribute("data-sitekey"), + invisible: true, + hl: "ru", + callback: function (token) { + if (!token || !pendingSubmitter) { + showError(); + return; + } + var submitter = pendingSubmitter; + pendingSubmitter = null; + executing = false; + tokenInput.value = token; + if (submitter.name) { + var action = document.createElement("input"); + action.type = "hidden"; + action.name = submitter.name; + action.value = submitter.value; + form.appendChild(action); + } + HTMLFormElement.prototype.submit.call(form); + } + }); + window.smartCaptcha.subscribe(widgetId, "network-error", showError); + window.smartCaptcha.subscribe(widgetId, "javascript-error", showError); + window.smartCaptcha.subscribe(widgetId, "token-expired", function () { + tokenInput.value = ""; + if (executing) showError(); + }); + } catch (_error) { + showError(); + } + }; + + if (!form || !tokenInput) return; + form.addEventListener("submit", function (event) { + var submitter = event.submitter; + var requiresCaptcha = !resendOnly + || (submitter && submitter.name === "otp_action" && submitter.value === "resend"); + if (!requiresCaptcha) return; + event.preventDefault(); + if (executing) return; + if (error) error.hidden = true; + if (widgetId === null || !window.smartCaptcha) { + showError(); + return; + } + pendingSubmitter = submitter || document.getElementById(container.getAttribute("data-submit-id")); + executing = true; + if (pendingSubmitter) pendingSubmitter.disabled = true; + tokenInput.value = ""; + try { + window.smartCaptcha.execute(widgetId); + } catch (_error) { + showError(); + } + }); + } + initDeviceMetadata(); initPhoneForm(); initOtpForm(); + initCaptcha(); })(); diff --git a/codebase/backend/nginx/snippets/proxy-keycloak-captcha-csp.conf b/codebase/backend/nginx/snippets/proxy-keycloak-captcha-csp.conf new file mode 100644 index 0000000..c8a9281 --- /dev/null +++ b/codebase/backend/nginx/snippets/proxy-keycloak-captcha-csp.conf @@ -0,0 +1,2 @@ +proxy_hide_header Content-Security-Policy; +add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-src 'self' https://smartcaptcha.cloud.yandex.ru https://yastatic.net; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' https://smartcaptcha.cloud.yandex.ru https://yastatic.net; connect-src 'self' https://smartcaptcha.cloud.yandex.ru; img-src 'self' data: blob: https://smartcaptcha.cloud.yandex.ru https://yastatic.net; style-src 'self' 'unsafe-inline' https://yastatic.net; font-src 'self' data: https://yastatic.net" always; diff --git a/codebase/backend/nginx/templates/frontend-static.conf.template b/codebase/backend/nginx/templates/frontend-static.conf.template index ccc8aa3..4d7b3f3 100644 --- a/codebase/backend/nginx/templates/frontend-static.conf.template +++ b/codebase/backend/nginx/templates/frontend-static.conf.template @@ -19,6 +19,11 @@ location = /service-worker.js { add_header Cache-Control "no-cache"; include /etc/nginx/generated/security-headers.conf; } +location = /register-sw.js { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache"; + include /etc/nginx/generated/security-headers.conf; +} location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; diff --git a/codebase/backend/nginx/templates/site-tls.conf.template b/codebase/backend/nginx/templates/site-tls.conf.template index fe15115..10f6316 100644 --- a/codebase/backend/nginx/templates/site-tls.conf.template +++ b/codebase/backend/nginx/templates/site-tls.conf.template @@ -107,6 +107,20 @@ server { include /etc/nginx/snippets/proxy-keycloak.conf; proxy_pass http://keycloak_upstream; } + location = /auth/realms/han-chat/protocol/openid-connect/auth { + limit_req zone=auth burst=10; + include /etc/nginx/snippets/proxy-keycloak.conf; + include /etc/nginx/snippets/proxy-keycloak-captcha-csp.conf; + proxy_read_timeout 60s; + proxy_pass http://keycloak_upstream; + } + location = /auth/realms/han-chat/login-actions/authenticate { + limit_req zone=auth burst=10; + include /etc/nginx/snippets/proxy-keycloak.conf; + include /etc/nginx/snippets/proxy-keycloak-captcha-csp.conf; + proxy_read_timeout 60s; + proxy_pass http://keycloak_upstream; + } location ^~ /auth/realms/ { limit_req zone=auth burst=10; include /etc/nginx/snippets/proxy-keycloak.conf; diff --git a/codebase/backend/scripts/validate-env b/codebase/backend/scripts/validate-env index dbfe0bb..15a4e71 100644 --- a/codebase/backend/scripts/validate-env +++ b/codebase/backend/scripts/validate-env @@ -35,6 +35,7 @@ SECRET_KEYS = { } | { "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN", "IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD", + "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", } PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I) @@ -73,6 +74,16 @@ def main() -> int: 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") + captcha_enabled = env.get("KEYCLOAK_YANDEX_CAPTCHA_ENABLED", "false").lower() + if captcha_enabled not in {"true", "false"}: + errors.append("KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ожидается true или false") + if captcha_enabled == "true": + for key in ( + "KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY", + "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", + ): + if not env.get(key): + errors.append(f"{key}: обязательное значение при включённой CAPTCHA отсутствует") production = env.get("APP_ENV") in {"production-like", "production"} if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false": diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index 7183548..fc62339 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -43,7 +43,8 @@ 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.assertIn("networks: [public, backend, observability, egress]", application) + self.assertIn('KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY: ""', application) self.assertEqual( application.count( "IDGTL_SMS_API_KEY: ${IDGTL_SMS_API_KEY:?IDGTL_SMS_API_KEY is required}" @@ -99,6 +100,23 @@ 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 = /auth/realms/han-chat/protocol/openid-connect/auth", site + ) + self.assertIn( + "location = /auth/realms/han-chat/login-actions/authenticate", site + ) + captcha_csp = ( + ROOT / "nginx/snippets/proxy-keycloak-captcha-csp.conf" + ).read_text(encoding="utf-8") + self.assertIn("proxy_hide_header Content-Security-Policy", captcha_csp) + self.assertIn("smartcaptcha.cloud.yandex.ru", captcha_csp) + self.assertIn("yastatic.net", captcha_csp) + for directive in ("default-src 'self'", "base-uri 'self'", "form-action 'self'"): + self.assertIn(directive, captcha_csp) + self.assertNotIn("browserSecurityHeaders", ( + ROOT / "keycloak/realm/han-chat-realm.json" + ).read_text(encoding="utf-8")) self.assertIn("location = /callbacks/idgtl/sms", site) self.assertIn("allow 185.203.96.7;", site) self.assertIn("proxy_pass http://sms_service_upstream;", site) @@ -225,6 +243,9 @@ class InfrastructureConfigTests(unittest.TestCase): "KC_DB_SCHEMA", "KEYCLOAK_OTP_MOCK_ENABLED", "KEYCLOAK_OTP_MOCK_CODE", + "KEYCLOAK_YANDEX_CAPTCHA_ENABLED", + "KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY", + "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", "KEYCLOAK_OTP_HMAC_KEY", "KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", "KEYCLOAK_SETTINGS_BRIDGE_URL", @@ -264,6 +285,46 @@ class InfrastructureConfigTests(unittest.TestCase): ) self.assertEqual(result.returncode, 0, result.stderr) + def test_env_validator_requires_captcha_keys_only_when_enabled(self) -> None: + example = (ROOT / ".env.example").read_text(encoding="utf-8") + materialized = example.replace( + "change-me", "0123456789abcdef0123456789abcdef" + ).replace( + "KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false", + "KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true", + ) + enabled_without_keys = materialized.replace( + "KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false", + "KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true", + ) + enabled_with_keys = enabled_without_keys.replace( + "KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=", + "KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=client-key", + ).replace( + "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=", + "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=server-key-0123456789", + ) + with tempfile.TemporaryDirectory() as directory: + env_file = Path(directory) / ".env" + env_file.write_text(enabled_without_keys, encoding="utf-8") + missing = subprocess.run( + [sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)], + text=True, + capture_output=True, + check=False, + ) + env_file.write_text(enabled_with_keys, encoding="utf-8") + configured = subprocess.run( + [sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)], + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(missing.returncode, 0) + self.assertIn("KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY", missing.stderr) + self.assertIn("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", missing.stderr) + self.assertEqual(configured.returncode, 0, configured.stderr) + if __name__ == "__main__": unittest.main() diff --git a/modules/module-08-keycloak.md b/modules/module-08-keycloak.md index b9b0dc5..7d9240b 100644 --- a/modules/module-08-keycloak.md +++ b/modules/module-08-keycloak.md @@ -306,12 +306,14 @@ Frontend передаёт необязательные `han_device_id`, `han_fin 3. SPI product send limits per phone HMAC; 4. verify-attempt limit per challenge/phone/IP hash; 5. cooldown after repeated failures; -6. CAPTCHA/risk engine — future extension. +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 минимум: @@ -735,4 +737,4 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д - 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: CAPTCHA/risk scoring после mock. +- K-TBD9 закрыт для v1: одна невидимая Yandex SmartCaptcha защищает initial send и resend; динамический risk scoring остаётся вне scope. diff --git a/releases/notification-requirements.md b/releases/notification-requirements.md new file mode 100644 index 0000000..8a5b8f9 --- /dev/null +++ b/releases/notification-requirements.md @@ -0,0 +1,172 @@ +Подготовь бизнес-требования для создания новой функциональности приложения - уведомлений. +Я обновил макет в фигма. +Там следующие существенные изменения: +1) На главном экране под окном чата размещается не одна кнопка, а две - вход в чат и звонок оператору. +2) В верхнем меню ссылка на историю чата заменена на Центр уведомлений. + +## Уведомления - Это новая бизнес сущность. +Для гостевого режима перечень уведомлений одинаковый для всех посетителей. +Для авторизованного режима перечень уведомлений персоналирован под каждого пользователя. + +Уведомления могут быть разных видов (каждый label - это отдельный вид уведомления с различными сценариями поведения при нажатии, с различной цветовой гаммой и различными иконками): +[ + { + "type": "authorize" + "label": "Гостевой режим", + "header": "Вы в гостевом режиме", + "text": "Авторизуйтесь для получения полноценного доступа к функционалу приложения", + "CTA": 'Войти →' + "action": Переход к сценарию авторизации (/auth/consent → /auth/phone) + "countable": no + }, + { + "type": "install_Android" + "label": "Приложение", + "header": "Установите приложение", + "text": "Установите приложение, чтобы быть всегда на связи", + "CTA": ' Установить →' + "action": запустить скрипт установки PWA приложения + "countable": no + }, + { + "type": "install_IOS-HarmonyOS" + "label": "Приложение", + "header": "Установите приложение", + "text": "Установите приложение, чтобы быть всегда на связи", + "CTA": ' Как установить →' + "action": Переход на страницу детального просмотра уведомления + "countable": no + }, + { + "type": "emergency" + "label": "Срочно", + "header": String, + "text": String, + "CTA": 'Подробнее →' + "action": Переход на страницу детального просмотра уведомления + "source": notification-service + "countable": yes + }, + { + "type": "memo" + "label": "Напоминание", + "header": String, + "text": String, + "CTA": 'Подробнее →' + "action": Переход на страницу детального просмотра уведомления + "source": notification-service + "countable": yes + }, + { + "type": "news" + "label": "Новость", + "header": String, + "text": String, + "CTA": 'Подробнее →' + "action": Переход на страницу детального просмотра уведомления + "source": notification-service + "countable": yes + }, + { + "type": "new_message" + "label": "Сообщение", + "header": String, + "text": String, + "CTA": ' Открыть чат →' + "action": "Переход в чат с консультантом" + "source": han-app + "countable": yes + }, + { + "type": "ads" + "label": "Предложение", + "header": String, + "text": String, + "action": Переход на страницу детального просмотра уведомления + "price": INT "₽" + "old price": INT "₽" + "CTA": 'Узнать подробнее →' + "source": notification-service + "countable": yes + }, + { + "type": "promo" + "label": "Акция", + "header": String, + "text": String, + "action": Переход на страницу детального просмотра уведомления + "price": INT "₽" + "old price": INT "₽" + "CTA": 'Узнать подробнее →' + "source": ??? (public api) + "countable": yes + } +] + +Уведомления могут создаваться различными сервисами. На текущий момент создание уведомлений выглядит следующим образом (жесткую привязку не нужно делать сервисов и видов уведомлений, это то что нужно реализовать сейчас): + +1. В неавторизованной зоне: +1.1. Фронтенд самостоятельно создает уведомления для неавторизованных пользователей. Это уведомления, призывающие пользователя авторизоваться, скачать приложение: + 1.1.1. authorize + 1.1.2. install_Android (пользователь зашел с мобильного телефона на ОС Андроид, и у него не установлено приложение); + 1.1.3. install_IOS-HarmonyOS (пользователь зашел с мобильного телефона на iOS или HarmonyOS, и у него не установлено приложение); +1.2. Уведомления, которые фронтенд получает по публичному API (это могут быть рекламные или информационные сообщения) + 1.2.1. ads + 1.2.2. promo + +2. В авторизованной зоне: +2.1. han-app создает автоматизированные уведомления: + 2.1.1. new_message - в ситуации, когда пользователю было отправлено сообщение оператором bitrix24, а пользователь его не прочитал в течение 1 минуты. +2.2. han-app получает по API из приватной сети от сервисов, расположенных на других ВМ (не внутри контейнеров): + 2.2.1. emergency + 2.2.2. memo + 2.2.3. news + 2.2.4. ads + 2.2.5. promo + +Само уведомление состоит из двух частей: +1. Для вывода на баннере на главном экране или в центре уведомлений +2. Для вывода на отдельной странице уведомления (только для тех, у которых есть опция перехода на страницу детального просмотра уведомления) + +Уведомление отправляет запрос по api в han-app, в котором указывает следующие поля + "client_id" not-nullable, + "notification_type_id" not-nullable integer, + "date_expired" nullable date, + "header": not-nullable string, + "text": nullable string, + "price": nullable number(10,2), + "old price": nullable number(10,2), + "details": nullable object (не присылается, если нет перехода на детальный просмотр) + "deadline" nullable (может не быть срока.. если есть - визуализация на карточке "Срочно" + "details_header" nullable string, + "details_text" nullable string, + "todo_header" nullable string, + "todo_plan": nullable object (если присылается, то должна быть как минимум одна пара значений: номер пункта плана и текст) + "todo_NN" not-nullable integer + "todo_text" not-nullable string + "button_done" boolean + + +Если button_done = true, пользователю отображается кнопка "Выполнено". Нажатие на кнопку переводит уведомление в статус N с причиной "user_done" + + +Han-app должен предоставлять API методы: +- добавление уведомления пользователю +- смена статуса уведомления пользователю на N +API методы недоступны из интернета, только внутри приватной сети внутри ВМ. + + +Каждое персонализированное уведомление имеет следующие атрибуты (это бизнес-атрибуты): +1. Статус - A(актуальное)\N(неактуальное). Если уведомление актуальное - оно отображается в ЛК клиента. Если неактуальное - не отображается. +2. Видимость - скрыто\нескрыто. Как работает: у пользователя на главном экране есть возможность скрыть уведомление, нажав на "крестик" на карточке уведомления. При нажатии на "крестик" уведомление скрывается из главного экрана (приложение должно сообщить об этом бэкенду, чтобы повторно не отображать на главном экране), но уведомление остается в центре уведомлений. Это применимо только для уведомлений авторизованных пользователей. В неавторизованной зоне пользователь не может закрыть уведомление (крестик не должен отображаться). +3. Прочтение - прочитано или нет. Уведомление прочитано - если пользователь переходил на страницу детального просмотра уведомления. Применяется только для уведомлений, у которых "countable": yes +4. Причина закрытия статуса (может быть "user_done" - если пользователь в приложении нажан на кнопку "Выполнено"; "expired" - если у уведомления была назначена date_expired; "cancelled" - если по АПИ пришел запрос на отмену уведомления) +5. + +Большинство смысловых уведомлений + + + +## Центр уведомлений: +На экране на колокольчике красным цветом выделяется счетчик непрочитанных сообщений (из числа "countable": yes) +В центре уведомлений непрочитанные уведомления отмечаются красной точкой. Сортировка уведомлений осуществляется по принципу: countable: yes вверху, далее непрочитанные вверху\прочитанные внизу, далее по дате уведомления от новых к старым.