Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
+2 -1
View File
@@ -7,10 +7,11 @@ KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
KEYCLOAK_SMS_SERVICE_TOKEN=replace-with-independent-service-token
KEYCLOAK_LOG_LEVEL=INFO
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
+1
View File
@@ -6,6 +6,7 @@ COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
COPY src ./src
COPY realm ./realm
COPY themes ./themes
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
+15 -5
View File
@@ -9,10 +9,12 @@ Production-like Keycloak 26.1.4 image and realm for OTP-only phone authenticatio
- Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`.
- Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days.
- Realm brute-force protection uses temporary bounded lockouts.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migration `han-otp-1.0.0` is applied by Keycloak's JPA entity provider.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migrations are applied by Keycloak's JPA entity provider.
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
- Mock mode is explicit. Startup rejects missing values, code `1234`, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup.
- Every challenge snapshots code length, TTL, SMS-order timeout and settings version. Runtime OTP values are not read from environment variables.
- Mock mode is explicit and retains the configured test code. SMS mode generates a cryptographically secure numeric OTP, stores only its HMAC and orders delivery through `POST /internal/sms/v1/send`; Keycloak never calls or polls the provider.
- SMS mode requires `KEYCLOAK_SMS_SERVICE_URL` and an independent `KEYCLOAK_SMS_SERVICE_TOKEN`. No real credentials are committed.
## Build and test
@@ -73,14 +75,22 @@ Private signing keys are generated and stored by Keycloak and are absent from th
Provider tables:
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock;
- `han_otp_challenge`: expiring, one-time challenges with explicit ordering/active/final statuses, settings snapshot and optional `sms_message_id`;
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
- `han_otp_security_event`: append-only minimal outcomes without raw phone or OTP.
- `han_otp_security_event`: append-only send/verify outcomes with SMS correlation and validated device audit metadata, without raw phone or OTP.
Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use.
Resend creates a new durable order and marks earlier active/ordering challenges as superseded. Verification accepts only active, unexpired challenges, locks the row, increments attempts, and atomically consumes a valid code. Provider delivery status never participates in verification.
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
The provider schedules a once-per-minute expiry update and also performs lazy expiry on send and verify. The theme renders digit inputs and countdown from the challenge snapshot, submits a real resend action and carries optional `han_*` device metadata.
## SMS order behavior
`200` or `202` with a valid UUID `sms_message_id` and ISO-8601 `ordered_at` activates a real-mode challenge. Timeout, I/O failure or 5xx is retried once with the same `keycloak:challenge:{id}` idempotency key; final failure marks that challenge `order_failed`. The retry creates neither another challenge nor another send-counter increment.
Reserve, SMS HTTP order, and activation/order-failure run as separate transaction phases. The HTTP call holds no challenge/counter database lock, and every retry retains the same challenge id.
## Release and recovery
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
+3 -2
View File
@@ -21,12 +21,13 @@ services:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
KEYCLOAK_SMS_SERVICE_URL: ${KEYCLOAK_SMS_SERVICE_URL:-http://sms-service:8080}
KEYCLOAK_SMS_SERVICE_TOKEN: ${KEYCLOAK_SMS_SERVICE_TOKEN:-}
KC_LOG_CONSOLE_OUTPUT: json
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
@@ -5,21 +5,26 @@ import java.time.Duration;
final class Config {
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE");
static final String MOCK_CODE = env("KEYCLOAK_OTP_MOCK_CODE", "");
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
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) {
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start");
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_CODE.isBlank() || "1234".equals(MOCK_CODE) || MOCK_CODE.length() < 6) {
throw new IllegalStateException("KEYCLOAK_OTP_MOCK_CODE must be a non-default secret of at least 6 characters");
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 (HMAC_KEY.length < 32) {
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
@@ -28,6 +33,10 @@ final class Config {
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()) {
@@ -18,6 +18,17 @@ final class Crypto {
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");
@@ -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;
}
}
@@ -4,6 +4,7 @@ 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;
@@ -17,7 +18,7 @@ final class OtpStore {
this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
OtpChallengeEntity reserve(String phone, SettingsBridge.Limits limits) {
Reservation reserve(String phone, SettingsBridge.Settings settings, DeviceMetadata device) {
Instant now = Instant.now();
String phoneHmac = Crypto.hmac("phone", phone);
OtpSendCounterEntity counter = entityManager.find(
@@ -34,77 +35,169 @@ final class OtpStore {
counter.windowStart = now;
counter.sendCount = 0;
}
if (counter.sendCount >= limits.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, "limited", "daily_limit");
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(limits.minSecondsBetween()).isAfter(now)) {
event("otp_send", phoneHmac, null, "limited", "cooldown");
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.consumedAt = :now, c.providerStatus = 'superseded'
where c.phoneHmac = :phone and c.consumedAt is null and c.expiresAt > :now
""").setParameter("now", now).setParameter("phone", phoneHmac).executeUpdate();
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, Config.MOCK_CODE);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, otp);
challenge.createdAt = now;
challenge.expiresAt = now.plus(Config.OTP_TTL);
challenge.expiresAt = now.plusSeconds(settings.ttlSeconds());
challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = limits.maxVerifyAttempts();
challenge.settingsVersion = limits.version();
challenge.providerId = "mock-" + Crypto.randomId();
challenge.providerStatus = "accepted";
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);
event("otp_send", phoneHmac, challenge.id, "success", "mock");
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;
}
boolean consume(String challengeId, String suppliedCode) {
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);
}
boolean consume(String challengeId, String suppliedCode, DeviceMetadata device) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now();
if (challenge == null || challenge.consumedAt != null || !challenge.expiresAt.isAfter(now)) {
if (challenge != null) event("otp_verify", challenge.phoneHmac, challengeId, "failure", "expired_or_used");
if (challenge == null) return false;
if (!"active".equals(challenge.challengeStatus)) {
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"already_used", challenge.challengeStatus, device);
return false;
}
if (!challenge.expiresAt.isAfter(now)) {
challenge.challengeStatus = "expired";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"expired", "ttl", device);
return false;
}
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
event("otp_verify", challenge.phoneHmac, challengeId, "limited", "attempt_limit");
challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"limited", "attempt_limit", device);
return false;
}
challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
if (!valid) {
event("otp_verify", challenge.phoneHmac, challengeId, "failure", "invalid");
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 false;
}
challenge.consumedAt = now;
challenge.providerStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, "success", "verified");
challenge.challengeStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"success", "verified", device);
return true;
}
private void event(String type, String phoneHmac, String challengeId, String outcome, String details) {
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) {}
static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); }
boolean isCooldown() {
return "otp_send_cooldown".equals(getMessage());
}
}
}
@@ -12,40 +12,62 @@ 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(context.form().createForm("phone.ftl"));
context.challenge(phoneForm(context, null, device));
}
@Override
public void action(AuthenticationFlowContext context) {
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
DeviceMetadata device = DeviceMetadata.capture(context);
try {
String phone = normalizer.normalize(rawPhone);
SettingsBridge.Limits limits = SettingsBridge.get();
var challenge = new OtpStore(context.getSession()).reserve(phone, limits);
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 = context.form().setError("phoneInvalid").createForm("phone.ftl");
Response response = phoneForm(context, "phoneInvalid", device);
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
} catch (OtpStore.OtpLimitException exception) {
Response response = context.form().setError("otpLimited").createForm("phone.ftl");
Response response = phoneForm(
context, exception.isCooldown() ? "otpCooldown" : "otpLimited", device);
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response);
} catch (RuntimeException exception) {
Response response = context.form().setError("otpUnavailable").createForm("phone.ftl");
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());
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) {}
@@ -19,7 +19,7 @@ public final class PhoneOtpAuthenticator implements Authenticator {
}
String masked = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE);
context.challenge(context.form().setAttribute("maskedPhone", masked).createForm("otp.ftl"));
context.challenge(otpForm(context, masked, null));
}
@Override
@@ -27,16 +27,37 @@ public final class PhoneOtpAuthenticator implements Authenticator {
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");
if (challengeId == null || phone == null) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
if (!new OtpStore(context.getSession()).consume(challengeId, code)) {
Response response = context.form()
.setAttribute("maskedPhone", PhoneNormalizer.mask(phone))
.setError("otpInvalid")
.createForm("otp.ftl");
DeviceMetadata device = DeviceMetadata.capture(context);
if ("resend".equals(action)) {
try {
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;
}
if (!new OtpStore(context.getSession()).consume(challengeId, code, device)) {
Response response = otpForm(context, PhoneNormalizer.mask(phone), "otpInvalid");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
@@ -62,6 +83,26 @@ public final class PhoneOtpAuthenticator implements Authenticator {
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());
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) {}
@@ -7,7 +7,9 @@ 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";
@@ -25,11 +27,16 @@ public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory
@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) {
if (!ru.han.chat.keycloak.Config.MOCK_ENABLED) {
throw new IllegalStateException("OTP delivery provider is not configured");
@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 postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
}
@@ -17,18 +17,25 @@ final class SettingsBridge {
.connectTimeout(Duration.ofSeconds(2)).build();
private static volatile Cached cached;
record Limits(int maxSendsPer24h, int minSecondsBetween, int maxVerifyAttempts, String version) {}
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {}
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 Limits get() {
static Settings get() {
Cached local = cached;
Instant now = Instant.now();
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
synchronized (SettingsBridge.class) {
local = cached;
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
try {
HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL)
.timeout(Duration.ofSeconds(3))
@@ -38,27 +45,35 @@ final class SettingsBridge {
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.limits, now, now.plusSeconds(60), local.etag);
return local.limits;
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 || ttl < 1 || ttl > 3600) {
|| 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");
}
Limits limits = new Limits(max, minimum, maxVerify, version);
cached = new Cached(limits, now, now.plusSeconds(ttl),
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 limits;
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.limits;
return local.settings;
}
throw new IllegalStateException("OTP settings unavailable; send denied", exception);
}
@@ -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); }
}
}
@@ -6,6 +6,7 @@ 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")
@@ -20,7 +21,13 @@ public class OtpChallengeEntity {
@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", nullable = false, length = 128) public String providerId;
@Column(name = "provider_status", nullable = false, length = 32) public String providerStatus;
@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;
}
@@ -5,6 +5,8 @@ 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")
@@ -16,4 +18,15 @@ public class OtpSecurityEventEntity {
@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;
}
@@ -50,4 +50,65 @@
<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,6 +2,7 @@ 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;
@@ -19,4 +20,12 @@ class CryptoTest {
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,41 @@
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"));
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)"));
}
}
@@ -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);
}
}
}
@@ -20,5 +20,6 @@ verifyOtp=Подтвердить
mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован.
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
@@ -16,8 +16,15 @@
<form id="kc-otp-form" action="${url.loginAction}" method="post">
<input id="otp" name="otp" type="hidden" value=""/>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>">
<#list 0..5 as index>
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>"
style="grid-template-columns: repeat(${otpCodeLength!6}, minmax(0, 1fr));">
<#list 0..((otpCodeLength!6) - 1) as index>
<input class="han-otp-digit" type="text" inputmode="numeric" maxlength="1"
aria-label="${msg("otpDigit", index + 1)}"
<#if index == 0>autocomplete="one-time-code" autofocus</#if>
@@ -33,8 +40,10 @@
</#if>
<div class="han-resend">
<p id="han-resend-countdown">${msg("otpResendCountdown")} <strong>0:59</strong></p>
<button id="han-resend-button" type="button" hidden onclick="window.history.back()">
<p id="han-resend-countdown" data-expires-at="${(otpExpiresAt!0)?c}">
${msg("otpResendCountdown")} <strong>—</strong>
</p>
<button id="han-resend-button" type="submit" name="otp_action" value="resend" hidden>
<span aria-hidden="true">↻</span>
<span>${msg("otpResend")}</span>
</button>
@@ -45,6 +54,6 @@
</button>
</form>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
</#if>
</@layout.registrationLayout>
@@ -17,6 +17,12 @@
</div>
<form id="kc-phone-form" action="${url.loginAction}" method="post">
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<div class="han-field">
<label for="phone">${msg("phoneLabel")}</label>
<input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel"
@@ -46,6 +52,6 @@
<span>${msg("privacyPolicy")}</span>
</p>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
</#if>
</@layout.registrationLayout>
@@ -1,4 +1,30 @@
(function () {
function randomId() {
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (char) {
var value = Math.random() * 16 | 0;
return (char === "x" ? value : (value & 3 | 8)).toString(16);
});
}
function initDeviceMetadata() {
var deviceId = window.localStorage.getItem("han_device_id") || randomId();
var fingerprint = window.localStorage.getItem("han_fingerprint") || randomId();
window.localStorage.setItem("han_device_id", deviceId);
window.localStorage.setItem("han_fingerprint", fingerprint);
document.querySelectorAll(".han-device-id").forEach(function (input) {
if (!input.value) input.value = deviceId;
});
document.querySelectorAll(".han-fingerprint").forEach(function (input) {
if (!input.value) input.value = fingerprint;
});
document.querySelectorAll(".han-os-name").forEach(function (input) {
if (!input.value) {
input.value = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
}
});
}
function initPhoneForm() {
var input = document.getElementById("phone");
var submit = document.getElementById("han-phone-submit");
@@ -81,23 +107,31 @@
syncOtp();
var seconds = 59;
var countdown = document.getElementById("han-resend-countdown");
var countdownValue = countdown && countdown.querySelector("strong");
var resend = document.getElementById("han-resend-button");
if (!countdown || !countdownValue || !resend) return;
var timer = window.setInterval(function () {
seconds -= 1;
countdownValue.textContent = "0:" + String(seconds).padStart(2, "0");
var expiresAt = Number(countdown.getAttribute("data-expires-at"));
if (!Number.isFinite(expiresAt)) expiresAt = Date.now();
var timer;
function updateCountdown() {
var seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
countdownValue.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
if (seconds <= 0) {
window.clearInterval(timer);
if (timer) window.clearInterval(timer);
countdown.hidden = true;
resend.hidden = false;
}
}, 1000);
}
updateCountdown();
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
resend.addEventListener("click", function () {
window.setTimeout(function () { resend.disabled = true; }, 0);
});
}
initDeviceMetadata();
initPhoneForm();
initOtpForm();
})();