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