Добавлена Яндекс.Капча
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+13
-1
@@ -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");
|
||||
}
|
||||
|
||||
+13
-1
@@ -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");
|
||||
}
|
||||
|
||||
+121
@@ -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() {
|
||||
|
||||
+28
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -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&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&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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user