Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-6
@@ -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) {}
|
||||
|
||||
+47
-6
@@ -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) {}
|
||||
|
||||
+11
-4
@@ -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); }
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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;
|
||||
}
|
||||
|
||||
+13
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user