Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
target
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
*.log
|
||||
.env
|
||||
@@ -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¤tSchema=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
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
+53
@@ -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() {}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import java.util.List;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.AuthenticatorFactory;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
|
||||
public final class PhoneIdentityAuthenticatorFactory implements AuthenticatorFactory {
|
||||
public static final String ID = "han-phone-identity";
|
||||
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = {
|
||||
AuthenticationExecutionModel.Requirement.REQUIRED
|
||||
};
|
||||
private static final PhoneIdentityAuthenticator SINGLETON = new PhoneIdentityAuthenticator();
|
||||
|
||||
@Override public Authenticator create(KeycloakSession session) { return SINGLETON; }
|
||||
@Override public String getId() { return ID; }
|
||||
@Override public String getDisplayType() { return "HAN Phone Identity"; }
|
||||
@Override public String getReferenceCategory() { return "phone"; }
|
||||
@Override public boolean isConfigurable() { return false; }
|
||||
@Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; }
|
||||
@Override public boolean isUserSetupAllowed() { return false; }
|
||||
@Override public String getHelpText() { return "Normalizes E.164 phone and creates a durable OTP challenge."; }
|
||||
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
|
||||
@Override public void init(Config.Scope config) {}
|
||||
@Override public void postInit(KeycloakSessionFactory factory) {}
|
||||
@Override public void close() {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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() {}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package ru.han.chat.keycloak.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import 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;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package ru.han.chat.keycloak.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "han_otp_send_counter")
|
||||
public class OtpSendCounterEntity {
|
||||
@Id @Column(length = 128) public String id;
|
||||
@Column(name = "phone_hmac", nullable = false, length = 64) public String phoneHmac;
|
||||
@Column(name = "window_start", nullable = false) public Instant windowStart;
|
||||
@Column(name = "send_count", nullable = false) public int sendCount;
|
||||
@Column(name = "last_sent_at", nullable = false) public Instant lastSentAt;
|
||||
@Version public long version;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ru.han.chat.keycloak.persistence;
|
||||
|
||||
import java.util.List;
|
||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
||||
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
|
||||
import ru.han.chat.keycloak.entity.OtpSecurityEventEntity;
|
||||
import ru.han.chat.keycloak.entity.OtpSendCounterEntity;
|
||||
|
||||
public final class HanJpaEntityProvider implements JpaEntityProvider {
|
||||
@Override
|
||||
public List<Class<?>> getEntities() {
|
||||
return List.of(OtpChallengeEntity.class, OtpSendCounterEntity.class, OtpSecurityEventEntity.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getChangelogLocation() {
|
||||
return "META-INF/han-otp-changelog.xml";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFactoryId() {
|
||||
return HanJpaEntityProviderFactory.ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package ru.han.chat.keycloak.persistence;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public final class HanJpaEntityProviderFactory implements JpaEntityProviderFactory {
|
||||
public static final String ID = "han-phone-otp-jpa";
|
||||
|
||||
@Override public JpaEntityProvider create(KeycloakSession session) { return new HanJpaEntityProvider(); }
|
||||
@Override public void init(Config.Scope config) {}
|
||||
@Override public void postInit(KeycloakSessionFactory factory) {}
|
||||
@Override public void close() {}
|
||||
@Override public String getId() { return ID; }
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ru.han.chat.keycloak.PhoneIdentityAuthenticatorFactory
|
||||
ru.han.chat.keycloak.PhoneOtpAuthenticatorFactory
|
||||
+1
@@ -0,0 +1 @@
|
||||
ru.han.chat.keycloak.persistence.HanJpaEntityProviderFactory
|
||||
@@ -0,0 +1,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("+7 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
|
||||
Reference in New Issue
Block a user