Проект разделен на два репозитория
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
+66
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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() {}
|
||||
}
|
||||
+31
@@ -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() {}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -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() {}
|
||||
}
|
||||
+42
@@ -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() {}
|
||||
}
|
||||
+98
@@ -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;
|
||||
}
|
||||
}
|
||||
+109
@@ -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); }
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+27
@@ -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() {}
|
||||
}
|
||||
+17
@@ -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>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ru.han.chat.keycloak.PhoneIdentityAuthenticatorFactory
|
||||
ru.han.chat.keycloak.PhoneOtpAuthenticatorFactory
|
||||
+1
@@ -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));
|
||||
}
|
||||
}
|
||||
+34
@@ -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("+7 900 123 45 67"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsImpossibleNumber() {
|
||||
assertThrows(IllegalArgumentException.class, () -> normalizer.normalize("+700"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPersonallyIdentifyingDigits() {
|
||||
assertEquals("+7*****4567", PhoneNormalizer.mask("+79001234567"));
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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"));
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user