Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,76 @@
package ru.han.chat.keycloak;
import java.net.URI;
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));
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
"http://api-backend:8000/internal/settings/v1/otp"));
static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN");
static final URI SMS_SERVICE_URL = URI.create(env("KEYCLOAK_SMS_SERVICE_URL",
"http://sms-service:8080")).resolve("/internal/sms/v1/send");
static final String SMS_SERVICE_TOKEN = env("KEYCLOAK_SMS_SERVICE_TOKEN", "");
static {
if (MOCK_ENABLED && (!MOCK_CODE.matches("\\d{6,10}") || "1234".equals(MOCK_CODE))) {
throw new IllegalStateException(
"KEYCLOAK_OTP_MOCK_CODE must be a non-default numeric code of 6 to 10 digits");
}
if (!MOCK_ENABLED
&& SMS_SERVICE_TOKEN.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
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");
}
}
private Config() {}
static void validate() {
// Class initialization performs the fail-closed validation.
}
private static String required(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " is required");
}
return value;
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
private static boolean bool(String name, boolean 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) {
int value = Integer.parseInt(env(name, Integer.toString(fallback)));
if (value < min || value > max) throw new IllegalStateException(name + " is outside allowed range");
return value;
}
}
@@ -0,0 +1,46 @@
package ru.han.chat.keycloak;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
final class Crypto {
private static final SecureRandom RANDOM = new SecureRandom();
private Crypto() {}
static String randomId() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String randomNumericCode(int length) {
if (length < 4 || length > 10) {
throw new IllegalArgumentException("OTP length must be between 4 and 10");
}
StringBuilder code = new StringBuilder(length);
for (int index = 0; index < length; index++) {
code.append(RANDOM.nextInt(10));
}
return code.toString();
}
static String hmac(String purpose, String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(Config.HMAC_KEY, "HmacSHA256"));
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal((purpose + "\0" + value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("HMAC unavailable", exception);
}
}
static boolean constantTimeEquals(String left, String right) {
return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,66 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.MultivaluedMap;
import java.util.Set;
import org.keycloak.authentication.AuthenticationFlowContext;
record DeviceMetadata(
String clientIp,
String userAgent,
String deviceId,
String fingerprint,
String osName,
String osVersion,
String platform,
String appVersion) {
private static final Set<String> PLATFORMS = Set.of("web", "ios", "android");
static DeviceMetadata capture(AuthenticationFlowContext context) {
MultivaluedMap<String, String> form = context.getHttpRequest().getDecodedFormParameters();
var session = context.getAuthenticationSession();
MultivaluedMap<String, String> query = context.getHttpRequest().getUri().getQueryParameters();
String deviceId = value(form, query, session.getAuthNote("han.device_id"), "han_device_id", 256);
String fingerprint = value(form, query, session.getAuthNote("han.fingerprint"), "han_fingerprint", 256);
String osName = value(form, query, session.getAuthNote("han.os_name"), "han_os_name", 64);
String osVersion = value(form, query, session.getAuthNote("han.os_version"), "han_os_version", 64);
String platform = value(form, query, session.getAuthNote("han.platform"), "han_platform", 16);
String appVersion = value(form, query, session.getAuthNote("han.app_version"), "han_app_version", 64);
if (platform != null && !PLATFORMS.contains(platform)) platform = null;
save(session, "han.device_id", deviceId);
save(session, "han.fingerprint", fingerprint);
save(session, "han.os_name", osName);
save(session, "han.os_version", osVersion);
save(session, "han.platform", platform);
save(session, "han.app_version", appVersion);
return new DeviceMetadata(
clean(context.getConnection().getRemoteAddr(), 64),
clean(context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent"), 1024),
deviceId, fingerprint, osName, osVersion, platform, appVersion);
}
private static String value(
MultivaluedMap<String, String> form,
MultivaluedMap<String, String> query,
String saved,
String name,
int max) {
String submitted = form.getFirst(name);
if (submitted == null) submitted = query.getFirst(name);
return clean(submitted == null ? saved : submitted, max);
}
private static void save(
org.keycloak.sessions.AuthenticationSessionModel session, String name, String value) {
if (value == null) session.removeAuthNote(name);
else session.setAuthNote(name, value);
}
private static String clean(String value, int max) {
if (value == null || value.isBlank() || value.length() > max) return null;
for (int i = 0; i < value.length(); i++) {
if (Character.isISOControl(value.charAt(i))) return null;
}
return value;
}
}
@@ -0,0 +1,38 @@
package ru.han.chat.keycloak;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.utils.KeycloakModelUtils;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
final class OtpFlow {
private OtpFlow() {}
static OtpChallengeEntity start(
AuthenticationFlowContext context,
String phone,
SettingsBridge.Settings settings,
DeviceMetadata device) {
OtpStore.Reservation reservation = KeycloakModelUtils.runJobInTransactionWithResult(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).reserve(phone, settings, device));
OtpChallengeEntity challenge = reservation.challenge();
if (!Config.MOCK_ENABLED) {
String challengeId = challenge.id;
try {
String requestId = context.getHttpRequest().getHttpHeaders().getHeaderString("X-Request-ID");
String traceparent = context.getHttpRequest().getHttpHeaders().getHeaderString("traceparent");
SmsOrderClient.OrderResult order = new SmsOrderClient().order(
challengeId, phone, reservation.otp(), settings, requestId, traceparent);
challenge = KeycloakModelUtils.runJobInTransactionWithResult(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).activate(challengeId, order, device));
} catch (RuntimeException exception) {
KeycloakModelUtils.runJobInTransaction(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).orderFailed(challengeId, device));
throw exception;
}
}
return challenge;
}
}
@@ -0,0 +1,215 @@
package ru.han.chat.keycloak;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
import ru.han.chat.keycloak.entity.OtpSecurityEventEntity;
import ru.han.chat.keycloak.entity.OtpSendCounterEntity;
final class OtpStore {
private final EntityManager entityManager;
OtpStore(KeycloakSession session) {
this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
Reservation reserve(String phone, SettingsBridge.Settings settings, DeviceMetadata device) {
Instant now = Instant.now();
String phoneHmac = Crypto.hmac("phone", phone);
OtpSendCounterEntity counter = entityManager.find(
OtpSendCounterEntity.class, phoneHmac, LockModeType.PESSIMISTIC_WRITE);
if (counter == null) {
counter = new OtpSendCounterEntity();
counter.id = phoneHmac;
counter.phoneHmac = phoneHmac;
counter.windowStart = now;
counter.lastSentAt = Instant.EPOCH;
counter.sendCount = 0;
entityManager.persist(counter);
} else if (counter.windowStart.plus(Duration.ofHours(24)).isBefore(now)) {
counter.windowStart = now;
counter.sendCount = 0;
}
expireDue(now);
if (counter.sendCount >= settings.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, null, "limited", "daily_limit", device);
throw new OtpLimitException("otp_send_limited");
}
if (counter.lastSentAt.plusSeconds(settings.minSecondsBetween()).isAfter(now)) {
event("otp_send", phoneHmac, null, null, "limited", "cooldown", device);
throw new OtpLimitException("otp_send_cooldown");
}
counter.sendCount++;
counter.lastSentAt = now;
entityManager.createQuery("""
update OtpChallengeEntity c set c.challengeStatus = 'superseded'
where c.phoneHmac = :phone and c.challengeStatus in ('active', 'ordering')
""").setParameter("phone", phoneHmac).executeUpdate();
OtpChallengeEntity challenge = new OtpChallengeEntity();
challenge.id = Crypto.randomId();
String otp = Config.MOCK_ENABLED ? Config.MOCK_CODE : Crypto.randomNumericCode(settings.codeLength());
if (Config.MOCK_ENABLED && otp.length() != settings.codeLength()) {
throw new IllegalStateException("Mock OTP length must match the settings snapshot");
}
challenge.phoneHmac = phoneHmac;
challenge.destinationMasked = PhoneNormalizer.mask(phone);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, otp);
challenge.createdAt = now;
challenge.expiresAt = now.plusSeconds(settings.ttlSeconds());
challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = settings.maxVerifyAttempts();
challenge.settingsVersion = settings.version();
challenge.deliveryMode = Config.MOCK_ENABLED ? "mock" : "sms";
challenge.challengeStatus = Config.MOCK_ENABLED ? "active" : "ordering";
challenge.orderedAt = Config.MOCK_ENABLED ? now : null;
challenge.otpTtlSec = settings.ttlSeconds();
challenge.otpCodeLength = settings.codeLength();
entityManager.persist(challenge);
if (Config.MOCK_ENABLED) {
event("otp_send", phoneHmac, challenge.id, null, "success", "mock", device);
}
return new Reservation(challenge, otp);
}
OtpChallengeEntity activate(
String challengeId, SmsOrderClient.OrderResult order, DeviceMetadata device) {
OtpChallengeEntity challenge = locked(challengeId);
if (!"ordering".equals(challenge.challengeStatus)) return challenge;
challenge.smsMessageId = order.smsMessageId();
challenge.orderedAt = order.orderedAt();
challenge.expiresAt = order.orderedAt().plusSeconds(challenge.otpTtlSec);
challenge.challengeStatus = "active";
event("otp_send", challenge.phoneHmac, challenge.id, challenge.smsMessageId,
"success", "ordered", device);
return challenge;
}
void orderFailed(String challengeId, DeviceMetadata device) {
OtpChallengeEntity challenge = locked(challengeId);
if (!"ordering".equals(challenge.challengeStatus)) return;
challenge.challengeStatus = "order_failed";
event("otp_send", challenge.phoneHmac, challenge.id, null,
"failure", "order_failed", device);
}
VerifyResult consume(String challengeId, String suppliedCode, DeviceMetadata device) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now();
if (challenge == null) return VerifyResult.INVALID;
if (!"active".equals(challenge.challengeStatus)) {
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"already_used", challenge.challengeStatus, device);
return switch (challenge.challengeStatus) {
case "limited" -> VerifyResult.VERIFY_LIMITED;
case "expired" -> VerifyResult.EXPIRED;
default -> VerifyResult.ALREADY_USED;
};
}
if (!challenge.expiresAt.isAfter(now)) {
challenge.challengeStatus = "expired";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"expired", "ttl", device);
return VerifyResult.EXPIRED;
}
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"limited", "attempt_limit", device);
return VerifyResult.VERIFY_LIMITED;
}
challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
if (!valid) {
boolean limited = challenge.verifyAttempts >= challenge.maxVerifyAttempts;
if (limited) challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device);
return limited ? VerifyResult.VERIFY_LIMITED : VerifyResult.INVALID;
}
challenge.consumedAt = now;
challenge.challengeStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"success", "verified", device);
return VerifyResult.VERIFIED;
}
OtpChallengeEntity get(String challengeId) {
return entityManager.find(OtpChallengeEntity.class, challengeId);
}
void expireDue() {
expireDue(Instant.now());
}
private OtpChallengeEntity locked(String challengeId) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
if (challenge == null) throw new IllegalStateException("OTP challenge not found");
return challenge;
}
private void expireDue(Instant now) {
entityManager.createQuery("""
update OtpChallengeEntity c set c.challengeStatus = 'expired'
where c.challengeStatus = 'active' and c.expiresAt <= :now
""").setParameter("now", now).executeUpdate();
}
private void event(
String type,
String phoneHmac,
String challengeId,
UUID smsMessageId,
String outcome,
String details,
DeviceMetadata device) {
OtpSecurityEventEntity event = new OtpSecurityEventEntity();
event.id = Crypto.randomId();
event.occurredAt = Instant.now();
event.eventType = type;
event.phoneHmac = phoneHmac;
event.challengeId = challengeId;
event.smsMessageId = smsMessageId;
event.outcome = outcome;
event.details = details;
if (device != null) {
event.clientIp = device.clientIp();
event.userAgent = device.userAgent();
event.deviceId = device.deviceId();
event.fingerprint = device.fingerprint();
event.osName = device.osName();
event.osVersion = device.osVersion();
event.platform = device.platform();
event.appVersion = device.appVersion();
}
entityManager.persist(event);
}
record Reservation(OtpChallengeEntity challenge, String otp) {}
enum VerifyResult {
VERIFIED,
INVALID,
EXPIRED,
ALREADY_USED,
VERIFY_LIMITED
}
static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); }
boolean isCooldown() {
return "otp_send_cooldown".equals(getMessage());
}
}
}
@@ -0,0 +1,87 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.Response;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public final class PhoneIdentityAuthenticator implements Authenticator {
static final String PHONE_NOTE = "han.phone";
static final String CHALLENGE_NOTE = "han.otp.challenge";
static final String MASKED_NOTE = "han.phone.masked";
static final String CODE_LENGTH_NOTE = "han.otp.code_length";
static final String EXPIRES_AT_NOTE = "han.otp.expires_at";
private final PhoneNormalizer normalizer = new PhoneNormalizer();
@Override
public void authenticate(AuthenticationFlowContext context) {
DeviceMetadata device = DeviceMetadata.capture(context);
if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) {
context.success();
return;
}
context.challenge(phoneForm(context, null, device));
}
@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);
context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id);
context.getAuthenticationSession().setAuthNote(MASKED_NOTE, challenge.destinationMasked);
context.getAuthenticationSession().setAuthNote(
CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
context.getAuthenticationSession().setAuthNote(
EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
context.success();
} catch (IllegalArgumentException exception) {
Response response = phoneForm(context, "phoneInvalid", device);
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
} catch (OtpStore.OtpLimitException exception) {
Response response = phoneForm(
context, exception.isCooldown() ? "otpCooldown" : "otpLimited", device);
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response);
} catch (RuntimeException exception) {
Response response = phoneForm(context, "otpUnavailable", device);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response);
}
}
private static Response phoneForm(
AuthenticationFlowContext context, String messageKey, DeviceMetadata device) {
var form = context.form()
.setAttribute("hanDeviceId", device.deviceId())
.setAttribute("hanFingerprint", device.fingerprint())
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.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");
}
@Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@Override public void close() {}
}
@@ -0,0 +1,31 @@
package ru.han.chat.keycloak;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
public final class PhoneIdentityAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "han-phone-identity";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
private static final PhoneIdentityAuthenticator SINGLETON = new PhoneIdentityAuthenticator();
@Override public Authenticator create(KeycloakSession session) { return SINGLETON; }
@Override public String getId() { return ID; }
@Override public String getDisplayType() { return "HAN Phone Identity"; }
@Override public String getReferenceCategory() { return "phone"; }
@Override public boolean isConfigurable() { return false; }
@Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; }
@Override public boolean isUserSetupAllowed() { return false; }
@Override public String getHelpText() { return "Normalizes E.164 phone and creates a durable OTP challenge."; }
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
@Override public void init(Config.Scope config) {}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
}
@@ -0,0 +1,38 @@
package ru.han.chat.keycloak;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import java.text.Normalizer;
public final class PhoneNormalizer {
private static final PhoneNumberUtil UTIL = PhoneNumberUtil.getInstance();
public String normalize(String input) {
if (input == null || input.isBlank()) throw new IllegalArgumentException("phone_required");
String normalized = normalizeDigits(Normalizer.normalize(input.trim(), Normalizer.Form.NFKC));
try {
var parsed = UTIL.parse(normalized, normalized.startsWith("+") ? "ZZ" : "RU");
if (!UTIL.isValidNumber(parsed) || !UTIL.isPossibleNumber(parsed)) {
throw new IllegalArgumentException("phone_invalid");
}
return UTIL.format(parsed, PhoneNumberUtil.PhoneNumberFormat.E164);
} catch (NumberParseException exception) {
throw new IllegalArgumentException("phone_invalid", exception);
}
}
static String normalizeDigits(String value) {
StringBuilder result = new StringBuilder(value.length());
value.codePoints().forEach(codePoint -> {
if (Character.isDigit(codePoint)) result.append(Character.getNumericValue(codePoint));
else result.appendCodePoint(codePoint);
});
return result.toString();
}
public static String mask(String e164) {
return e164.length() < 7
? "***"
: e164.substring(0, 2) + "*****" + e164.substring(e164.length() - 4);
}
}
@@ -0,0 +1,127 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.Response;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public final class PhoneOtpAuthenticator implements Authenticator {
@Override
public void authenticate(AuthenticationFlowContext context) {
String challengeId = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
if (challengeId == null) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
String masked = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE);
context.challenge(otpForm(context, masked, null));
}
@Override
public void action(AuthenticationFlowContext context) {
String challengeId = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
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;
}
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);
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
context.challenge(otpForm(context, challenge.destinationMasked, null));
} catch (OtpStore.OtpLimitException exception) {
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
otpForm(
context,
PhoneNormalizer.mask(phone),
exception.isCooldown() ? "otpCooldown" : "otpLimited"));
} catch (RuntimeException exception) {
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
otpForm(context, PhoneNormalizer.mask(phone), "otpUnavailable"));
}
return;
}
OtpStore.VerifyResult result =
new OtpStore(context.getSession()).consume(challengeId, code, device);
if (result != OtpStore.VerifyResult.VERIFIED) {
String messageKey = result == OtpStore.VerifyResult.VERIFY_LIMITED
? "otpVerifyLimited"
: "otpInvalid";
Response response = otpForm(context, PhoneNormalizer.mask(phone), messageKey);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
UserModel user = context.getSession().users()
.searchForUserByUserAttributeStream(context.getRealm(), "phone_number", phone)
.findFirst()
.orElseGet(() -> {
UserModel created = context.getSession().users().addUser(context.getRealm(), phone);
created.setEnabled(true);
created.setSingleAttribute("phone_number", phone);
created.setSingleAttribute("phone_number_verified", "true");
return created;
});
if (!user.isEnabled()) {
context.failure(AuthenticationFlowError.USER_DISABLED);
return;
}
user.setSingleAttribute("phone_number", phone);
user.setSingleAttribute("phone_number_verified", "true");
context.setUser(user);
context.getAuthenticationSession().setUserSessionNote("amr", "phone_otp");
context.success();
}
private static Response otpForm(AuthenticationFlowContext context, String masked, String messageKey) {
DeviceMetadata device = DeviceMetadata.capture(context);
String codeLength = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CODE_LENGTH_NOTE);
String expiresAt = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.EXPIRES_AT_NOTE);
var form = context.form()
.setAttribute("maskedPhone", masked)
.setAttribute("otpCodeLength", codeLength == null ? 6 : Integer.parseInt(codeLength))
.setAttribute("otpExpiresAt", expiresAt == null ? 0 : Long.parseLong(expiresAt))
.setAttribute("hanDeviceId", device.deviceId())
.setAttribute("hanFingerprint", device.fingerprint())
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.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");
}
@Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@Override public void close() {}
}
@@ -0,0 +1,42 @@
package ru.han.chat.keycloak;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.timer.TimerProvider;
public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "han-phone-otp";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
private static final PhoneOtpAuthenticator SINGLETON = new PhoneOtpAuthenticator();
@Override public Authenticator create(KeycloakSession session) { return SINGLETON; }
@Override public String getId() { return ID; }
@Override public String getDisplayType() { return "HAN Phone OTP"; }
@Override public String getReferenceCategory() { return "phone-otp"; }
@Override public boolean isConfigurable() { return false; }
@Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; }
@Override public boolean isUserSetupAllowed() { return false; }
@Override public String getHelpText() { return "Verifies and atomically consumes a durable phone OTP challenge."; }
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
@Override public void init(Config.Scope config) { ru.han.chat.keycloak.Config.validate(); }
@Override
public void postInit(KeycloakSessionFactory factory) {
try (KeycloakSession session = factory.create()) {
session.getProvider(TimerProvider.class).schedule(
() -> KeycloakModelUtils.runJobInTransaction(
factory, jobSession -> new OtpStore(jobSession).expireDue()),
60_000L,
"han-otp-expiry");
}
}
@Override public void close() {}
}
@@ -0,0 +1,98 @@
package ru.han.chat.keycloak;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jboss.logging.Logger;
final class SettingsBridge {
private static final Logger LOG = Logger.getLogger(SettingsBridge.class);
private static final Pattern INT = Pattern.compile("\"%s\"\\s*:\\s*(\\d+)");
private static final Pattern STRING = Pattern.compile("\"%s\"\\s*:\\s*\"([^\"]+)\"");
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build();
private static volatile Cached cached;
record Settings(
int maxSendsPer24h,
int minSecondsBetween,
int maxVerifyAttempts,
int codeLength,
int ttlSeconds,
int smsOrderTimeoutMs,
String version) {}
private record Cached(Settings settings, Instant fetchedAt, Instant refreshAfter, String etag) {}
private SettingsBridge() {}
static Settings get() {
Cached local = cached;
Instant now = Instant.now();
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
synchronized (SettingsBridge.class) {
local = cached;
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
try {
HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL)
.timeout(Duration.ofSeconds(3))
.header("Authorization", "Bearer " + Config.SETTINGS_TOKEN)
.header("Accept", "application/json")
.GET();
if (local != null && local.etag != null) builder.header("If-None-Match", local.etag);
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 304 && local != null) {
cached = new Cached(local.settings, now, now.plusSeconds(60), local.etag);
return local.settings;
}
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
int max = integer(response.body(), "max_send_attempts_per_24h");
int minimum = integer(response.body(), "min_seconds_between_attempts");
int maxVerify = integer(response.body(), "max_verify_attempts");
int codeLength = integer(response.body(), "code_length");
int otpTtl = integer(response.body(), "ttl_seconds");
int orderTimeout = integer(response.body(), "sms_order_timeout_ms");
int ttl = integer(response.body(), "cache_ttl_seconds");
String version = string(response.body(), "version");
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400
|| maxVerify < 1 || maxVerify > 10
|| codeLength < 4 || codeLength > 10
|| otpTtl < 60 || otpTtl > 900 || otpTtl % 60 != 0
|| orderTimeout < 100 || orderTimeout > 30000
|| ttl < 1 || ttl > 3600) {
throw new IllegalStateException("settings_invalid_range");
}
Settings settings = new Settings(
max, minimum, maxVerify, codeLength, otpTtl, orderTimeout, version);
cached = new Cached(settings, now, now.plusSeconds(ttl),
response.headers().firstValue("ETag").orElse(null));
return settings;
} catch (Exception exception) {
if (local != null && now.isBefore(local.fetchedAt.plus(Config.SETTINGS_MAX_STALE))) {
LOG.warn("OTP settings refresh failed; using bounded last-known-good");
return local.settings;
}
throw new IllegalStateException("OTP settings unavailable; send denied", exception);
}
}
}
private static int integer(String json, String field) {
Matcher matcher = Pattern.compile(INT.pattern().formatted(Pattern.quote(field))).matcher(json);
if (!matcher.find()) throw new IllegalStateException("settings_missing_" + field);
return Integer.parseInt(matcher.group(1));
}
private static String string(String json, String field) {
Matcher matcher = Pattern.compile(STRING.pattern().formatted(Pattern.quote(field))).matcher(json);
if (!matcher.find()) throw new IllegalStateException("settings_missing_" + field);
return matcher.group(1);
}
static void clearForTests() {
cached = null;
}
}
@@ -0,0 +1,109 @@
package ru.han.chat.keycloak;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class SmsOrderClient {
private static final Pattern MESSAGE_ID =
Pattern.compile("\"sms_message_id\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern ORDERED_AT =
Pattern.compile("\"ordered_at\"\\s*:\\s*\"([^\"]+)\"");
private final HttpClient client;
private final URI serviceUrl;
private final String serviceToken;
SmsOrderClient() {
this(HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2))
.build(),
Config.SMS_SERVICE_URL, Config.SMS_SERVICE_TOKEN);
}
SmsOrderClient(HttpClient client, URI serviceUrl, String serviceToken) {
this.client = client;
this.serviceUrl = serviceUrl;
this.serviceToken = serviceToken;
}
OrderResult order(
String challengeId,
String phone,
String otp,
SettingsBridge.Settings settings,
String requestId,
String traceparent) {
String body = requestBody(challengeId, phone, otp, settings);
HttpRequest.Builder builder = HttpRequest.newBuilder(serviceUrl)
.timeout(Duration.ofMillis(settings.smsOrderTimeoutMs()))
.header("Authorization", "Bearer " + serviceToken)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Request-ID", requestId == null ? challengeId : requestId)
.POST(HttpRequest.BodyPublishers.ofString(body));
if (traceparent != null && !traceparent.isBlank()) builder.header("traceparent", traceparent);
HttpRequest request = builder.build();
for (int attempt = 0; attempt < 2; attempt++) {
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 202) {
return parse(response.body());
}
if (response.statusCode() < 500 || attempt == 1) {
throw new SmsOrderException("sms_order_http_" + response.statusCode());
}
} catch (java.net.http.HttpTimeoutException exception) {
if (attempt == 1) throw new SmsOrderException("sms_order_timeout", exception);
} catch (java.io.IOException exception) {
if (attempt == 1) throw new SmsOrderException("sms_order_io", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new SmsOrderException("sms_order_interrupted", exception);
}
}
throw new SmsOrderException("sms_order_unavailable");
}
static OrderResult parse(String json) {
Matcher idMatcher = MESSAGE_ID.matcher(json);
Matcher orderedMatcher = ORDERED_AT.matcher(json);
if (!idMatcher.find() || !orderedMatcher.find()) {
throw new SmsOrderException("sms_order_invalid_response");
}
try {
return new OrderResult(UUID.fromString(idMatcher.group(1)), Instant.parse(orderedMatcher.group(1)));
} catch (RuntimeException exception) {
throw new SmsOrderException("sms_order_invalid_response", exception);
}
}
static String requestBody(
String challengeId, String phone, String otp, SettingsBridge.Settings settings) {
return ("{\"idempotency_key\":\"keycloak:challenge:%s\","
+ "\"template_code\":\"auth_otp\",\"locale\":\"ru\","
+ "\"phone_e164\":\"%s\",\"substitutions\":{\"code\":\"%s\",\"ttl_min\":\"%d\"},"
+ "\"customer_ref\":\"%s\",\"message_ttl_sec\":%d}").formatted(
escape(challengeId), escape(phone), escape(otp), settings.ttlSeconds() / 60,
escape(challengeId), settings.ttlSeconds());
}
private static String escape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
record OrderResult(UUID smsMessageId, Instant orderedAt) {}
static final class SmsOrderException extends RuntimeException {
SmsOrderException(String message) { super(message); }
SmsOrderException(String message, Throwable cause) { super(message, cause); }
}
}
@@ -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);
}
}
@@ -0,0 +1,33 @@
package ru.han.chat.keycloak.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "han_otp_challenge")
public class OtpChallengeEntity {
@Id @Column(length = 32) public String id;
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
@Column(name = "destination_masked", nullable = false, length = 32) public String destinationMasked;
@Column(name = "otp_hash", nullable = false, length = 64) public String otpHash;
@Column(name = "created_at", nullable = false) public Instant createdAt;
@Column(name = "expires_at", nullable = false) public Instant expiresAt;
@Column(name = "consumed_at") public Instant consumedAt;
@Column(name = "verify_attempts", nullable = false) public int verifyAttempts;
@Column(name = "max_verify_attempts", nullable = false) public int maxVerifyAttempts;
@Column(name = "settings_version", nullable = false, length = 128) public String settingsVersion;
@Column(name = "provider_id", length = 128) public String providerId;
@Column(name = "provider_status", length = 32) public String providerStatus;
@Column(name = "sms_message_id") public UUID smsMessageId;
@Column(name = "delivery_mode", nullable = false, length = 16) public String deliveryMode;
@Column(name = "challenge_status", nullable = false, length = 16) public String challengeStatus;
@Column(name = "ordered_at") public Instant orderedAt;
@Column(name = "otp_ttl_sec", nullable = false) public int otpTtlSec;
@Column(name = "otp_code_length", nullable = false) public int otpCodeLength;
@Version public long version;
}
@@ -0,0 +1,32 @@
package ru.han.chat.keycloak.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.ColumnTransformer;
@Entity
@Table(name = "han_otp_security_event")
public class OtpSecurityEventEntity {
@Id @Column(length = 32) public String id;
@Column(name = "occurred_at", nullable = false) public Instant occurredAt;
@Column(name = "event_type", nullable = false, length = 64) public String eventType;
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
@Column(name = "challenge_id", length = 32) public String challengeId;
@Column(name = "outcome", nullable = false, length = 32) public String outcome;
@Column(name = "details", length = 256) public String details;
@Column(name = "sms_message_id") public UUID smsMessageId;
@Column(name = "client_ip", columnDefinition = "inet")
@ColumnTransformer(write = "cast(? as inet)")
public String clientIp;
@Column(name = "user_agent") public String userAgent;
@Column(name = "device_id", length = 256) public String deviceId;
@Column(name = "fingerprint", length = 256) public String fingerprint;
@Column(name = "os_name", length = 64) public String osName;
@Column(name = "os_version", length = 64) public String osVersion;
@Column(name = "platform", length = 16) public String platform;
@Column(name = "app_version", length = 64) public String appVersion;
}
@@ -0,0 +1,19 @@
package ru.han.chat.keycloak.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
@Entity
@Table(name = "han_otp_send_counter")
public class OtpSendCounterEntity {
@Id @Column(length = 128) public String id;
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
@Column(name = "window_start", nullable = false) public Instant windowStart;
@Column(name = "send_count", nullable = false) public int sendCount;
@Column(name = "last_sent_at", nullable = false) public Instant lastSentAt;
@Version public long version;
}
@@ -0,0 +1,27 @@
package ru.han.chat.keycloak.persistence;
import java.util.List;
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
import ru.han.chat.keycloak.entity.OtpSecurityEventEntity;
import ru.han.chat.keycloak.entity.OtpSendCounterEntity;
public final class HanJpaEntityProvider implements JpaEntityProvider {
@Override
public List<Class<?>> getEntities() {
return List.of(OtpChallengeEntity.class, OtpSendCounterEntity.class, OtpSecurityEventEntity.class);
}
@Override
public String getChangelogLocation() {
return "META-INF/han-otp-changelog.xml";
}
@Override
public String getFactoryId() {
return HanJpaEntityProviderFactory.ID;
}
@Override
public void close() {}
}
@@ -0,0 +1,17 @@
package ru.han.chat.keycloak.persistence;
import org.keycloak.Config;
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public final class HanJpaEntityProviderFactory implements JpaEntityProviderFactory {
public static final String ID = "han-phone-otp-jpa";
@Override public JpaEntityProvider create(KeycloakSession session) { return new HanJpaEntityProvider(); }
@Override public void init(Config.Scope config) {}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
@Override public String getId() { return ID; }
}
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<changeSet id="han-otp-1.0.0" author="han-chat">
<createTable tableName="han_otp_challenge">
<column name="id" type="varchar(32)"><constraints primaryKey="true" nullable="false"/></column>
<column name="phone_hmac" type="varchar(64)"><constraints nullable="false"/></column>
<column name="destination_masked" type="varchar(32)"><constraints nullable="false"/></column>
<column name="otp_hash" type="varchar(64)"><constraints nullable="false"/></column>
<column name="created_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="expires_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="consumed_at" type="timestamp with time zone"/>
<column name="verify_attempts" type="int"><constraints nullable="false"/></column>
<column name="max_verify_attempts" type="int"><constraints nullable="false"/></column>
<column name="settings_version" type="varchar(128)"><constraints nullable="false"/></column>
<column name="provider_id" type="varchar(128)"><constraints nullable="false"/></column>
<column name="provider_status" type="varchar(32)"><constraints nullable="false"/></column>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_phone">
<column name="phone_hmac"/>
</createIndex>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_expiry">
<column name="expires_at"/>
</createIndex>
<createTable tableName="han_otp_send_counter">
<column name="id" type="varchar(128)"><constraints primaryKey="true" nullable="false"/></column>
<column name="phone_hmac" type="varchar(64)"><constraints nullable="false"/></column>
<column name="window_start" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="send_count" type="int"><constraints nullable="false"/></column>
<column name="last_sent_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="han_otp_send_counter" indexName="ix_han_otp_counter_phone_window">
<column name="phone_hmac"/><column name="window_start"/>
</createIndex>
<createTable tableName="han_otp_security_event">
<column name="id" type="varchar(32)"><constraints primaryKey="true" nullable="false"/></column>
<column name="occurred_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="event_type" type="varchar(64)"><constraints nullable="false"/></column>
<column name="phone_hmac" type="varchar(64)"><constraints nullable="false"/></column>
<column name="challenge_id" type="varchar(32)"/>
<column name="outcome" type="varchar(32)"><constraints nullable="false"/></column>
<column name="details" type="varchar(256)"/>
</createTable>
<createIndex tableName="han_otp_security_event" indexName="ix_han_otp_event_time">
<column name="occurred_at"/>
</createIndex>
</changeSet>
<changeSet id="han-otp-1.1.0-sms-lifecycle" author="han-chat">
<addColumn tableName="han_otp_challenge">
<column name="sms_message_id" type="uuid"/>
<column name="delivery_mode" type="varchar(16)"/>
<column name="challenge_status" type="varchar(16)"/>
<column name="ordered_at" type="timestamp with time zone"/>
<column name="otp_ttl_sec" type="int"/>
<column name="otp_code_length" type="smallint"/>
</addColumn>
<sql>
UPDATE han_otp_challenge
SET delivery_mode = 'mock',
challenge_status = CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END,
ordered_at = created_at,
otp_ttl_sec = 60,
otp_code_length = 6;
ALTER TABLE han_otp_challenge ALTER COLUMN delivery_mode SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN challenge_status SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN otp_ttl_sec SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN otp_code_length SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN provider_id DROP NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN provider_status DROP NOT NULL;
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_delivery_mode
CHECK (delivery_mode IN ('mock', 'sms'));
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_challenge_status
CHECK (challenge_status IN
('ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'));
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_ttl
CHECK (otp_ttl_sec BETWEEN 60 AND 900 AND otp_ttl_sec % 60 = 0);
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_code_length
CHECK (otp_code_length BETWEEN 4 AND 10);
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_active_sms
CHECK (challenge_status != 'active' OR delivery_mode != 'sms' OR sms_message_id IS NOT NULL);
</sql>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_status_expiry">
<column name="challenge_status"/><column name="expires_at"/>
</createIndex>
<sql>
CREATE INDEX ix_han_otp_challenge_sms_message
ON han_otp_challenge (sms_message_id) WHERE sms_message_id IS NOT NULL;
</sql>
<addColumn tableName="han_otp_security_event">
<column name="sms_message_id" type="uuid"/>
<column name="client_ip" type="inet"/>
<column name="user_agent" type="text"/>
<column name="device_id" type="varchar(256)"/>
<column name="fingerprint" type="varchar(256)"/>
<column name="os_name" type="varchar(64)"/>
<column name="os_version" type="varchar(64)"/>
<column name="platform" type="varchar(16)"/>
<column name="app_version" type="varchar(64)"/>
</addColumn>
<sql>
ALTER TABLE han_otp_security_event ADD CONSTRAINT ck_han_otp_event_platform
CHECK (platform IS NULL OR platform IN ('web', 'ios', 'android'));
CREATE INDEX ix_han_otp_event_sms_message
ON han_otp_security_event (sms_message_id) WHERE sms_message_id IS NOT NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,2 @@
ru.han.chat.keycloak.PhoneIdentityAuthenticatorFactory
ru.han.chat.keycloak.PhoneOtpAuthenticatorFactory
@@ -0,0 +1 @@
ru.han.chat.keycloak.persistence.HanJpaEntityProviderFactory
@@ -0,0 +1,31 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class CryptoTest {
@Test
void challengeIdsAreUniqueAndContainAtLeast128Bits() {
String first = Crypto.randomId();
String second = Crypto.randomId();
assertNotEquals(first, second);
assertTrue(first.length() >= 22);
}
@Test
void constantTimeComparisonChecksEntireValue() {
assertTrue(Crypto.constantTimeEquals("same-value", "same-value"));
assertFalse(Crypto.constantTimeEquals("same-value", "same-valuf"));
}
@Test
void randomOtpIsNumericAndUsesRequestedLength() {
String code = Crypto.randomNumericCode(8);
assertTrue(code.matches("\\d{8}"));
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(3));
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(11));
}
}
@@ -0,0 +1,34 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class PhoneNormalizerTest {
private final PhoneNormalizer normalizer = new PhoneNormalizer();
@Test
void normalizesRussianNationalNumber() {
assertEquals("+79001234567", normalizer.normalize("8 (900) 123-45-67"));
}
@Test
void normalizesInternationalNumber() {
assertEquals("+442079460018", normalizer.normalize("+44 20 7946 0018"));
}
@Test
void normalizesUnicodeDigitsWithNfkc() {
assertEquals("+79001234567", normalizer.normalize("+ 900 123 45 67"));
}
@Test
void rejectsImpossibleNumber() {
assertThrows(IllegalArgumentException.class, () -> normalizer.normalize("+700"));
}
@Test
void masksPersonallyIdentifyingDigits() {
assertEquals("+7*****4567", PhoneNormalizer.mask("+79001234567"));
}
}
@@ -0,0 +1,50 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class RealmContractTest {
private final String realm = readRealm();
@Test
void requiresCodeFlowPkceAndDisablesUnsafeGrants() {
assertTrue(realm.contains("\"pkce.code.challenge.method\": \"S256\""));
assertTrue(realm.contains("\"standardFlowEnabled\": true"));
assertTrue(realm.contains("\"implicitFlowEnabled\": false"));
assertTrue(realm.contains("\"directAccessGrantsEnabled\": false"));
assertTrue(realm.contains("\"publicClient\": true"));
}
@Test
void includesAudiencePhoneClaimsAndRefreshRotation() {
assertTrue(realm.contains("\"included.client.audience\": \"han-chat-api\""));
assertTrue(realm.contains("\"claim.name\": \"phone_number\""));
assertTrue(realm.contains("\"claim.name\": \"phone_number_verified\""));
assertTrue(realm.contains("\"protocolMapper\": \"oidc-sub-mapper\""));
assertTrue(realm.contains("\"revokeRefreshToken\": true"));
assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0"));
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
assertTrue(realm.contains("\"han-chat://auth/callback\""));
}
@Test
void containsNoSecretsOrWildcardOrigins() {
assertFalse(realm.contains("KEYCLOAK_OTP_MOCK_CODE"));
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() {
try {
return Files.readString(Path.of("realm", "han-chat-realm.json"));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -0,0 +1,85 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class SmsLifecycleContractTest {
@Test
void migrationContainsLifecycleSnapshotAndAuditColumns() throws Exception {
String migration = Files.readString(
Path.of("src/main/resources/META-INF/han-otp-changelog.xml"));
for (String required : new String[] {
"sms_message_id", "delivery_mode", "challenge_status", "ordered_at",
"otp_ttl_sec", "otp_code_length", "client_ip", "user_agent",
"device_id", "fingerprint", "os_name", "os_version", "platform", "app_version",
"'ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'"
}) {
assertTrue(migration.contains(required), "Missing migration contract: " + required);
}
assertTrue(migration.contains("delivery_mode = 'mock'"));
assertTrue(migration.contains(
"CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END"));
}
@Test
void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception {
String template = 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 messages = Files.readString(
Path.of("themes/han-phone/login/messages/messages_ru.properties"));
String verifier = Files.readString(Path.of(
"src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java"));
assertTrue(template.contains("otpCodeLength"));
assertTrue(template.contains("otpExpiresAt"));
assertTrue(template.contains("(otpExpiresAt!0)?c"));
assertTrue(template.contains("name=\"otp_action\" value=\"resend\""));
assertTrue(template.contains("han_device_id"));
assertTrue(script.contains("han_device_id"));
assertTrue(script.contains("expiresAt - Date.now()"));
assertTrue(script.contains("Number.isFinite(expiresAt)"));
assertTrue(messages.contains("otpVerifyLimited="));
assertTrue(messages.contains("Попробуйте через 24 часа"));
assertTrue(verifier.contains("VerifyResult.VERIFY_LIMITED"));
}
@Test
void phoneThemeDoesNotRepeatConsentText() throws Exception {
String phone = Files.readString(Path.of("themes/han-phone/login/phone.ftl"));
assertTrue(!phone.contains("phoneLegalPrefix"));
assertTrue(!phone.contains("termsOfUse"));
assertTrue(!phone.contains("privacyPolicy"));
}
@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,75 @@
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
class SmsOrderClientTest {
private static final SettingsBridge.Settings SETTINGS =
new SettingsBridge.Settings(3, 30, 5, 6, 120, 3000, "v1");
@Test
void requestUsesStableIdempotencyAndSnapshot() {
String body = SmsOrderClient.requestBody(
"challenge-1", "+79001234567", "482193", SETTINGS);
assertTrue(body.contains("\"idempotency_key\":\"keycloak:challenge:challenge-1\""));
assertTrue(body.contains("\"template_code\":\"auth_otp\""));
assertTrue(body.contains("\"code\":\"482193\""));
assertTrue(body.contains("\"ttl_min\":\"2\""));
assertTrue(body.contains("\"message_ttl_sec\":120"));
assertFalse(body.contains("Authorization"));
}
@Test
void parsesOnlyUuidAndIsoOrderedTimestamp() {
UUID id = UUID.randomUUID();
SmsOrderClient.OrderResult result = SmsOrderClient.parse(
"{\"sms_message_id\":\"" + id + "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}");
assertEquals(id, result.smsMessageId());
assertEquals(Instant.parse("2026-07-22T13:00:00Z"), result.orderedAt());
assertThrows(SmsOrderClient.SmsOrderException.class,
() -> SmsOrderClient.parse("{\"sms_message_id\":\"not-a-uuid\"}"));
}
@Test
void retriesServerFailureWithSameOrder() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
AtomicInteger calls = new AtomicInteger();
UUID messageId = UUID.randomUUID();
server.createContext("/internal/sms/v1/send", exchange -> {
assertEquals("Bearer test-token", exchange.getRequestHeaders().getFirst("Authorization"));
int call = calls.incrementAndGet();
byte[] response = (call == 1 ? "{}" :
"{\"sms_message_id\":\"" + messageId
+ "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}")
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(call == 1 ? 503 : 202, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start();
try {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort()
+ "/internal/sms/v1/send");
SmsOrderClient client = new SmsOrderClient(HttpClient.newHttpClient(), uri, "test-token");
SmsOrderClient.OrderResult result =
client.order("challenge-1", "+79001234567", "482193", SETTINGS, "request-1", null);
assertEquals(messageId, result.smsMessageId());
assertEquals(2, calls.get());
} finally {
server.stop(0);
}
}
}
@@ -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);
}
}
}