Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
@@ -0,0 +1,54 @@
package ru.han.chat.keycloak;
import java.net.URI;
import java.time.Duration;
final class Config {
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
static final String MOCK_CODE = required("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 int MAX_VERIFY_ATTEMPTS = integer("KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS", 5, 1, 10);
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 {
if (!MOCK_ENABLED) {
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start");
}
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 (HMAC_KEY.length < 32) {
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
}
}
private Config() {}
private static String required(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " is required");
}
return value;
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
private static boolean bool(String name, boolean fallback) {
return Boolean.parseBoolean(env(name, Boolean.toString(fallback)));
}
private static int integer(String name, int fallback, int min, int max) {
int value = Integer.parseInt(env(name, Integer.toString(fallback)));
if (value < min || value > max) throw new IllegalStateException(name + " is outside allowed range");
return value;
}
}
@@ -0,0 +1,35 @@
package ru.han.chat.keycloak;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
final class Crypto {
private static final SecureRandom RANDOM = new SecureRandom();
private Crypto() {}
static String randomId() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String hmac(String purpose, String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(Config.HMAC_KEY, "HmacSHA256"));
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal((purpose + "\0" + value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("HMAC unavailable", exception);
}
}
static boolean constantTimeEquals(String left, String right) {
return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,110 @@
package ru.han.chat.keycloak;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.time.Duration;
import java.time.Instant;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
import ru.han.chat.keycloak.entity.OtpSecurityEventEntity;
import ru.han.chat.keycloak.entity.OtpSendCounterEntity;
final class OtpStore {
private final EntityManager entityManager;
OtpStore(KeycloakSession session) {
this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
OtpChallengeEntity reserve(String phone, SettingsBridge.Limits limits) {
Instant now = Instant.now();
String phoneHmac = Crypto.hmac("phone", phone);
OtpSendCounterEntity counter = entityManager.find(
OtpSendCounterEntity.class, phoneHmac, LockModeType.PESSIMISTIC_WRITE);
if (counter == null) {
counter = new OtpSendCounterEntity();
counter.id = phoneHmac;
counter.phoneHmac = phoneHmac;
counter.windowStart = now;
counter.lastSentAt = Instant.EPOCH;
counter.sendCount = 0;
entityManager.persist(counter);
} else if (counter.windowStart.plus(Duration.ofHours(24)).isBefore(now)) {
counter.windowStart = now;
counter.sendCount = 0;
}
if (counter.sendCount >= limits.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, "limited", "daily_limit");
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");
}
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();
OtpChallengeEntity challenge = new OtpChallengeEntity();
challenge.id = Crypto.randomId();
challenge.phoneHmac = phoneHmac;
challenge.destinationMasked = PhoneNormalizer.mask(phone);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, Config.MOCK_CODE);
challenge.createdAt = now;
challenge.expiresAt = now.plus(Config.OTP_TTL);
challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = Config.MAX_VERIFY_ATTEMPTS;
challenge.settingsVersion = limits.version();
challenge.providerId = "mock-" + Crypto.randomId();
challenge.providerStatus = "accepted";
entityManager.persist(challenge);
event("otp_send", phoneHmac, challenge.id, "success", "mock");
return challenge;
}
boolean consume(String challengeId, String suppliedCode) {
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");
return false;
}
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
event("otp_verify", challenge.phoneHmac, challengeId, "limited", "attempt_limit");
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");
return false;
}
challenge.consumedAt = now;
challenge.providerStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, "success", "verified");
return true;
}
private void event(String type, String phoneHmac, String challengeId, String outcome, String details) {
OtpSecurityEventEntity event = new OtpSecurityEventEntity();
event.id = Crypto.randomId();
event.occurredAt = Instant.now();
event.eventType = type;
event.phoneHmac = phoneHmac;
event.challengeId = challengeId;
event.outcome = outcome;
event.details = details;
entityManager.persist(event);
}
static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); }
}
}
@@ -0,0 +1,53 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.Response;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public final class PhoneIdentityAuthenticator implements Authenticator {
static final String PHONE_NOTE = "han.phone";
static final String CHALLENGE_NOTE = "han.otp.challenge";
static final String MASKED_NOTE = "han.phone.masked";
private final PhoneNormalizer normalizer = new PhoneNormalizer();
@Override
public void authenticate(AuthenticationFlowContext context) {
if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) {
context.success();
return;
}
context.challenge(context.form().createForm("phone.ftl"));
}
@Override
public void action(AuthenticationFlowContext context) {
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
try {
String phone = normalizer.normalize(rawPhone);
SettingsBridge.Limits limits = SettingsBridge.get();
var challenge = new OtpStore(context.getSession()).reserve(phone, limits);
context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone);
context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id);
context.getAuthenticationSession().setAuthNote(MASKED_NOTE, challenge.destinationMasked);
context.success();
} catch (IllegalArgumentException exception) {
Response response = context.form().setError("phoneInvalid").createForm("phone.ftl");
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
} catch (OtpStore.OtpLimitException exception) {
Response response = context.form().setError("otpLimited").createForm("phone.ftl");
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response);
} catch (RuntimeException exception) {
Response response = context.form().setError("otpUnavailable").createForm("phone.ftl");
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response);
}
}
@Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@Override public void close() {}
}
@@ -0,0 +1,31 @@
package ru.han.chat.keycloak;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
public final class PhoneIdentityAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "han-phone-identity";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
private static final PhoneIdentityAuthenticator SINGLETON = new PhoneIdentityAuthenticator();
@Override public Authenticator create(KeycloakSession session) { return SINGLETON; }
@Override public String getId() { return ID; }
@Override public String getDisplayType() { return "HAN Phone Identity"; }
@Override public String getReferenceCategory() { return "phone"; }
@Override public boolean isConfigurable() { return false; }
@Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; }
@Override public boolean isUserSetupAllowed() { return false; }
@Override public String getHelpText() { return "Normalizes E.164 phone and creates a durable OTP challenge."; }
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
@Override public void init(Config.Scope config) {}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
}
@@ -0,0 +1,38 @@
package ru.han.chat.keycloak;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import java.text.Normalizer;
public final class PhoneNormalizer {
private static final PhoneNumberUtil UTIL = PhoneNumberUtil.getInstance();
public String normalize(String input) {
if (input == null || input.isBlank()) throw new IllegalArgumentException("phone_required");
String normalized = normalizeDigits(Normalizer.normalize(input.trim(), Normalizer.Form.NFKC));
try {
var parsed = UTIL.parse(normalized, normalized.startsWith("+") ? "ZZ" : "RU");
if (!UTIL.isValidNumber(parsed) || !UTIL.isPossibleNumber(parsed)) {
throw new IllegalArgumentException("phone_invalid");
}
return UTIL.format(parsed, PhoneNumberUtil.PhoneNumberFormat.E164);
} catch (NumberParseException exception) {
throw new IllegalArgumentException("phone_invalid", exception);
}
}
static String normalizeDigits(String value) {
StringBuilder result = new StringBuilder(value.length());
value.codePoints().forEach(codePoint -> {
if (Character.isDigit(codePoint)) result.append(Character.getNumericValue(codePoint));
else result.appendCodePoint(codePoint);
});
return result.toString();
}
public static String mask(String e164) {
return e164.length() < 7
? "***"
: e164.substring(0, 2) + "*****" + e164.substring(e164.length() - 4);
}
}
@@ -0,0 +1,69 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.Response;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public final class PhoneOtpAuthenticator implements Authenticator {
@Override
public void authenticate(AuthenticationFlowContext context) {
String challengeId = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
if (challengeId == null) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
String masked = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE);
context.challenge(context.form().setAttribute("maskedPhone", masked).createForm("otp.ftl"));
}
@Override
public void action(AuthenticationFlowContext context) {
String challengeId = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE);
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");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
UserModel user = context.getSession().users()
.searchForUserByUserAttributeStream(context.getRealm(), "phone_number", phone)
.findFirst()
.orElseGet(() -> {
UserModel created = context.getSession().users().addUser(context.getRealm(), phone);
created.setEnabled(true);
created.setSingleAttribute("phone_number", phone);
created.setSingleAttribute("phone_number_verified", "true");
return created;
});
if (!user.isEnabled()) {
context.failure(AuthenticationFlowError.USER_DISABLED);
return;
}
user.setSingleAttribute("phone_number", phone);
user.setSingleAttribute("phone_number_verified", "true");
context.setUser(user);
context.getAuthenticationSession().setUserSessionNote("amr", "phone_otp");
context.success();
}
@Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@Override public void close() {}
}
@@ -0,0 +1,35 @@
package ru.han.chat.keycloak;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "han-phone-otp";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
private static final PhoneOtpAuthenticator SINGLETON = new PhoneOtpAuthenticator();
@Override public Authenticator create(KeycloakSession session) { return SINGLETON; }
@Override public String getId() { return ID; }
@Override public String getDisplayType() { return "HAN Phone OTP"; }
@Override public String getReferenceCategory() { return "phone-otp"; }
@Override public boolean isConfigurable() { return false; }
@Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; }
@Override public boolean isUserSetupAllowed() { return false; }
@Override public String getHelpText() { return "Verifies and atomically consumes a durable phone OTP challenge."; }
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
@Override public void init(Config.Scope config) {
if (!ru.han.chat.keycloak.Config.MOCK_ENABLED) {
throw new IllegalStateException("OTP delivery provider is not configured");
}
}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
}
@@ -0,0 +1,81 @@
package ru.han.chat.keycloak;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jboss.logging.Logger;
final class SettingsBridge {
private static final Logger LOG = Logger.getLogger(SettingsBridge.class);
private static final Pattern INT = Pattern.compile("\"%s\"\\s*:\\s*(\\d+)");
private static final Pattern STRING = Pattern.compile("\"%s\"\\s*:\\s*\"([^\"]+)\"");
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build();
private static volatile Cached cached;
record Limits(int maxSendsPer24h, int minSecondsBetween, String version) {}
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {}
private SettingsBridge() {}
static Limits get() {
Cached local = cached;
Instant now = Instant.now();
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
synchronized (SettingsBridge.class) {
local = cached;
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
try {
HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL)
.timeout(Duration.ofSeconds(3))
.header("Authorization", "Bearer " + Config.SETTINGS_TOKEN)
.header("Accept", "application/json")
.GET();
if (local != null && local.etag != null) builder.header("If-None-Match", local.etag);
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 304 && local != null) {
cached = new Cached(local.limits, now, now.plusSeconds(60), local.etag);
return local.limits;
}
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 ttl = integer(response.body(), "cache_ttl_seconds");
String version = string(response.body(), "version");
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400 || ttl < 1 || ttl > 3600) {
throw new IllegalStateException("settings_invalid_range");
}
Limits limits = new Limits(max, minimum, version);
cached = new Cached(limits, now, now.plusSeconds(ttl),
response.headers().firstValue("ETag").orElse(null));
return limits;
} 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;
}
throw new IllegalStateException("OTP settings unavailable; send denied", exception);
}
}
}
private static int integer(String json, String field) {
Matcher matcher = Pattern.compile(INT.pattern().formatted(Pattern.quote(field))).matcher(json);
if (!matcher.find()) throw new IllegalStateException("settings_missing_" + field);
return Integer.parseInt(matcher.group(1));
}
private static String string(String json, String field) {
Matcher matcher = Pattern.compile(STRING.pattern().formatted(Pattern.quote(field))).matcher(json);
if (!matcher.find()) throw new IllegalStateException("settings_missing_" + field);
return matcher.group(1);
}
static void clearForTests() {
cached = null;
}
}
@@ -0,0 +1,26 @@
package ru.han.chat.keycloak.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
@Entity
@Table(name = "han_otp_challenge")
public class OtpChallengeEntity {
@Id @Column(length = 32) public String id;
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
@Column(name = "destination_masked", nullable = false, length = 32) public String destinationMasked;
@Column(name = "otp_hash", nullable = false, length = 64) public String otpHash;
@Column(name = "created_at", nullable = false) public Instant createdAt;
@Column(name = "expires_at", nullable = false) public Instant expiresAt;
@Column(name = "consumed_at") public Instant consumedAt;
@Column(name = "verify_attempts", nullable = false) public int verifyAttempts;
@Column(name = "max_verify_attempts", nullable = false) public int maxVerifyAttempts;
@Column(name = "settings_version", nullable = false, length = 128) public String settingsVersion;
@Column(name = "provider_id", nullable = false, length = 128) public String providerId;
@Column(name = "provider_status", nullable = false, length = 32) public String providerStatus;
@Version public long version;
}
@@ -0,0 +1,19 @@
package ru.han.chat.keycloak.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "han_otp_security_event")
public class OtpSecurityEventEntity {
@Id @Column(length = 32) public String id;
@Column(name = "occurred_at", nullable = false) public Instant occurredAt;
@Column(name = "event_type", nullable = false, length = 64) public String eventType;
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
@Column(name = "challenge_id", length = 32) public String challengeId;
@Column(name = "outcome", nullable = false, length = 32) public String outcome;
@Column(name = "details", length = 256) public String details;
}
@@ -0,0 +1,19 @@
package ru.han.chat.keycloak.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
@Entity
@Table(name = "han_otp_send_counter")
public class OtpSendCounterEntity {
@Id @Column(length = 128) public String id;
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
@Column(name = "window_start", nullable = false) public Instant windowStart;
@Column(name = "send_count", nullable = false) public int sendCount;
@Column(name = "last_sent_at", nullable = false) public Instant lastSentAt;
@Version public long version;
}
@@ -0,0 +1,27 @@
package ru.han.chat.keycloak.persistence;
import java.util.List;
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
import ru.han.chat.keycloak.entity.OtpSecurityEventEntity;
import ru.han.chat.keycloak.entity.OtpSendCounterEntity;
public final class HanJpaEntityProvider implements JpaEntityProvider {
@Override
public List<Class<?>> getEntities() {
return List.of(OtpChallengeEntity.class, OtpSendCounterEntity.class, OtpSecurityEventEntity.class);
}
@Override
public String getChangelogLocation() {
return "META-INF/han-otp-changelog.xml";
}
@Override
public String getFactoryId() {
return HanJpaEntityProviderFactory.ID;
}
@Override
public void close() {}
}
@@ -0,0 +1,17 @@
package ru.han.chat.keycloak.persistence;
import org.keycloak.Config;
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public final class HanJpaEntityProviderFactory implements JpaEntityProviderFactory {
public static final String ID = "han-phone-otp-jpa";
@Override public JpaEntityProvider create(KeycloakSession session) { return new HanJpaEntityProvider(); }
@Override public void init(Config.Scope config) {}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
@Override public String getId() { return ID; }
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<changeSet id="han-otp-1.0.0" author="han-chat">
<createTable tableName="han_otp_challenge">
<column name="id" type="varchar(32)"><constraints primaryKey="true" nullable="false"/></column>
<column name="phone_hmac" type="varchar(64)"><constraints nullable="false"/></column>
<column name="destination_masked" type="varchar(32)"><constraints nullable="false"/></column>
<column name="otp_hash" type="varchar(64)"><constraints nullable="false"/></column>
<column name="created_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="expires_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="consumed_at" type="timestamp with time zone"/>
<column name="verify_attempts" type="int"><constraints nullable="false"/></column>
<column name="max_verify_attempts" type="int"><constraints nullable="false"/></column>
<column name="settings_version" type="varchar(128)"><constraints nullable="false"/></column>
<column name="provider_id" type="varchar(128)"><constraints nullable="false"/></column>
<column name="provider_status" type="varchar(32)"><constraints nullable="false"/></column>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_phone">
<column name="phone_hmac"/>
</createIndex>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_expiry">
<column name="expires_at"/>
</createIndex>
<createTable tableName="han_otp_send_counter">
<column name="id" type="varchar(128)"><constraints primaryKey="true" nullable="false"/></column>
<column name="phone_hmac" type="varchar(64)"><constraints nullable="false"/></column>
<column name="window_start" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="send_count" type="int"><constraints nullable="false"/></column>
<column name="last_sent_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="han_otp_send_counter" indexName="ix_han_otp_counter_phone_window">
<column name="phone_hmac"/><column name="window_start"/>
</createIndex>
<createTable tableName="han_otp_security_event">
<column name="id" type="varchar(32)"><constraints primaryKey="true" nullable="false"/></column>
<column name="occurred_at" type="timestamp with time zone"><constraints nullable="false"/></column>
<column name="event_type" type="varchar(64)"><constraints nullable="false"/></column>
<column name="phone_hmac" type="varchar(64)"><constraints nullable="false"/></column>
<column name="challenge_id" type="varchar(32)"/>
<column name="outcome" type="varchar(32)"><constraints nullable="false"/></column>
<column name="details" type="varchar(256)"/>
</createTable>
<createIndex tableName="han_otp_security_event" indexName="ix_han_otp_event_time">
<column name="occurred_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,2 @@
ru.han.chat.keycloak.PhoneIdentityAuthenticatorFactory
ru.han.chat.keycloak.PhoneOtpAuthenticatorFactory
@@ -0,0 +1 @@
ru.han.chat.keycloak.persistence.HanJpaEntityProviderFactory
@@ -0,0 +1,22 @@
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.assertTrue;
import org.junit.jupiter.api.Test;
class CryptoTest {
@Test
void challengeIdsAreUniqueAndContainAtLeast128Bits() {
String first = Crypto.randomId();
String second = Crypto.randomId();
assertNotEquals(first, second);
assertTrue(first.length() >= 22);
}
@Test
void constantTimeComparisonChecksEntireValue() {
assertTrue(Crypto.constantTimeEquals("same-value", "same-value"));
assertFalse(Crypto.constantTimeEquals("same-value", "same-valuf"));
}
}
@@ -0,0 +1,34 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class PhoneNormalizerTest {
private final PhoneNormalizer normalizer = new PhoneNormalizer();
@Test
void normalizesRussianNationalNumber() {
assertEquals("+79001234567", normalizer.normalize("8 (900) 123-45-67"));
}
@Test
void normalizesInternationalNumber() {
assertEquals("+442079460018", normalizer.normalize("+44 20 7946 0018"));
}
@Test
void normalizesUnicodeDigitsWithNfkc() {
assertEquals("+79001234567", normalizer.normalize("+ 900 123 45 67"));
}
@Test
void rejectsImpossibleNumber() {
assertThrows(IllegalArgumentException.class, () -> normalizer.normalize("+700"));
}
@Test
void masksPersonallyIdentifyingDigits() {
assertEquals("+7*****4567", PhoneNormalizer.mask("+79001234567"));
}
}
@@ -0,0 +1,47 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class RealmContractTest {
private final String realm = readRealm();
@Test
void requiresCodeFlowPkceAndDisablesUnsafeGrants() {
assertTrue(realm.contains("\"pkce.code.challenge.method\": \"S256\""));
assertTrue(realm.contains("\"standardFlowEnabled\": true"));
assertTrue(realm.contains("\"implicitFlowEnabled\": false"));
assertTrue(realm.contains("\"directAccessGrantsEnabled\": false"));
assertTrue(realm.contains("\"publicClient\": true"));
}
@Test
void includesAudiencePhoneClaimsAndRefreshRotation() {
assertTrue(realm.contains("\"included.client.audience\": \"han-chat-api\""));
assertTrue(realm.contains("\"claim.name\": \"phone_number\""));
assertTrue(realm.contains("\"claim.name\": \"phone_number_verified\""));
assertTrue(realm.contains("\"revokeRefreshToken\": true"));
assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0"));
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
assertTrue(realm.contains("\"han-chat://auth/callback\""));
}
@Test
void containsNoSecretsOrWildcardOrigins() {
assertFalse(realm.contains("KEYCLOAK_OTP_MOCK_CODE"));
assertFalse(realm.contains("\"webOrigins\": [\"*\"]"));
assertFalse(realm.contains("\"redirectUris\": [\"*\"]"));
assertFalse(realm.contains("\"secret\":"));
}
private static String readRealm() {
try {
return Files.readString(Path.of("realm", "han-chat-realm.json"));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
}