Добавлена Яндекс.Капча

This commit is contained in:
mi
2026-07-23 18:53:32 +03:00
parent 31efbf3b69
commit 8c3c454998
36 changed files with 836 additions and 35 deletions
@@ -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.
@@ -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
# =============================================================================
+2
View File
@@ -17,6 +17,8 @@
16. Сделать тестового пользователя с фиксированным СМС-входом
~~17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)~~
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
19. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
20. Убрать с экрана при запросе OTP тексты согласий (внизу экрана)
На будущее (после доработки отдельных функциональностей):
1. Разработка message-safety
+4
View File
@@ -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
+18
View File
@@ -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
@@ -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-сервера:
@@ -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 (
<html lang="ru">
@@ -19,9 +11,9 @@ export default function Root({ children }: PropsWithChildren) {
<meta name="theme-color" content="#030213" />
<meta name="description" content="Помощник по документам и жизни в России" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="360x360" href="/apple-touch-icon.png" />
<script dangerouslySetInnerHTML={{ __html: serviceWorkerBootstrap }} />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<script src="/register-sw.js" defer />
<ScrollViewStyleReset />
</head>
<body>{children}</body>
@@ -7,7 +7,7 @@ export default defineConfig({
reporter: "list",
use: { baseURL: "http://127.0.0.1:4173", trace: "on-first-retry" },
webServer: {
command: "npm run build && npx serve dist -l 4173",
command: "npm run build:pwa && npx serve dist -l 4173",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
@@ -1,23 +1,24 @@
{
"short_name": "HAN Chat",
"name": "HAN Chat",
"id": "/",
"description": "Помощник по документам и жизни в России",
"icons": [
{
"src": "/favicon.png",
"sizes": "64x64",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "360x360",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "1024x1024",
"sizes": "512x512",
"purpose": "any maskable"
}
],
@@ -27,5 +28,7 @@
"orientation": "portrait",
"theme_color": "#030213",
"background_color": "#ffffff",
"lang": "ru"
"lang": "ru",
"dir": "ltr",
"categories": ["productivity", "utilities"]
}
@@ -0,0 +1,7 @@
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js", { updateViaCache: "none" })
.catch((error) => console.warn("Service worker registration failed:", error));
});
}
@@ -1,5 +1,23 @@
import { expect, test } from "@playwright/test";
test("PWA-манифест и Service Worker доступны", async ({ request }) => {
const manifestResponse = await request.get("/manifest.json");
expect(manifestResponse.ok()).toBe(true);
const manifest = await manifestResponse.json();
expect(manifest).toMatchObject({
name: "HAN Chat",
display: "standalone",
icons: expect.arrayContaining([
expect.objectContaining({ src: "/icon-192.png", sizes: "192x192" }),
expect.objectContaining({ src: "/icon-512.png", sizes: "512x512" }),
]),
});
const serviceWorkerResponse = await request.get("/service-worker.js");
expect(serviceWorkerResponse.ok()).toBe(true);
expect(await serviceWorkerResponse.text()).toContain("precacheAndRoute");
});
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/public/app-config", (route) => route.fulfill({
json: {
@@ -4,6 +4,7 @@ module.exports = {
globPatterns: ["**/*.{js,css,html,ico,png,json,woff2,svg,webp}"],
globIgnores: ["**/node_modules/**"],
swDest: "dist/service-worker.js",
cleanupOutdatedCaches: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api/, /^\/auth/],
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/auth(?:\/|$)/],
};
@@ -5,6 +5,9 @@ x-no-sms-secrets: &no-sms-secrets
IDGTL_SMS_API_KEY: ""
IDGTL_SMS_CALLBACK_USERNAME: ""
IDGTL_SMS_CALLBACK_PASSWORD: ""
KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ""
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: ""
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY: ""
x-api-runtime: &api-runtime
build:
@@ -93,6 +96,9 @@ services:
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ${KEYCLOAK_YANDEX_CAPTCHA_ENABLED:-false}
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: ${KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY:-}
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY: ${KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
@@ -106,7 +112,7 @@ services:
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
networks: [public, backend, observability]
networks: [public, backend, observability, egress]
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.0\r\n\r\n' >&3 && grep -q '200 OK' <&3"]
interval: 15s
+3
View File
@@ -6,6 +6,9 @@ KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
+14
View File
@@ -31,6 +31,20 @@ The Maven build shades only libphonenumber into the provider JAR; Keycloak SPI d
Copy values from `.env.example` into the root backend `.env`; never commit `.env`. Generate independent random values for admin password, mock code, OTP HMAC key and settings bridge token.
### Yandex SmartCaptcha
Invisible SmartCaptcha protects every operation that orders an OTP SMS, including resend. It is disabled by default. To enable it, create one CAPTCHA in Yandex Cloud, add the public login hostname (without `https://`) to allowed sites and set:
```env
KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=<public-client-key>
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=<secret-server-key>
```
The browser token is validated by Keycloak before `OtpFlow.start()`. A provider rejection, missing token or non-temporary HTTP 4xx denies the SMS order. Timeout, I/O, HTTP 408/429/5xx and malformed provider responses are logged without token/phone/keys and handled fail-open. Tokens are one-time and a resend always executes a fresh CAPTCHA.
SmartCaptcha CSP is applied only by nginx to the `han-chat` login and login-action endpoints. Never set a custom `browserSecurityHeaders.contentSecurityPolicy` in the realm: it can break Keycloak Admin Console and third-party cookie iframes.
Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`:
- `https://APP_LINK_HOST.example/auth/callback`
@@ -22,6 +22,9 @@ services:
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ${KEYCLOAK_YANDEX_CAPTCHA_ENABLED:-false}
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: ${KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY:-}
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY: ${KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
@@ -38,6 +41,7 @@ services:
- public
- backend
- observability
- egress
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"]
interval: 15s
@@ -59,3 +63,4 @@ networks:
public:
backend:
observability:
egress:
+6
View File
@@ -36,6 +36,12 @@
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
@@ -6,6 +6,12 @@ import java.time.Duration;
final class Config {
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
static final String MOCK_CODE = env("KEYCLOAK_OTP_MOCK_CODE", "");
static final boolean CAPTCHA_ENABLED = bool("KEYCLOAK_YANDEX_CAPTCHA_ENABLED", false);
static final String CAPTCHA_CLIENT_KEY = env("KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY", "");
static final String CAPTCHA_SERVER_KEY = env("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", "");
static final URI CAPTCHA_VALIDATE_URL =
URI.create("https://smartcaptcha.cloud.yandex.ru/validate");
static final Duration CAPTCHA_TIMEOUT = Duration.ofMillis(1500);
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
@@ -26,6 +32,11 @@ final class Config {
throw new IllegalStateException(
"KEYCLOAK_SMS_SERVICE_TOKEN must contain at least 32 bytes in SMS mode");
}
if (CAPTCHA_ENABLED
&& (CAPTCHA_CLIENT_KEY.isBlank() || CAPTCHA_SERVER_KEY.length() < 16)) {
throw new IllegalStateException(
"Yandex CAPTCHA client key and server key (at least 16 characters) are required");
}
if (HMAC_KEY.length < 32) {
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
}
@@ -51,7 +62,10 @@ final class Config {
}
private static boolean bool(String name, boolean fallback) {
return Boolean.parseBoolean(env(name, Boolean.toString(fallback)));
String value = env(name, Boolean.toString(fallback));
if ("true".equalsIgnoreCase(value)) return true;
if ("false".equalsIgnoreCase(value)) return false;
throw new IllegalStateException(name + " must be true or false");
}
private static int integer(String name, int fallback, int min, int max) {
@@ -29,9 +29,19 @@ public final class PhoneIdentityAuthenticator implements Authenticator {
@Override
public void action(AuthenticationFlowContext context) {
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
String captchaToken =
context.getHttpRequest().getDecodedFormParameters().getFirst("smart-token");
DeviceMetadata device = DeviceMetadata.capture(context);
try {
String phone = normalizer.normalize(rawPhone);
if (Config.CAPTCHA_ENABLED
&& YandexSmartCaptchaClient.get().verify(captchaToken, device.clientIp())
== YandexSmartCaptchaClient.Result.REJECTED) {
context.failureChallenge(
AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
phoneForm(context, "captchaInvalid", device));
return;
}
SettingsBridge.Settings settings = SettingsBridge.get();
var challenge = OtpFlow.start(context, phone, settings, device);
context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone);
@@ -63,7 +73,9 @@ public final class PhoneIdentityAuthenticator implements Authenticator {
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.setAttribute("hanAppVersion", device.appVersion());
.setAttribute("hanAppVersion", device.appVersion())
.setAttribute("captchaEnabled", Config.CAPTCHA_ENABLED)
.setAttribute("captchaClientKey", Config.CAPTCHA_CLIENT_KEY);
if (messageKey != null) form.setError(messageKey);
return form.createForm("phone.ftl");
}
@@ -29,6 +29,8 @@ public final class PhoneOtpAuthenticator implements Authenticator {
String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE);
String action = context.getHttpRequest().getDecodedFormParameters().getFirst("otp_action");
String code = context.getHttpRequest().getDecodedFormParameters().getFirst("otp");
String captchaToken =
context.getHttpRequest().getDecodedFormParameters().getFirst("smart-token");
if (challengeId == null || phone == null) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
@@ -36,6 +38,14 @@ public final class PhoneOtpAuthenticator implements Authenticator {
DeviceMetadata device = DeviceMetadata.capture(context);
if ("resend".equals(action)) {
try {
if (Config.CAPTCHA_ENABLED
&& YandexSmartCaptchaClient.get().verify(captchaToken, device.clientIp())
== YandexSmartCaptchaClient.Result.REJECTED) {
context.failureChallenge(
AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
otpForm(context, PhoneNormalizer.mask(phone), "captchaInvalid"));
return;
}
var challenge = OtpFlow.start(context, phone, SettingsBridge.get(), device);
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.CHALLENGE_NOTE, challenge.id);
@@ -98,7 +108,9 @@ public final class PhoneOtpAuthenticator implements Authenticator {
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.setAttribute("hanAppVersion", device.appVersion());
.setAttribute("hanAppVersion", device.appVersion())
.setAttribute("captchaEnabled", Config.CAPTCHA_ENABLED)
.setAttribute("captchaClientKey", Config.CAPTCHA_CLIENT_KEY);
if (messageKey != null) form.setError(messageKey);
return form.createForm("otp.ftl");
}
@@ -0,0 +1,121 @@
package ru.han.chat.keycloak;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import org.jboss.logging.Logger;
import org.keycloak.util.JsonSerialization;
final class YandexSmartCaptchaClient {
enum Result { PASSED, REJECTED, BYPASSED }
private static final Logger LOG = Logger.getLogger(YandexSmartCaptchaClient.class);
private static final class Holder {
private static final YandexSmartCaptchaClient INSTANCE =
new YandexSmartCaptchaClient();
}
private final HttpClient client;
private final URI validateUrl;
private final String serverKey;
private final Duration timeout;
YandexSmartCaptchaClient() {
this(HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Config.CAPTCHA_TIMEOUT)
.build(),
Config.CAPTCHA_VALIDATE_URL,
Config.CAPTCHA_SERVER_KEY,
Config.CAPTCHA_TIMEOUT);
}
YandexSmartCaptchaClient(
HttpClient client, URI validateUrl, String serverKey, Duration timeout) {
this.client = client;
this.validateUrl = validateUrl;
this.serverKey = serverKey;
this.timeout = timeout;
}
static YandexSmartCaptchaClient get() {
return Holder.INSTANCE;
}
Result verify(String token, String clientIp) {
long startedAt = System.nanoTime();
if (token == null || token.isBlank() || token.length() > 8192) {
log("rejected", "missing_or_invalid_token", startedAt, false);
return Result.REJECTED;
}
String body = form("secret", serverKey)
+ "&" + form("token", token)
+ (clientIp == null || clientIp.isBlank() ? "" : "&" + form("ip", clientIp));
HttpRequest request = HttpRequest.newBuilder(validateUrl)
.timeout(timeout)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
boolean temporary = response.statusCode() == 408
|| response.statusCode() == 429
|| response.statusCode() >= 500;
log(
temporary ? "bypassed" : "rejected",
"http_" + response.statusCode(),
startedAt,
true);
return temporary ? Result.BYPASSED : Result.REJECTED;
}
Object status = JsonSerialization.readValue(response.body(), Map.class).get("status");
if (!"ok".equals(status) && !"failed".equals(status)) {
log("bypassed", "invalid_response", startedAt, true);
return Result.BYPASSED;
}
if ("ok".equals(status)) {
log("passed", "ok", startedAt, false);
return Result.PASSED;
}
log("rejected", "provider_rejected", startedAt, false);
return Result.REJECTED;
} catch (java.net.http.HttpTimeoutException exception) {
log("bypassed", "timeout", startedAt, true);
return Result.BYPASSED;
} catch (IOException exception) {
log("bypassed", "io", startedAt, true);
return Result.BYPASSED;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
log("bypassed", "interrupted", startedAt, true);
return Result.BYPASSED;
} catch (RuntimeException exception) {
log("rejected", "client_error", startedAt, true);
return Result.REJECTED;
}
}
private static String form(String name, String value) {
return URLEncoder.encode(name, StandardCharsets.UTF_8)
+ "=" + URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static void log(
String outcome, String reason, long startedAt, boolean warning) {
long durationMs = (System.nanoTime() - startedAt) / 1_000_000L;
String message = "captcha.validation outcome=%s reason=%s duration_ms=%d"
.formatted(outcome, reason, durationMs);
if (warning) LOG.warn(message);
else LOG.info(message);
}
}
@@ -36,6 +36,8 @@ class RealmContractTest {
assertFalse(realm.contains("\"webOrigins\": [\"*\"]"));
assertFalse(realm.contains("\"redirectUris\": [\"*\"]"));
assertFalse(realm.contains("\"secret\":"));
assertFalse(realm.contains("KEYCLOAK_YANDEX_CAPTCHA"));
assertFalse(realm.contains("\"browserSecurityHeaders\""));
}
private static String readRealm() {
@@ -38,4 +38,32 @@ class SmsLifecycleContractTest {
assertTrue(script.contains("expiresAt - Date.now()"));
assertTrue(script.contains("Number.isFinite(expiresAt)"));
}
@Test
void captchaProtectsInitialSendAndResendWithFreshTokens() throws Exception {
String phone = Files.readString(Path.of("themes/han-phone/login/phone.ftl"));
String otp = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
String styles = Files.readString(Path.of("themes/han-phone/login/resources/css/han-login.css"));
String identity = Files.readString(Path.of(
"src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java"));
String verifier = Files.readString(Path.of(
"src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java"));
assertTrue(phone.contains("name=\"smart-token\""));
assertTrue(phone.contains("captchaClientKey"));
assertTrue(otp.contains("data-resend-only=\"true\""));
assertTrue(otp.contains("name=\"smart-token\""));
assertTrue(script.contains("window.smartCaptcha.execute(widgetId)"));
assertTrue(script.contains("\"network-error\", showError"));
assertTrue(script.contains("\"javascript-error\", showError"));
assertTrue(script.contains("\"token-expired\""));
assertTrue(script.contains("tokenInput.value = \"\""));
assertTrue(styles.contains(".han-error[hidden]"));
String captchaGate = "YandexSmartCaptchaClient.get().verify";
assertTrue(identity.contains(captchaGate));
assertTrue(verifier.contains(captchaGate));
assertTrue(identity.indexOf(captchaGate) < identity.indexOf("OtpFlow.start"));
assertTrue(verifier.indexOf(captchaGate) < verifier.indexOf("OtpFlow.start"));
}
}
@@ -0,0 +1,130 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class YandexSmartCaptchaClientTest {
@Test
void passesValidTokenAndUrlEncodesSensitiveFormValues() throws Exception {
try (CaptchaServer server = new CaptchaServer(200, "{\"status\":\"ok\"}", 0)) {
YandexSmartCaptchaClient client = server.client("secret +&=", Duration.ofSeconds(1));
assertEquals(
YandexSmartCaptchaClient.Result.PASSED,
client.verify("token +&=", "203.0.113.7"));
assertTrue(server.body().contains("secret=secret+%2B%26%3D"));
assertTrue(server.body().contains("token=token+%2B%26%3D"));
assertTrue(server.body().contains("ip=203.0.113.7"));
}
}
@Test
void rejectsProviderFailureAndMissingToken() throws Exception {
try (CaptchaServer server = new CaptchaServer(200, "{\"status\":\"failed\"}", 0)) {
YandexSmartCaptchaClient client = server.client("secret", Duration.ofSeconds(1));
assertEquals(
YandexSmartCaptchaClient.Result.REJECTED,
client.verify("token", "203.0.113.7"));
assertEquals(
YandexSmartCaptchaClient.Result.REJECTED,
client.verify("", "203.0.113.7"));
assertEquals(1, server.calls());
}
}
@Test
void bypassesHttpMalformedAndTimeoutFailures() throws Exception {
try (CaptchaServer http = new CaptchaServer(503, "unavailable", 0);
CaptchaServer malformed = new CaptchaServer(
200, "{\"data\":{\"status\":\"ok\"}}", 0);
CaptchaServer slow = new CaptchaServer(200, "{\"status\":\"ok\"}", 250)) {
assertEquals(
YandexSmartCaptchaClient.Result.BYPASSED,
http.client("secret", Duration.ofSeconds(1)).verify("token", null));
assertEquals(
YandexSmartCaptchaClient.Result.BYPASSED,
malformed.client("secret", Duration.ofSeconds(1)).verify("token", null));
assertEquals(
YandexSmartCaptchaClient.Result.BYPASSED,
slow.client("secret", Duration.ofMillis(50)).verify("token", null));
}
}
@Test
void rejectsNonTemporaryHttpClientErrors() throws Exception {
try (CaptchaServer server = new CaptchaServer(
400, "{\"status\":\"failed\"}", 0)) {
assertEquals(
YandexSmartCaptchaClient.Result.REJECTED,
server.client("secret", Duration.ofSeconds(1)).verify("token", null));
}
}
@Test
void tokenAndSecretNeverAppearInResult() throws Exception {
try (CaptchaServer server = new CaptchaServer(500, "secret-token", 0)) {
String secret = "server-secret-must-not-leak";
String token = "captcha-token-must-not-leak";
String result = server.client(secret, Duration.ofSeconds(1))
.verify(token, null).name();
assertFalse(result.contains(secret));
assertFalse(result.contains(token));
}
}
private static final class CaptchaServer implements AutoCloseable {
private final HttpServer server;
private final AtomicReference<String> body = new AtomicReference<>("");
private final AtomicInteger calls = new AtomicInteger();
CaptchaServer(int status, String responseBody, long delayMs) throws Exception {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/validate", exchange -> {
calls.incrementAndGet();
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
try {
if (delayMs > 0) Thread.sleep(delayMs);
byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, response.length);
exchange.getResponseBody().write(response);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
} finally {
exchange.close();
}
});
server.start();
}
YandexSmartCaptchaClient client(String secret, Duration timeout) {
URI uri = URI.create(
"http://127.0.0.1:" + server.getAddress().getPort() + "/validate");
return new YandexSmartCaptchaClient(
HttpClient.newHttpClient(), uri, secret, timeout);
}
String body() {
return body.get();
}
int calls() {
return calls.get();
}
@Override
public void close() {
server.stop(0);
}
}
}
@@ -23,3 +23,5 @@ otpInvalid=Код неверен, истёк или уже использова
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.
captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите.
@@ -2,7 +2,7 @@
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">${msg("otpTitle")}
<#elseif section = "form">
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=4"/>
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=6"/>
<div class="han-auth-screen han-otp-screen">
<button class="han-back-button" type="button" onclick="window.history.back()">
<span aria-hidden="true">←</span>
@@ -22,6 +22,18 @@
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<#if captchaEnabled!false>
<input id="han-captcha-token" name="smart-token" type="hidden" value=""/>
<div id="han-captcha-container" class="han-captcha"
data-sitekey="${captchaClientKey!""}"
data-form-id="kc-otp-form"
data-submit-id="han-resend-button"
data-resend-only="true"></div>
<div id="han-captcha-client-error" class="han-error" role="alert" hidden>
<span class="han-error-icon">!</span>
<span>${msg("captchaUnavailable")}</span>
</div>
</#if>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>"
style="grid-template-columns: repeat(${otpCodeLength!6}, minmax(0, 1fr));">
<#list 0..((otpCodeLength!6) - 1) as index>
@@ -54,6 +66,10 @@
</button>
</form>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
<#if captchaEnabled!false>
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&amp;onload=hanCaptchaOnload"
async defer></script>
</#if>
</#if>
</@layout.registrationLayout>
@@ -2,7 +2,7 @@
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">${msg("phoneTitle")}
<#elseif section = "form">
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=4"/>
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=6"/>
<div class="han-auth-screen han-phone-screen">
<div class="han-auth-main">
<div class="han-wordmark" aria-label="HAN">
@@ -31,6 +31,18 @@
<p class="han-field-hint">${msg("phoneCountry")}</p>
</div>
<#if captchaEnabled!false>
<input id="han-captcha-token" name="smart-token" type="hidden" value=""/>
<div id="han-captcha-container" class="han-captcha"
data-sitekey="${captchaClientKey!""}"
data-form-id="kc-phone-form"
data-submit-id="han-phone-submit"></div>
<div id="han-captcha-client-error" class="han-error" role="alert" hidden>
<span class="han-error-icon">!</span>
<span>${msg("captchaUnavailable")}</span>
</div>
</#if>
<#if message?has_content>
<div class="han-error" role="alert">
<span class="han-error-icon">!</span>
@@ -52,6 +64,10 @@
<span>${msg("privacyPolicy")}</span>
</p>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
<#if captchaEnabled!false>
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&amp;onload=hanCaptchaOnload"
async defer></script>
</#if>
</#if>
</@layout.registrationLayout>
@@ -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;
@@ -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();
})();
@@ -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;
@@ -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;
@@ -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;
+11
View File
@@ -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":
+62 -1
View File
@@ -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()
+4 -2
View File
@@ -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.
+172
View File
@@ -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": '<mini icon download> Установить →'
"action": запустить скрипт установки PWA приложения
"countable": no
},
{
"type": "install_IOS-HarmonyOS"
"label": "Приложение",
"header": "Установите приложение",
"text": "Установите приложение, чтобы быть всегда на связи",
"CTA": '<mini icon download> Как установить →'
"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": '<mini icon chat> Открыть чат →'
"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 вверху, далее непрочитанные вверху\прочитанные внизу, далее по дате уведомления от новых к старым.