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

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
+7
View File
@@ -0,0 +1,7 @@
target
.git
.idea
.vscode
*.iml
*.log
.env
+17
View File
@@ -0,0 +1,17 @@
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.internal:6432/han_chat?sslmode=verify-full&currentSchema=keycloak&ApplicationName=keycloak
KC_DB_URL_PROPERTIES=currentSchema=keycloak
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin
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_MAX_VERIFY_ATTEMPTS=5
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_LOG_LEVEL=INFO
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
+25
View File
@@ -0,0 +1,25 @@
ARG KEYCLOAK_VERSION=26.1.4
FROM maven:3.9.9-eclipse-temurin-21 AS provider-build
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
COPY --from=provider-build /build/target/han-phone-otp-provider.jar /opt/keycloak/providers/
COPY themes/han-phone /opt/keycloak/themes/han-phone
ENV KC_HEALTH_ENABLED=true \
KC_METRICS_ENABLED=true \
KC_DB=postgres \
KC_HTTP_RELATIVE_PATH=/auth
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.1.4
COPY --from=keycloak-build --chown=keycloak:keycloak /opt/keycloak/ /opt/keycloak/
COPY --chown=keycloak:keycloak realm/han-chat-realm.json /opt/keycloak/data/import/han-chat-realm.json
USER 1000
EXPOSE 8080 9000
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized", "--import-realm"]
+88
View File
@@ -0,0 +1,88 @@
# HAN Chat Keycloak
Production-like Keycloak 26.1.4 image and realm for OTP-only phone authentication. The module is self-contained and does not publish host ports; root nginx must proxy `/auth/*` to `keycloak:8080`.
## Security contract
- Realm `han-chat`; public client `han-chat-frontend`.
- Authorization Code flow only, mandatory PKCE S256; implicit, password/direct, device and service-account grants are disabled.
- 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 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.
## Build and test
Requires Java 21 and Maven 3.9:
```bash
mvn -B -ntp clean verify
docker build -t han-chat/keycloak:26.1.4-otp-1.0.0 .
```
The Maven build shades only libphonenumber into the provider JAR; Keycloak SPI dependencies remain provided by the pinned server image.
## Configuration
Copy values from `.env.example` into the root backend `.env`; never commit `.env`. Generate independent random values for admin password, mock code, OTP HMAC key and settings bridge token.
Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`:
- `https://APP_LINK_HOST.example/auth/callback`
- `https://APP_LINK_HOST.example/auth/logout`
- `https://APP_WEB_ORIGIN.example`
Use exact Expo universal/app links and web origins. Do not replace them with wildcards. `https://tohin.ru/auth/callback` and `han-chat://auth/callback` are already allow-listed.
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
## Runtime
For standalone validation:
```bash
docker compose --env-file .env up --build
```
The service exposes only Docker-network ports:
- application HTTP: `8080`, relative path `/auth`;
- management health and metrics: `9000`;
- readiness: `GET http://keycloak:9000/auth/health/ready`;
- liveness: `GET http://keycloak:9000/auth/health/live`;
- Prometheus metrics: `GET http://keycloak:9000/auth/metrics`.
Only nginx may publish external ports. Preserve `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443` and the trusted client IP chain.
## Realm lifecycle
`--import-realm` is suitable for a clean environment. It does not safely reconcile an existing production realm. For changes to a live realm:
1. take a managed PostgreSQL backup/PITR checkpoint and export the current realm without users/secrets;
2. compare the desired safe subset (clients, scopes, flows, token policy);
3. apply through a controlled admin job or Admin API procedure;
4. verify discovery issuer, JWKS, PKCE login, refresh rotation and logout;
5. retain old passive signing keys until all tokens signed by them expire.
Private signing keys are generated and stored by Keycloak and are absent from the realm JSON.
## OTP data and operations
Provider tables:
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock;
- `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.
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.
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.
## 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.
Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer `https://tohin.ru/auth/realms/han-chat`, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.
@@ -0,0 +1,61 @@
services:
keycloak:
build:
context: .
dockerfile: Dockerfile
image: han-chat/keycloak:26.1.4-otp-1.0.0
command: ["start", "--optimized", "--import-realm"]
environment:
KC_DB: postgres
KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required}
KC_DB_URL_PROPERTIES: ${KC_DB_URL_PROPERTIES:-currentSchema=keycloak}
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-https://tohin.ru/auth}
KC_HOSTNAME_STRICT: "true"
KC_HTTP_ENABLED: "true"
KC_HTTP_PORT: "8080"
KC_HTTP_RELATIVE_PATH: /auth
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HTTP_MANAGEMENT_PORT: "9000"
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_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
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}
KC_LOG_CONSOLE_OUTPUT: json
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
expose:
- "8080"
- "9000"
networks:
- public
- backend
- observability
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"]
interval: 15s
timeout: 5s
retries: 12
start_period: 60s
read_only: true
tmpfs:
- /tmp:size=64m,mode=1770
- /opt/keycloak/data/tmp:size=64m,uid=1000,gid=0,mode=0770
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
restart: unless-stopped
networks:
public:
backend:
observability:
+102
View File
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.han.chat</groupId>
<artifactId>han-phone-otp-provider</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<keycloak.version>26.1.4</keycloak.version>
<libphonenumber.version>8.13.55</libphonenumber.version>
<junit.version>5.11.4</junit.version>
<mockito.version>5.15.2</mockito.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>${libphonenumber.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>han-phone-otp-provider</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<artifactSet>
<includes>
<include>com.googlecode.libphonenumber:libphonenumber</include>
</includes>
</artifactSet>
<createDependencyReducedPom>false</createDependencyReducedPom>
<shadedArtifactAttached>false</shadedArtifactAttached>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,205 @@
{
"realm": "han-chat",
"enabled": true,
"displayName": "HAN Chat",
"registrationAllowed": false,
"registrationEmailAsUsername": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"loginWithEmailAllowed": false,
"duplicateEmailsAllowed": false,
"verifyEmail": false,
"rememberMe": true,
"sslRequired": "external",
"defaultSignatureAlgorithm": "RS256",
"accessTokenLifespan": 300,
"accessCodeLifespan": 60,
"accessCodeLifespanLogin": 300,
"ssoSessionIdleTimeout": 2592000,
"ssoSessionMaxLifespan": 7776000,
"clientSessionIdleTimeout": 2592000,
"clientSessionMaxLifespan": 7776000,
"revokeRefreshToken": true,
"refreshTokenMaxReuse": 0,
"offlineSessionMaxLifespanEnabled": true,
"offlineSessionMaxLifespan": 0,
"bruteForceProtected": true,
"permanentLockout": false,
"maxTemporaryLockouts": 0,
"failureFactor": 5,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"minimumQuickLoginWaitSeconds": 60,
"maxFailureWaitSeconds": 900,
"maxDeltaTimeSeconds": 43200,
"eventsEnabled": true,
"eventsExpiration": 7776000,
"enabledEventTypes": [
"LOGIN", "LOGIN_ERROR", "LOGOUT", "LOGOUT_ERROR",
"REFRESH_TOKEN", "REFRESH_TOKEN_ERROR", "REVOKE_GRANT", "REVOKE_GRANT_ERROR"
],
"adminEventsEnabled": true,
"adminEventsDetailsEnabled": false,
"internationalizationEnabled": true,
"supportedLocales": ["ru"],
"defaultLocale": "ru",
"loginTheme": "han-phone",
"browserFlow": "han-phone-otp-browser",
"clients": [
{
"clientId": "han-chat-frontend",
"name": "HAN Chat Frontend",
"enabled": true,
"publicClient": true,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": false,
"frontchannelLogout": true,
"fullScopeAllowed": false,
"redirectUris": [
"https://tohin.ru/auth/callback",
"han-chat://auth/callback",
"https://APP_LINK_HOST.example/auth/callback"
],
"webOrigins": [
"https://tohin.ru",
"https://APP_WEB_ORIGIN.example"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://tohin.ru/##han-chat://auth/logout##https://APP_LINK_HOST.example/auth/logout",
"oauth2.device.authorization.grant.enabled": "false",
"oidc.ciba.grant.enabled": "false",
"use.refresh.tokens": "true",
"client.use.lightweight.access.token.enabled": "false"
},
"defaultClientScopes": ["openid", "profile", "phone", "han-chat-api"],
"optionalClientScopes": ["offline_access"]
}
],
"clientScopes": [
{
"name": "phone",
"description": "Verified E.164 phone claims",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "phone number",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "phone_number",
"claim.name": "phone_number",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "phone verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "phone_number_verified",
"claim.name": "phone_number_verified",
"jsonType.label": "boolean",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
},
{
"name": "han-chat-api",
"description": "HAN Chat API audience",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "han-chat-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "han-chat-api",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"authenticationFlows": [
{
"alias": "han-phone-otp-browser",
"description": "Cookie SSO or phone OTP only",
"providerId": "basic-flow",
"topLevel": true,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "auth-cookie",
"requirement": "ALTERNATIVE",
"priority": 10,
"authenticatorFlow": false
},
{
"flowAlias": "han-phone-otp-forms",
"requirement": "ALTERNATIVE",
"priority": 20,
"authenticatorFlow": true
}
]
},
{
"alias": "han-phone-otp-forms",
"description": "Normalize phone, reserve challenge and verify OTP",
"providerId": "basic-flow",
"topLevel": false,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "han-phone-identity",
"requirement": "REQUIRED",
"priority": 10,
"authenticatorFlow": false
},
{
"authenticator": "han-phone-otp",
"requirement": "REQUIRED",
"priority": 20,
"authenticatorFlow": false
}
]
}
],
"requiredActions": [],
"components": {
"org.keycloak.keys.KeyProvider": [
{
"name": "rsa-generated",
"providerId": "rsa-generated",
"subType": "rsa-generated",
"config": {
"priority": ["100"],
"algorithm": ["RS256"],
"keySize": ["2048"]
}
}
]
}
}
@@ -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);
}
}
}
@@ -0,0 +1,13 @@
phoneTitle=Вход по номеру телефона
phoneLabel=Номер телефона
phoneHelp=Укажите российский или международный номер. Мы используем его только для входа.
sendOtp=Получить код
otpTitle=Подтверждение телефона
otpLabel=Код подтверждения
otpSent=Код подтверждения подготовлен для номера {0}
verifyOtp=Войти
mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован.
otpLimited=Слишком много попыток. Повторите позже.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
@@ -0,0 +1,16 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">${msg("otpTitle")}
<#elseif section = "form">
<form id="kc-otp-form" action="${url.loginAction}" method="post">
<p class="han-help">${msg("otpSent", maskedPhone!"***")}</p>
<p class="han-test-mode">${msg("mockMode")}</p>
<div class="form-group">
<label for="otp">${msg("otpLabel")}</label>
<input id="otp" name="otp" type="password" inputmode="numeric" autocomplete="one-time-code"
minlength="4" maxlength="12" required autofocus/>
</div>
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">${msg("verifyOtp")}</button>
</form>
</#if>
</@layout.registrationLayout>
@@ -0,0 +1,16 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">${msg("phoneTitle")}
<#elseif section = "form">
<form id="kc-phone-form" action="${url.loginAction}" method="post">
<div class="form-group">
<label for="phone">${msg("phoneLabel")}</label>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel"
placeholder="+7 900 123-45-67" required autofocus
aria-invalid="<#if messagesPerField.existsError('phone')>true<#else>false</#if>"/>
</div>
<p class="han-help">${msg("phoneHelp")}</p>
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">${msg("sendOtp")}</button>
</form>
</#if>
</@layout.registrationLayout>
@@ -0,0 +1,18 @@
:root {
--han-primary: #246bfd;
--han-text: #172033;
}
body { color: var(--han-text); }
.pf-c-button.pf-m-primary { background: var(--han-primary); border-radius: 10px; min-height: 44px; }
input[type="tel"], input[name="otp"] {
box-sizing: border-box;
width: 100%;
min-height: 44px;
margin-top: 8px;
padding: 10px 12px;
border: 1px solid #9aa6bd;
border-radius: 8px;
}
.han-help { color: #596780; font-size: .9rem; }
.han-test-mode { color: #7a4b00; background: #fff3cd; padding: 8px 10px; border-radius: 8px; }
@@ -0,0 +1,4 @@
parent=keycloak.v2
import=common/keycloak
styles=css/han-login.css
locales=ru