Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
target
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,20 @@
|
||||
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_YANDEX_CAPTCHA_ENABLED=false
|
||||
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=
|
||||
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=
|
||||
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
|
||||
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
|
||||
KEYCLOAK_SMS_SERVICE_TOKEN=replace-with-independent-service-token
|
||||
|
||||
KEYCLOAK_LOG_LEVEL=INFO
|
||||
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
COPY realm ./realm
|
||||
COPY themes ./themes
|
||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
||||
|
||||
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
|
||||
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
|
||||
COPY --chown=keycloak:keycloak --chmod=0555 container-entrypoint.sh /opt/keycloak/bin/han-container-entrypoint
|
||||
USER 1000
|
||||
EXPOSE 8080 9000
|
||||
ENTRYPOINT ["/opt/keycloak/bin/han-container-entrypoint"]
|
||||
CMD ["start", "--optimized", "--import-realm"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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 migrations are applied by Keycloak's JPA entity provider.
|
||||
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
|
||||
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
|
||||
- Every challenge snapshots code length, TTL, SMS-order timeout and settings version. Runtime OTP values are not read from environment variables.
|
||||
- Mock mode is explicit and retains the configured test code. SMS mode generates a cryptographically secure numeric OTP, stores only its HMAC and orders delivery through `POST /internal/sms/v1/send`; Keycloak never calls or polls the provider.
|
||||
- SMS mode requires `KEYCLOAK_SMS_SERVICE_URL` and an independent `KEYCLOAK_SMS_SERVICE_TOKEN`. No real credentials are committed.
|
||||
|
||||
## Build and test
|
||||
|
||||
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.
|
||||
|
||||
### Yandex SmartCaptcha
|
||||
|
||||
Invisible SmartCaptcha protects every operation that orders an OTP SMS, including resend. It is disabled by default. To enable it, create one CAPTCHA in Yandex Cloud, add the public login hostname (without `https://`) to allowed sites and set:
|
||||
|
||||
```env
|
||||
KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true
|
||||
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=<public-client-key>
|
||||
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=<secret-server-key>
|
||||
```
|
||||
|
||||
The browser token is validated by Keycloak before `OtpFlow.start()`. A provider rejection, missing token or non-temporary HTTP 4xx denies the SMS order. Timeout, I/O, HTTP 408/429/5xx and malformed provider responses are logged without token/phone/keys and handled fail-open. Tokens are one-time and a resend always executes a fresh CAPTCHA.
|
||||
|
||||
SmartCaptcha CSP is applied only by nginx to the `han-chat` login and login-action endpoints. Never set a custom `browserSecurityHeaders.contentSecurityPolicy` in the realm: it can break Keycloak Admin Console and third-party cookie iframes.
|
||||
|
||||
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 explicit ordering/active/final statuses, settings snapshot and optional `sms_message_id`;
|
||||
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
|
||||
- `han_otp_security_event`: append-only send/verify outcomes with SMS correlation and validated device audit metadata, without raw phone or OTP.
|
||||
|
||||
Resend creates a new durable order and marks earlier active/ordering challenges as superseded. Verification accepts only active, unexpired challenges, locks the row, increments attempts, and atomically consumes a valid code. Provider delivery status never participates in verification.
|
||||
|
||||
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
|
||||
|
||||
The provider schedules a once-per-minute expiry update and also performs lazy expiry on send and verify. The theme renders digit inputs and countdown from the challenge snapshot, submits a real resend action and carries optional `han_*` device metadata.
|
||||
|
||||
## SMS order behavior
|
||||
|
||||
`200` or `202` with a valid UUID `sms_message_id` and ISO-8601 `ordered_at` activates a real-mode challenge. Timeout, I/O failure or 5xx is retried once with the same `keycloak:challenge:{id}` idempotency key; final failure marks that challenge `order_failed`. The retry creates neither another challenge nor another send-counter increment.
|
||||
|
||||
Reserve, SMS HTTP order, and activation/order-failure run as separate transaction phases. The HTTP call holds no challenge/counter database lock, and every retry retains the same challenge id.
|
||||
|
||||
## Release and recovery
|
||||
|
||||
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
|
||||
|
||||
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,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec /opt/keycloak/bin/kc.sh "$@"
|
||||
@@ -0,0 +1,98 @@
|
||||
services:
|
||||
keycloak:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: han-chat/keycloak:26.1.4-otp-1.0.0
|
||||
command: ["start", "--optimized", "--import-realm"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: >-
|
||||
KC_DB_PASSWORD KC_BOOTSTRAP_ADMIN_PASSWORD KEYCLOAK_OTP_MOCK_CODE
|
||||
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY KEYCLOAK_OTP_HMAC_KEY
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN KEYCLOAK_SMS_SERVICE_TOKEN
|
||||
KC_DB_PASSWORD_FILE: /run/secrets/keycloak_db_password
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD_FILE: /run/secrets/keycloak_admin_password
|
||||
KEYCLOAK_OTP_MOCK_CODE_FILE: /run/secrets/keycloak_otp_mock_code
|
||||
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY_FILE: /run/secrets/keycloak_yandex_captcha_server_key
|
||||
KEYCLOAK_OTP_HMAC_KEY_FILE: /run/secrets/keycloak_otp_hmac_key
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN_FILE: /run/secrets/keycloak_settings_bridge_token
|
||||
KEYCLOAK_SMS_SERVICE_TOKEN_FILE: /run/secrets/keycloak_sms_service_token
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required}
|
||||
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME:?KEYCLOAK_DB_USERNAME 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}
|
||||
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
|
||||
KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ${KEYCLOAK_YANDEX_CAPTCHA_ENABLED:-false}
|
||||
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: ${KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY:-}
|
||||
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_SMS_SERVICE_URL: ${KEYCLOAK_SMS_SERVICE_URL:-http://sms-service:8080}
|
||||
KC_LOG_CONSOLE_OUTPUT: json
|
||||
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
|
||||
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
|
||||
secrets:
|
||||
- keycloak_db_password
|
||||
- keycloak_admin_password
|
||||
- keycloak_otp_mock_code
|
||||
- keycloak_yandex_captcha_server_key
|
||||
- keycloak_otp_hmac_key
|
||||
- keycloak_settings_bridge_token
|
||||
- keycloak_sms_service_token
|
||||
expose:
|
||||
- "8080"
|
||||
- "9000"
|
||||
networks:
|
||||
- public
|
||||
- backend
|
||||
- observability
|
||||
- egress
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/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
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
public:
|
||||
backend:
|
||||
observability:
|
||||
egress:
|
||||
|
||||
secrets:
|
||||
keycloak_db_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_DB_PASSWORD
|
||||
keycloak_admin_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_ADMIN_PASSWORD
|
||||
keycloak_otp_mock_code:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_OTP_MOCK_CODE
|
||||
keycloak_yandex_captcha_server_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY
|
||||
keycloak_otp_hmac_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_OTP_HMAC_KEY
|
||||
keycloak_settings_bridge_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_SETTINGS_BRIDGE_TOKEN
|
||||
keycloak_sms_service_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_SMS_SERVICE_TOKEN
|
||||
@@ -0,0 +1,108 @@
|
||||
<?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-core</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,217 @@
|
||||
{
|
||||
"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://chat.han0107.ru/auth/callback",
|
||||
"han-chat://auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://chat.han0107.ru"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"post.logout.redirect.uris": "https://chat.han0107.ru/##han-chat://auth/logout",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"client.use.lightweight.access.token.enabled": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "subject",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-sub-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": ["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,76 @@
|
||||
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 = env("KEYCLOAK_OTP_MOCK_CODE", "");
|
||||
static final boolean CAPTCHA_ENABLED = bool("KEYCLOAK_YANDEX_CAPTCHA_ENABLED", false);
|
||||
static final String CAPTCHA_CLIENT_KEY = env("KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY", "");
|
||||
static final String CAPTCHA_SERVER_KEY = env("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", "");
|
||||
static final URI CAPTCHA_VALIDATE_URL =
|
||||
URI.create("https://smartcaptcha.cloud.yandex.ru/validate");
|
||||
static final Duration CAPTCHA_TIMEOUT = Duration.ofMillis(1500);
|
||||
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
|
||||
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
|
||||
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"http://api-backend:8000/internal/settings/v1/otp"));
|
||||
static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN");
|
||||
static final URI SMS_SERVICE_URL = URI.create(env("KEYCLOAK_SMS_SERVICE_URL",
|
||||
"http://sms-service:8080")).resolve("/internal/sms/v1/send");
|
||||
static final String SMS_SERVICE_TOKEN = env("KEYCLOAK_SMS_SERVICE_TOKEN", "");
|
||||
|
||||
static {
|
||||
if (MOCK_ENABLED && (!MOCK_CODE.matches("\\d{6,10}") || "1234".equals(MOCK_CODE))) {
|
||||
throw new IllegalStateException(
|
||||
"KEYCLOAK_OTP_MOCK_CODE must be a non-default numeric code of 6 to 10 digits");
|
||||
}
|
||||
if (!MOCK_ENABLED
|
||||
&& SMS_SERVICE_TOKEN.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalStateException(
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN must contain at least 32 bytes in SMS mode");
|
||||
}
|
||||
if (CAPTCHA_ENABLED
|
||||
&& (CAPTCHA_CLIENT_KEY.isBlank() || CAPTCHA_SERVER_KEY.length() < 16)) {
|
||||
throw new IllegalStateException(
|
||||
"Yandex CAPTCHA client key and server key (at least 16 characters) are required");
|
||||
}
|
||||
if (HMAC_KEY.length < 32) {
|
||||
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private Config() {}
|
||||
|
||||
static void validate() {
|
||||
// Class initialization performs the fail-closed validation.
|
||||
}
|
||||
|
||||
private static String required(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
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) {
|
||||
String value = env(name, Boolean.toString(fallback));
|
||||
if ("true".equalsIgnoreCase(value)) return true;
|
||||
if ("false".equalsIgnoreCase(value)) return false;
|
||||
throw new IllegalStateException(name + " must be true or false");
|
||||
}
|
||||
|
||||
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,46 @@
|
||||
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 randomNumericCode(int length) {
|
||||
if (length < 4 || length > 10) {
|
||||
throw new IllegalArgumentException("OTP length must be between 4 and 10");
|
||||
}
|
||||
StringBuilder code = new StringBuilder(length);
|
||||
for (int index = 0; index < length; index++) {
|
||||
code.append(RANDOM.nextInt(10));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
static String hmac(String purpose, String value) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
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));
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import java.util.Set;
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
|
||||
record DeviceMetadata(
|
||||
String clientIp,
|
||||
String userAgent,
|
||||
String deviceId,
|
||||
String fingerprint,
|
||||
String osName,
|
||||
String osVersion,
|
||||
String platform,
|
||||
String appVersion) {
|
||||
private static final Set<String> PLATFORMS = Set.of("web", "ios", "android");
|
||||
|
||||
static DeviceMetadata capture(AuthenticationFlowContext context) {
|
||||
MultivaluedMap<String, String> form = context.getHttpRequest().getDecodedFormParameters();
|
||||
var session = context.getAuthenticationSession();
|
||||
MultivaluedMap<String, String> query = context.getHttpRequest().getUri().getQueryParameters();
|
||||
String deviceId = value(form, query, session.getAuthNote("han.device_id"), "han_device_id", 256);
|
||||
String fingerprint = value(form, query, session.getAuthNote("han.fingerprint"), "han_fingerprint", 256);
|
||||
String osName = value(form, query, session.getAuthNote("han.os_name"), "han_os_name", 64);
|
||||
String osVersion = value(form, query, session.getAuthNote("han.os_version"), "han_os_version", 64);
|
||||
String platform = value(form, query, session.getAuthNote("han.platform"), "han_platform", 16);
|
||||
String appVersion = value(form, query, session.getAuthNote("han.app_version"), "han_app_version", 64);
|
||||
if (platform != null && !PLATFORMS.contains(platform)) platform = null;
|
||||
|
||||
save(session, "han.device_id", deviceId);
|
||||
save(session, "han.fingerprint", fingerprint);
|
||||
save(session, "han.os_name", osName);
|
||||
save(session, "han.os_version", osVersion);
|
||||
save(session, "han.platform", platform);
|
||||
save(session, "han.app_version", appVersion);
|
||||
return new DeviceMetadata(
|
||||
clean(context.getConnection().getRemoteAddr(), 64),
|
||||
clean(context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent"), 1024),
|
||||
deviceId, fingerprint, osName, osVersion, platform, appVersion);
|
||||
}
|
||||
|
||||
private static String value(
|
||||
MultivaluedMap<String, String> form,
|
||||
MultivaluedMap<String, String> query,
|
||||
String saved,
|
||||
String name,
|
||||
int max) {
|
||||
String submitted = form.getFirst(name);
|
||||
if (submitted == null) submitted = query.getFirst(name);
|
||||
return clean(submitted == null ? saved : submitted, max);
|
||||
}
|
||||
|
||||
private static void save(
|
||||
org.keycloak.sessions.AuthenticationSessionModel session, String name, String value) {
|
||||
if (value == null) session.removeAuthNote(name);
|
||||
else session.setAuthNote(name, value);
|
||||
}
|
||||
|
||||
private static String clean(String value, int max) {
|
||||
if (value == null || value.isBlank() || value.length() > max) return null;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (Character.isISOControl(value.charAt(i))) return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
|
||||
|
||||
final class OtpFlow {
|
||||
private OtpFlow() {}
|
||||
|
||||
static OtpChallengeEntity start(
|
||||
AuthenticationFlowContext context,
|
||||
String phone,
|
||||
SettingsBridge.Settings settings,
|
||||
DeviceMetadata device) {
|
||||
OtpStore.Reservation reservation = KeycloakModelUtils.runJobInTransactionWithResult(
|
||||
context.getSession().getKeycloakSessionFactory(),
|
||||
session -> new OtpStore(session).reserve(phone, settings, device));
|
||||
OtpChallengeEntity challenge = reservation.challenge();
|
||||
if (!Config.MOCK_ENABLED) {
|
||||
String challengeId = challenge.id;
|
||||
try {
|
||||
String requestId = context.getHttpRequest().getHttpHeaders().getHeaderString("X-Request-ID");
|
||||
String traceparent = context.getHttpRequest().getHttpHeaders().getHeaderString("traceparent");
|
||||
SmsOrderClient.OrderResult order = new SmsOrderClient().order(
|
||||
challengeId, phone, reservation.otp(), settings, requestId, traceparent);
|
||||
challenge = KeycloakModelUtils.runJobInTransactionWithResult(
|
||||
context.getSession().getKeycloakSessionFactory(),
|
||||
session -> new OtpStore(session).activate(challengeId, order, device));
|
||||
} catch (RuntimeException exception) {
|
||||
KeycloakModelUtils.runJobInTransaction(
|
||||
context.getSession().getKeycloakSessionFactory(),
|
||||
session -> new OtpStore(session).orderFailed(challengeId, device));
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
return challenge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
|
||||
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();
|
||||
}
|
||||
|
||||
Reservation reserve(String phone, SettingsBridge.Settings settings, DeviceMetadata device) {
|
||||
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;
|
||||
}
|
||||
|
||||
expireDue(now);
|
||||
if (counter.sendCount >= settings.maxSendsPer24h()) {
|
||||
event("otp_send", phoneHmac, null, null, "limited", "daily_limit", device);
|
||||
throw new OtpLimitException("otp_send_limited");
|
||||
}
|
||||
if (counter.lastSentAt.plusSeconds(settings.minSecondsBetween()).isAfter(now)) {
|
||||
event("otp_send", phoneHmac, null, null, "limited", "cooldown", device);
|
||||
throw new OtpLimitException("otp_send_cooldown");
|
||||
}
|
||||
counter.sendCount++;
|
||||
counter.lastSentAt = now;
|
||||
|
||||
entityManager.createQuery("""
|
||||
update OtpChallengeEntity c set c.challengeStatus = 'superseded'
|
||||
where c.phoneHmac = :phone and c.challengeStatus in ('active', 'ordering')
|
||||
""").setParameter("phone", phoneHmac).executeUpdate();
|
||||
|
||||
OtpChallengeEntity challenge = new OtpChallengeEntity();
|
||||
challenge.id = Crypto.randomId();
|
||||
String otp = Config.MOCK_ENABLED ? Config.MOCK_CODE : Crypto.randomNumericCode(settings.codeLength());
|
||||
if (Config.MOCK_ENABLED && otp.length() != settings.codeLength()) {
|
||||
throw new IllegalStateException("Mock OTP length must match the settings snapshot");
|
||||
}
|
||||
challenge.phoneHmac = phoneHmac;
|
||||
challenge.destinationMasked = PhoneNormalizer.mask(phone);
|
||||
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, otp);
|
||||
challenge.createdAt = now;
|
||||
challenge.expiresAt = now.plusSeconds(settings.ttlSeconds());
|
||||
challenge.verifyAttempts = 0;
|
||||
challenge.maxVerifyAttempts = settings.maxVerifyAttempts();
|
||||
challenge.settingsVersion = settings.version();
|
||||
challenge.deliveryMode = Config.MOCK_ENABLED ? "mock" : "sms";
|
||||
challenge.challengeStatus = Config.MOCK_ENABLED ? "active" : "ordering";
|
||||
challenge.orderedAt = Config.MOCK_ENABLED ? now : null;
|
||||
challenge.otpTtlSec = settings.ttlSeconds();
|
||||
challenge.otpCodeLength = settings.codeLength();
|
||||
entityManager.persist(challenge);
|
||||
if (Config.MOCK_ENABLED) {
|
||||
event("otp_send", phoneHmac, challenge.id, null, "success", "mock", device);
|
||||
}
|
||||
return new Reservation(challenge, otp);
|
||||
}
|
||||
|
||||
OtpChallengeEntity activate(
|
||||
String challengeId, SmsOrderClient.OrderResult order, DeviceMetadata device) {
|
||||
OtpChallengeEntity challenge = locked(challengeId);
|
||||
if (!"ordering".equals(challenge.challengeStatus)) return challenge;
|
||||
challenge.smsMessageId = order.smsMessageId();
|
||||
challenge.orderedAt = order.orderedAt();
|
||||
challenge.expiresAt = order.orderedAt().plusSeconds(challenge.otpTtlSec);
|
||||
challenge.challengeStatus = "active";
|
||||
event("otp_send", challenge.phoneHmac, challenge.id, challenge.smsMessageId,
|
||||
"success", "ordered", device);
|
||||
return challenge;
|
||||
}
|
||||
|
||||
void orderFailed(String challengeId, DeviceMetadata device) {
|
||||
OtpChallengeEntity challenge = locked(challengeId);
|
||||
if (!"ordering".equals(challenge.challengeStatus)) return;
|
||||
challenge.challengeStatus = "order_failed";
|
||||
event("otp_send", challenge.phoneHmac, challenge.id, null,
|
||||
"failure", "order_failed", device);
|
||||
}
|
||||
|
||||
VerifyResult consume(String challengeId, String suppliedCode, DeviceMetadata device) {
|
||||
OtpChallengeEntity challenge = entityManager.find(
|
||||
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
|
||||
Instant now = Instant.now();
|
||||
if (challenge == null) return VerifyResult.INVALID;
|
||||
if (!"active".equals(challenge.challengeStatus)) {
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
|
||||
"already_used", challenge.challengeStatus, device);
|
||||
return switch (challenge.challengeStatus) {
|
||||
case "limited" -> VerifyResult.VERIFY_LIMITED;
|
||||
case "expired" -> VerifyResult.EXPIRED;
|
||||
default -> VerifyResult.ALREADY_USED;
|
||||
};
|
||||
}
|
||||
if (!challenge.expiresAt.isAfter(now)) {
|
||||
challenge.challengeStatus = "expired";
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
|
||||
"expired", "ttl", device);
|
||||
return VerifyResult.EXPIRED;
|
||||
}
|
||||
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
|
||||
challenge.challengeStatus = "limited";
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
|
||||
"limited", "attempt_limit", device);
|
||||
return VerifyResult.VERIFY_LIMITED;
|
||||
}
|
||||
challenge.verifyAttempts++;
|
||||
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
|
||||
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
|
||||
if (!valid) {
|
||||
boolean limited = challenge.verifyAttempts >= challenge.maxVerifyAttempts;
|
||||
if (limited) challenge.challengeStatus = "limited";
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
|
||||
limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device);
|
||||
return limited ? VerifyResult.VERIFY_LIMITED : VerifyResult.INVALID;
|
||||
}
|
||||
challenge.consumedAt = now;
|
||||
challenge.challengeStatus = "consumed";
|
||||
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
|
||||
"success", "verified", device);
|
||||
return VerifyResult.VERIFIED;
|
||||
}
|
||||
|
||||
OtpChallengeEntity get(String challengeId) {
|
||||
return entityManager.find(OtpChallengeEntity.class, challengeId);
|
||||
}
|
||||
|
||||
void expireDue() {
|
||||
expireDue(Instant.now());
|
||||
}
|
||||
|
||||
private OtpChallengeEntity locked(String challengeId) {
|
||||
OtpChallengeEntity challenge = entityManager.find(
|
||||
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
|
||||
if (challenge == null) throw new IllegalStateException("OTP challenge not found");
|
||||
return challenge;
|
||||
}
|
||||
|
||||
private void expireDue(Instant now) {
|
||||
entityManager.createQuery("""
|
||||
update OtpChallengeEntity c set c.challengeStatus = 'expired'
|
||||
where c.challengeStatus = 'active' and c.expiresAt <= :now
|
||||
""").setParameter("now", now).executeUpdate();
|
||||
}
|
||||
|
||||
private void event(
|
||||
String type,
|
||||
String phoneHmac,
|
||||
String challengeId,
|
||||
UUID smsMessageId,
|
||||
String outcome,
|
||||
String details,
|
||||
DeviceMetadata device) {
|
||||
OtpSecurityEventEntity event = new OtpSecurityEventEntity();
|
||||
event.id = Crypto.randomId();
|
||||
event.occurredAt = Instant.now();
|
||||
event.eventType = type;
|
||||
event.phoneHmac = phoneHmac;
|
||||
event.challengeId = challengeId;
|
||||
event.smsMessageId = smsMessageId;
|
||||
event.outcome = outcome;
|
||||
event.details = details;
|
||||
if (device != null) {
|
||||
event.clientIp = device.clientIp();
|
||||
event.userAgent = device.userAgent();
|
||||
event.deviceId = device.deviceId();
|
||||
event.fingerprint = device.fingerprint();
|
||||
event.osName = device.osName();
|
||||
event.osVersion = device.osVersion();
|
||||
event.platform = device.platform();
|
||||
event.appVersion = device.appVersion();
|
||||
}
|
||||
entityManager.persist(event);
|
||||
}
|
||||
|
||||
record Reservation(OtpChallengeEntity challenge, String otp) {}
|
||||
|
||||
enum VerifyResult {
|
||||
VERIFIED,
|
||||
INVALID,
|
||||
EXPIRED,
|
||||
ALREADY_USED,
|
||||
VERIFY_LIMITED
|
||||
}
|
||||
|
||||
static final class OtpLimitException extends RuntimeException {
|
||||
OtpLimitException(String message) { super(message); }
|
||||
|
||||
boolean isCooldown() {
|
||||
return "otp_send_cooldown".equals(getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
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";
|
||||
static final String CODE_LENGTH_NOTE = "han.otp.code_length";
|
||||
static final String EXPIRES_AT_NOTE = "han.otp.expires_at";
|
||||
private final PhoneNormalizer normalizer = new PhoneNormalizer();
|
||||
|
||||
@Override
|
||||
public void authenticate(AuthenticationFlowContext context) {
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) {
|
||||
context.success();
|
||||
return;
|
||||
}
|
||||
context.challenge(phoneForm(context, null, device));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
|
||||
String captchaToken =
|
||||
context.getHttpRequest().getDecodedFormParameters().getFirst("smart-token");
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
try {
|
||||
String phone = normalizer.normalize(rawPhone);
|
||||
if (Config.CAPTCHA_ENABLED
|
||||
&& YandexSmartCaptchaClient.get().verify(captchaToken, device.clientIp())
|
||||
== YandexSmartCaptchaClient.Result.REJECTED) {
|
||||
context.failureChallenge(
|
||||
AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
|
||||
phoneForm(context, "captchaInvalid", device));
|
||||
return;
|
||||
}
|
||||
SettingsBridge.Settings settings = SettingsBridge.get();
|
||||
var challenge = OtpFlow.start(context, phone, settings, device);
|
||||
context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone);
|
||||
context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id);
|
||||
context.getAuthenticationSession().setAuthNote(MASKED_NOTE, challenge.destinationMasked);
|
||||
context.getAuthenticationSession().setAuthNote(
|
||||
CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
|
||||
context.getAuthenticationSession().setAuthNote(
|
||||
EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
|
||||
context.success();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
Response response = phoneForm(context, "phoneInvalid", device);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
|
||||
} catch (OtpStore.OtpLimitException exception) {
|
||||
Response response = phoneForm(
|
||||
context, exception.isCooldown() ? "otpCooldown" : "otpLimited", device);
|
||||
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response);
|
||||
} catch (RuntimeException exception) {
|
||||
Response response = phoneForm(context, "otpUnavailable", device);
|
||||
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response);
|
||||
}
|
||||
}
|
||||
|
||||
private static Response phoneForm(
|
||||
AuthenticationFlowContext context, String messageKey, DeviceMetadata device) {
|
||||
var form = context.form()
|
||||
.setAttribute("hanDeviceId", device.deviceId())
|
||||
.setAttribute("hanFingerprint", device.fingerprint())
|
||||
.setAttribute("hanPlatform", device.platform())
|
||||
.setAttribute("hanOsName", device.osName())
|
||||
.setAttribute("hanOsVersion", device.osVersion())
|
||||
.setAttribute("hanAppVersion", device.appVersion())
|
||||
.setAttribute("captchaEnabled", Config.CAPTCHA_ENABLED)
|
||||
.setAttribute("captchaClientKey", Config.CAPTCHA_CLIENT_KEY);
|
||||
if (messageKey != null) form.setError(messageKey);
|
||||
return form.createForm("phone.ftl");
|
||||
}
|
||||
|
||||
@Override public boolean requiresUser() { return false; }
|
||||
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
|
||||
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
|
||||
@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() {}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
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(otpForm(context, masked, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
String challengeId = context.getAuthenticationSession()
|
||||
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
|
||||
String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE);
|
||||
String action = context.getHttpRequest().getDecodedFormParameters().getFirst("otp_action");
|
||||
String code = context.getHttpRequest().getDecodedFormParameters().getFirst("otp");
|
||||
String captchaToken =
|
||||
context.getHttpRequest().getDecodedFormParameters().getFirst("smart-token");
|
||||
if (challengeId == null || phone == null) {
|
||||
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
|
||||
return;
|
||||
}
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
if ("resend".equals(action)) {
|
||||
try {
|
||||
if (Config.CAPTCHA_ENABLED
|
||||
&& YandexSmartCaptchaClient.get().verify(captchaToken, device.clientIp())
|
||||
== YandexSmartCaptchaClient.Result.REJECTED) {
|
||||
context.failureChallenge(
|
||||
AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
|
||||
otpForm(context, PhoneNormalizer.mask(phone), "captchaInvalid"));
|
||||
return;
|
||||
}
|
||||
var challenge = OtpFlow.start(context, phone, SettingsBridge.get(), device);
|
||||
context.getAuthenticationSession().setAuthNote(
|
||||
PhoneIdentityAuthenticator.CHALLENGE_NOTE, challenge.id);
|
||||
context.getAuthenticationSession().setAuthNote(
|
||||
PhoneIdentityAuthenticator.CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
|
||||
context.getAuthenticationSession().setAuthNote(
|
||||
PhoneIdentityAuthenticator.EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
|
||||
context.challenge(otpForm(context, challenge.destinationMasked, null));
|
||||
} catch (OtpStore.OtpLimitException exception) {
|
||||
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
|
||||
otpForm(
|
||||
context,
|
||||
PhoneNormalizer.mask(phone),
|
||||
exception.isCooldown() ? "otpCooldown" : "otpLimited"));
|
||||
} catch (RuntimeException exception) {
|
||||
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
|
||||
otpForm(context, PhoneNormalizer.mask(phone), "otpUnavailable"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
OtpStore.VerifyResult result =
|
||||
new OtpStore(context.getSession()).consume(challengeId, code, device);
|
||||
if (result != OtpStore.VerifyResult.VERIFIED) {
|
||||
String messageKey = result == OtpStore.VerifyResult.VERIFY_LIMITED
|
||||
? "otpVerifyLimited"
|
||||
: "otpInvalid";
|
||||
Response response = otpForm(context, PhoneNormalizer.mask(phone), messageKey);
|
||||
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();
|
||||
}
|
||||
|
||||
private static Response otpForm(AuthenticationFlowContext context, String masked, String messageKey) {
|
||||
DeviceMetadata device = DeviceMetadata.capture(context);
|
||||
String codeLength = context.getAuthenticationSession()
|
||||
.getAuthNote(PhoneIdentityAuthenticator.CODE_LENGTH_NOTE);
|
||||
String expiresAt = context.getAuthenticationSession()
|
||||
.getAuthNote(PhoneIdentityAuthenticator.EXPIRES_AT_NOTE);
|
||||
var form = context.form()
|
||||
.setAttribute("maskedPhone", masked)
|
||||
.setAttribute("otpCodeLength", codeLength == null ? 6 : Integer.parseInt(codeLength))
|
||||
.setAttribute("otpExpiresAt", expiresAt == null ? 0 : Long.parseLong(expiresAt))
|
||||
.setAttribute("hanDeviceId", device.deviceId())
|
||||
.setAttribute("hanFingerprint", device.fingerprint())
|
||||
.setAttribute("hanPlatform", device.platform())
|
||||
.setAttribute("hanOsName", device.osName())
|
||||
.setAttribute("hanOsVersion", device.osVersion())
|
||||
.setAttribute("hanAppVersion", device.appVersion())
|
||||
.setAttribute("captchaEnabled", Config.CAPTCHA_ENABLED)
|
||||
.setAttribute("captchaClientKey", Config.CAPTCHA_CLIENT_KEY);
|
||||
if (messageKey != null) form.setError(messageKey);
|
||||
return form.createForm("otp.ftl");
|
||||
}
|
||||
|
||||
@Override public boolean requiresUser() { return false; }
|
||||
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
|
||||
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
|
||||
@Override public void close() {}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
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.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.timer.TimerProvider;
|
||||
|
||||
public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory {
|
||||
public static final String ID = "han-phone-otp";
|
||||
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) { ru.han.chat.keycloak.Config.validate(); }
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
try (KeycloakSession session = factory.create()) {
|
||||
session.getProvider(TimerProvider.class).schedule(
|
||||
() -> KeycloakModelUtils.runJobInTransaction(
|
||||
factory, jobSession -> new OtpStore(jobSession).expireDue()),
|
||||
60_000L,
|
||||
"han-otp-expiry");
|
||||
}
|
||||
}
|
||||
@Override public void close() {}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
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 Settings(
|
||||
int maxSendsPer24h,
|
||||
int minSecondsBetween,
|
||||
int maxVerifyAttempts,
|
||||
int codeLength,
|
||||
int ttlSeconds,
|
||||
int smsOrderTimeoutMs,
|
||||
String version) {}
|
||||
private record Cached(Settings settings, Instant fetchedAt, Instant refreshAfter, String etag) {}
|
||||
|
||||
private SettingsBridge() {}
|
||||
|
||||
static Settings get() {
|
||||
Cached local = cached;
|
||||
Instant now = Instant.now();
|
||||
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
|
||||
synchronized (SettingsBridge.class) {
|
||||
local = cached;
|
||||
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
|
||||
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.settings, now, now.plusSeconds(60), local.etag);
|
||||
return local.settings;
|
||||
}
|
||||
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
|
||||
int max = integer(response.body(), "max_send_attempts_per_24h");
|
||||
int minimum = integer(response.body(), "min_seconds_between_attempts");
|
||||
int maxVerify = integer(response.body(), "max_verify_attempts");
|
||||
int codeLength = integer(response.body(), "code_length");
|
||||
int otpTtl = integer(response.body(), "ttl_seconds");
|
||||
int orderTimeout = integer(response.body(), "sms_order_timeout_ms");
|
||||
int ttl = integer(response.body(), "cache_ttl_seconds");
|
||||
String version = string(response.body(), "version");
|
||||
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400
|
||||
|| maxVerify < 1 || maxVerify > 10
|
||||
|| codeLength < 4 || codeLength > 10
|
||||
|| otpTtl < 60 || otpTtl > 900 || otpTtl % 60 != 0
|
||||
|| orderTimeout < 100 || orderTimeout > 30000
|
||||
|| ttl < 1 || ttl > 3600) {
|
||||
throw new IllegalStateException("settings_invalid_range");
|
||||
}
|
||||
Settings settings = new Settings(
|
||||
max, minimum, maxVerify, codeLength, otpTtl, orderTimeout, version);
|
||||
cached = new Cached(settings, now, now.plusSeconds(ttl),
|
||||
response.headers().firstValue("ETag").orElse(null));
|
||||
return settings;
|
||||
} catch (Exception exception) {
|
||||
if (local != null && now.isBefore(local.fetchedAt.plus(Config.SETTINGS_MAX_STALE))) {
|
||||
LOG.warn("OTP settings refresh failed; using bounded last-known-good");
|
||||
return local.settings;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class SmsOrderClient {
|
||||
private static final Pattern MESSAGE_ID =
|
||||
Pattern.compile("\"sms_message_id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
private static final Pattern ORDERED_AT =
|
||||
Pattern.compile("\"ordered_at\"\\s*:\\s*\"([^\"]+)\"");
|
||||
private final HttpClient client;
|
||||
private final URI serviceUrl;
|
||||
private final String serviceToken;
|
||||
|
||||
SmsOrderClient() {
|
||||
this(HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(2))
|
||||
.build(),
|
||||
Config.SMS_SERVICE_URL, Config.SMS_SERVICE_TOKEN);
|
||||
}
|
||||
|
||||
SmsOrderClient(HttpClient client, URI serviceUrl, String serviceToken) {
|
||||
this.client = client;
|
||||
this.serviceUrl = serviceUrl;
|
||||
this.serviceToken = serviceToken;
|
||||
}
|
||||
|
||||
OrderResult order(
|
||||
String challengeId,
|
||||
String phone,
|
||||
String otp,
|
||||
SettingsBridge.Settings settings,
|
||||
String requestId,
|
||||
String traceparent) {
|
||||
String body = requestBody(challengeId, phone, otp, settings);
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(serviceUrl)
|
||||
.timeout(Duration.ofMillis(settings.smsOrderTimeoutMs()))
|
||||
.header("Authorization", "Bearer " + serviceToken)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Request-ID", requestId == null ? challengeId : requestId)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (traceparent != null && !traceparent.isBlank()) builder.header("traceparent", traceparent);
|
||||
HttpRequest request = builder.build();
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
HttpResponse<String> response =
|
||||
client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200 || response.statusCode() == 202) {
|
||||
return parse(response.body());
|
||||
}
|
||||
if (response.statusCode() < 500 || attempt == 1) {
|
||||
throw new SmsOrderException("sms_order_http_" + response.statusCode());
|
||||
}
|
||||
} catch (java.net.http.HttpTimeoutException exception) {
|
||||
if (attempt == 1) throw new SmsOrderException("sms_order_timeout", exception);
|
||||
} catch (java.io.IOException exception) {
|
||||
if (attempt == 1) throw new SmsOrderException("sms_order_io", exception);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new SmsOrderException("sms_order_interrupted", exception);
|
||||
}
|
||||
}
|
||||
throw new SmsOrderException("sms_order_unavailable");
|
||||
}
|
||||
|
||||
static OrderResult parse(String json) {
|
||||
Matcher idMatcher = MESSAGE_ID.matcher(json);
|
||||
Matcher orderedMatcher = ORDERED_AT.matcher(json);
|
||||
if (!idMatcher.find() || !orderedMatcher.find()) {
|
||||
throw new SmsOrderException("sms_order_invalid_response");
|
||||
}
|
||||
try {
|
||||
return new OrderResult(UUID.fromString(idMatcher.group(1)), Instant.parse(orderedMatcher.group(1)));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new SmsOrderException("sms_order_invalid_response", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static String requestBody(
|
||||
String challengeId, String phone, String otp, SettingsBridge.Settings settings) {
|
||||
return ("{\"idempotency_key\":\"keycloak:challenge:%s\","
|
||||
+ "\"template_code\":\"auth_otp\",\"locale\":\"ru\","
|
||||
+ "\"phone_e164\":\"%s\",\"substitutions\":{\"code\":\"%s\",\"ttl_min\":\"%d\"},"
|
||||
+ "\"customer_ref\":\"%s\",\"message_ttl_sec\":%d}").formatted(
|
||||
escape(challengeId), escape(phone), escape(otp), settings.ttlSeconds() / 60,
|
||||
escape(challengeId), settings.ttlSeconds());
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
record OrderResult(UUID smsMessageId, Instant orderedAt) {}
|
||||
|
||||
static final class SmsOrderException extends RuntimeException {
|
||||
SmsOrderException(String message) { super(message); }
|
||||
SmsOrderException(String message, Throwable cause) { super(message, cause); }
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
final class YandexSmartCaptchaClient {
|
||||
enum Result { PASSED, REJECTED, BYPASSED }
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(YandexSmartCaptchaClient.class);
|
||||
private static final class Holder {
|
||||
private static final YandexSmartCaptchaClient INSTANCE =
|
||||
new YandexSmartCaptchaClient();
|
||||
}
|
||||
|
||||
private final HttpClient client;
|
||||
private final URI validateUrl;
|
||||
private final String serverKey;
|
||||
private final Duration timeout;
|
||||
|
||||
YandexSmartCaptchaClient() {
|
||||
this(HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Config.CAPTCHA_TIMEOUT)
|
||||
.build(),
|
||||
Config.CAPTCHA_VALIDATE_URL,
|
||||
Config.CAPTCHA_SERVER_KEY,
|
||||
Config.CAPTCHA_TIMEOUT);
|
||||
}
|
||||
|
||||
YandexSmartCaptchaClient(
|
||||
HttpClient client, URI validateUrl, String serverKey, Duration timeout) {
|
||||
this.client = client;
|
||||
this.validateUrl = validateUrl;
|
||||
this.serverKey = serverKey;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
static YandexSmartCaptchaClient get() {
|
||||
return Holder.INSTANCE;
|
||||
}
|
||||
|
||||
Result verify(String token, String clientIp) {
|
||||
long startedAt = System.nanoTime();
|
||||
if (token == null || token.isBlank() || token.length() > 8192) {
|
||||
log("rejected", "missing_or_invalid_token", startedAt, false);
|
||||
return Result.REJECTED;
|
||||
}
|
||||
|
||||
String body = form("secret", serverKey)
|
||||
+ "&" + form("token", token)
|
||||
+ (clientIp == null || clientIp.isBlank() ? "" : "&" + form("ip", clientIp));
|
||||
HttpRequest request = HttpRequest.newBuilder(validateUrl)
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response =
|
||||
client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
boolean temporary = response.statusCode() == 408
|
||||
|| response.statusCode() == 429
|
||||
|| response.statusCode() >= 500;
|
||||
log(
|
||||
temporary ? "bypassed" : "rejected",
|
||||
"http_" + response.statusCode(),
|
||||
startedAt,
|
||||
true);
|
||||
return temporary ? Result.BYPASSED : Result.REJECTED;
|
||||
}
|
||||
Object status = JsonSerialization.readValue(response.body(), Map.class).get("status");
|
||||
if (!"ok".equals(status) && !"failed".equals(status)) {
|
||||
log("bypassed", "invalid_response", startedAt, true);
|
||||
return Result.BYPASSED;
|
||||
}
|
||||
if ("ok".equals(status)) {
|
||||
log("passed", "ok", startedAt, false);
|
||||
return Result.PASSED;
|
||||
}
|
||||
log("rejected", "provider_rejected", startedAt, false);
|
||||
return Result.REJECTED;
|
||||
} catch (java.net.http.HttpTimeoutException exception) {
|
||||
log("bypassed", "timeout", startedAt, true);
|
||||
return Result.BYPASSED;
|
||||
} catch (IOException exception) {
|
||||
log("bypassed", "io", startedAt, true);
|
||||
return Result.BYPASSED;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
log("bypassed", "interrupted", startedAt, true);
|
||||
return Result.BYPASSED;
|
||||
} catch (RuntimeException exception) {
|
||||
log("rejected", "client_error", startedAt, true);
|
||||
return Result.REJECTED;
|
||||
}
|
||||
}
|
||||
|
||||
private static String form(String name, String value) {
|
||||
return URLEncoder.encode(name, StandardCharsets.UTF_8)
|
||||
+ "=" + URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void log(
|
||||
String outcome, String reason, long startedAt, boolean warning) {
|
||||
long durationMs = (System.nanoTime() - startedAt) / 1_000_000L;
|
||||
String message = "captcha.validation outcome=%s reason=%s duration_ms=%d"
|
||||
.formatted(outcome, reason, durationMs);
|
||||
if (warning) LOG.warn(message);
|
||||
else LOG.info(message);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
import java.util.UUID;
|
||||
|
||||
@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", length = 128) public String providerId;
|
||||
@Column(name = "provider_status", length = 32) public String providerStatus;
|
||||
@Column(name = "sms_message_id") public UUID smsMessageId;
|
||||
@Column(name = "delivery_mode", nullable = false, length = 16) public String deliveryMode;
|
||||
@Column(name = "challenge_status", nullable = false, length = 16) public String challengeStatus;
|
||||
@Column(name = "ordered_at") public Instant orderedAt;
|
||||
@Column(name = "otp_ttl_sec", nullable = false) public int otpTtlSec;
|
||||
@Column(name = "otp_code_length", nullable = false) public int otpCodeLength;
|
||||
@Version public long version;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
import java.util.UUID;
|
||||
import org.hibernate.annotations.ColumnTransformer;
|
||||
|
||||
@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;
|
||||
@Column(name = "sms_message_id") public UUID smsMessageId;
|
||||
@Column(name = "client_ip", columnDefinition = "inet")
|
||||
@ColumnTransformer(write = "cast(? as inet)")
|
||||
public String clientIp;
|
||||
@Column(name = "user_agent") public String userAgent;
|
||||
@Column(name = "device_id", length = 256) public String deviceId;
|
||||
@Column(name = "fingerprint", length = 256) public String fingerprint;
|
||||
@Column(name = "os_name", length = 64) public String osName;
|
||||
@Column(name = "os_version", length = 64) public String osVersion;
|
||||
@Column(name = "platform", length = 16) public String platform;
|
||||
@Column(name = "app_version", length = 64) public String appVersion;
|
||||
}
|
||||
+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,114 @@
|
||||
<?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>
|
||||
|
||||
<changeSet id="han-otp-1.1.0-sms-lifecycle" author="han-chat">
|
||||
<addColumn tableName="han_otp_challenge">
|
||||
<column name="sms_message_id" type="uuid"/>
|
||||
<column name="delivery_mode" type="varchar(16)"/>
|
||||
<column name="challenge_status" type="varchar(16)"/>
|
||||
<column name="ordered_at" type="timestamp with time zone"/>
|
||||
<column name="otp_ttl_sec" type="int"/>
|
||||
<column name="otp_code_length" type="smallint"/>
|
||||
</addColumn>
|
||||
<sql>
|
||||
UPDATE han_otp_challenge
|
||||
SET delivery_mode = 'mock',
|
||||
challenge_status = CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END,
|
||||
ordered_at = created_at,
|
||||
otp_ttl_sec = 60,
|
||||
otp_code_length = 6;
|
||||
ALTER TABLE han_otp_challenge ALTER COLUMN delivery_mode SET NOT NULL;
|
||||
ALTER TABLE han_otp_challenge ALTER COLUMN challenge_status SET NOT NULL;
|
||||
ALTER TABLE han_otp_challenge ALTER COLUMN otp_ttl_sec SET NOT NULL;
|
||||
ALTER TABLE han_otp_challenge ALTER COLUMN otp_code_length SET NOT NULL;
|
||||
ALTER TABLE han_otp_challenge ALTER COLUMN provider_id DROP NOT NULL;
|
||||
ALTER TABLE han_otp_challenge ALTER COLUMN provider_status DROP NOT NULL;
|
||||
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_delivery_mode
|
||||
CHECK (delivery_mode IN ('mock', 'sms'));
|
||||
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_challenge_status
|
||||
CHECK (challenge_status IN
|
||||
('ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'));
|
||||
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_ttl
|
||||
CHECK (otp_ttl_sec BETWEEN 60 AND 900 AND otp_ttl_sec % 60 = 0);
|
||||
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_code_length
|
||||
CHECK (otp_code_length BETWEEN 4 AND 10);
|
||||
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_active_sms
|
||||
CHECK (challenge_status != 'active' OR delivery_mode != 'sms' OR sms_message_id IS NOT NULL);
|
||||
</sql>
|
||||
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_status_expiry">
|
||||
<column name="challenge_status"/><column name="expires_at"/>
|
||||
</createIndex>
|
||||
<sql>
|
||||
CREATE INDEX ix_han_otp_challenge_sms_message
|
||||
ON han_otp_challenge (sms_message_id) WHERE sms_message_id IS NOT NULL;
|
||||
</sql>
|
||||
|
||||
<addColumn tableName="han_otp_security_event">
|
||||
<column name="sms_message_id" type="uuid"/>
|
||||
<column name="client_ip" type="inet"/>
|
||||
<column name="user_agent" type="text"/>
|
||||
<column name="device_id" type="varchar(256)"/>
|
||||
<column name="fingerprint" type="varchar(256)"/>
|
||||
<column name="os_name" type="varchar(64)"/>
|
||||
<column name="os_version" type="varchar(64)"/>
|
||||
<column name="platform" type="varchar(16)"/>
|
||||
<column name="app_version" type="varchar(64)"/>
|
||||
</addColumn>
|
||||
<sql>
|
||||
ALTER TABLE han_otp_security_event ADD CONSTRAINT ck_han_otp_event_platform
|
||||
CHECK (platform IS NULL OR platform IN ('web', 'ios', 'android'));
|
||||
CREATE INDEX ix_han_otp_event_sms_message
|
||||
ON han_otp_security_event (sms_message_id) WHERE sms_message_id IS NOT NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+2
@@ -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,31 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void randomOtpIsNumericAndUsesRequestedLength() {
|
||||
String code = Crypto.randomNumericCode(8);
|
||||
assertTrue(code.matches("\\d{8}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(3));
|
||||
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(11));
|
||||
}
|
||||
}
|
||||
+34
@@ -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"));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
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("\"protocolMapper\": \"oidc-sub-mapper\""));
|
||||
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\":"));
|
||||
assertFalse(realm.contains("KEYCLOAK_YANDEX_CAPTCHA"));
|
||||
assertFalse(realm.contains("\"browserSecurityHeaders\""));
|
||||
}
|
||||
|
||||
private static String readRealm() {
|
||||
try {
|
||||
return Files.readString(Path.of("realm", "han-chat-realm.json"));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SmsLifecycleContractTest {
|
||||
@Test
|
||||
void migrationContainsLifecycleSnapshotAndAuditColumns() throws Exception {
|
||||
String migration = Files.readString(
|
||||
Path.of("src/main/resources/META-INF/han-otp-changelog.xml"));
|
||||
for (String required : new String[] {
|
||||
"sms_message_id", "delivery_mode", "challenge_status", "ordered_at",
|
||||
"otp_ttl_sec", "otp_code_length", "client_ip", "user_agent",
|
||||
"device_id", "fingerprint", "os_name", "os_version", "platform", "app_version",
|
||||
"'ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'"
|
||||
}) {
|
||||
assertTrue(migration.contains(required), "Missing migration contract: " + required);
|
||||
}
|
||||
assertTrue(migration.contains("delivery_mode = 'mock'"));
|
||||
assertTrue(migration.contains(
|
||||
"CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception {
|
||||
String template = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
|
||||
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
|
||||
String messages = Files.readString(
|
||||
Path.of("themes/han-phone/login/messages/messages_ru.properties"));
|
||||
String verifier = Files.readString(Path.of(
|
||||
"src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java"));
|
||||
|
||||
assertTrue(template.contains("otpCodeLength"));
|
||||
assertTrue(template.contains("otpExpiresAt"));
|
||||
assertTrue(template.contains("(otpExpiresAt!0)?c"));
|
||||
assertTrue(template.contains("name=\"otp_action\" value=\"resend\""));
|
||||
assertTrue(template.contains("han_device_id"));
|
||||
assertTrue(script.contains("han_device_id"));
|
||||
assertTrue(script.contains("expiresAt - Date.now()"));
|
||||
assertTrue(script.contains("Number.isFinite(expiresAt)"));
|
||||
assertTrue(messages.contains("otpVerifyLimited="));
|
||||
assertTrue(messages.contains("Попробуйте через 24 часа"));
|
||||
assertTrue(verifier.contains("VerifyResult.VERIFY_LIMITED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void phoneThemeDoesNotRepeatConsentText() throws Exception {
|
||||
String phone = Files.readString(Path.of("themes/han-phone/login/phone.ftl"));
|
||||
|
||||
assertTrue(!phone.contains("phoneLegalPrefix"));
|
||||
assertTrue(!phone.contains("termsOfUse"));
|
||||
assertTrue(!phone.contains("privacyPolicy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void captchaProtectsInitialSendAndResendWithFreshTokens() throws Exception {
|
||||
String phone = Files.readString(Path.of("themes/han-phone/login/phone.ftl"));
|
||||
String otp = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
|
||||
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
|
||||
String styles = Files.readString(Path.of("themes/han-phone/login/resources/css/han-login.css"));
|
||||
String identity = Files.readString(Path.of(
|
||||
"src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java"));
|
||||
String verifier = Files.readString(Path.of(
|
||||
"src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java"));
|
||||
|
||||
assertTrue(phone.contains("name=\"smart-token\""));
|
||||
assertTrue(phone.contains("captchaClientKey"));
|
||||
assertTrue(otp.contains("data-resend-only=\"true\""));
|
||||
assertTrue(otp.contains("name=\"smart-token\""));
|
||||
assertTrue(script.contains("window.smartCaptcha.execute(widgetId)"));
|
||||
assertTrue(script.contains("\"network-error\", showError"));
|
||||
assertTrue(script.contains("\"javascript-error\", showError"));
|
||||
assertTrue(script.contains("\"token-expired\""));
|
||||
assertTrue(script.contains("tokenInput.value = \"\""));
|
||||
assertTrue(styles.contains(".han-error[hidden]"));
|
||||
String captchaGate = "YandexSmartCaptchaClient.get().verify";
|
||||
assertTrue(identity.contains(captchaGate));
|
||||
assertTrue(verifier.contains(captchaGate));
|
||||
assertTrue(identity.indexOf(captchaGate) < identity.indexOf("OtpFlow.start"));
|
||||
assertTrue(verifier.indexOf(captchaGate) < verifier.indexOf("OtpFlow.start"));
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SmsOrderClientTest {
|
||||
private static final SettingsBridge.Settings SETTINGS =
|
||||
new SettingsBridge.Settings(3, 30, 5, 6, 120, 3000, "v1");
|
||||
|
||||
@Test
|
||||
void requestUsesStableIdempotencyAndSnapshot() {
|
||||
String body = SmsOrderClient.requestBody(
|
||||
"challenge-1", "+79001234567", "482193", SETTINGS);
|
||||
|
||||
assertTrue(body.contains("\"idempotency_key\":\"keycloak:challenge:challenge-1\""));
|
||||
assertTrue(body.contains("\"template_code\":\"auth_otp\""));
|
||||
assertTrue(body.contains("\"code\":\"482193\""));
|
||||
assertTrue(body.contains("\"ttl_min\":\"2\""));
|
||||
assertTrue(body.contains("\"message_ttl_sec\":120"));
|
||||
assertFalse(body.contains("Authorization"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesOnlyUuidAndIsoOrderedTimestamp() {
|
||||
UUID id = UUID.randomUUID();
|
||||
SmsOrderClient.OrderResult result = SmsOrderClient.parse(
|
||||
"{\"sms_message_id\":\"" + id + "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}");
|
||||
assertEquals(id, result.smsMessageId());
|
||||
assertEquals(Instant.parse("2026-07-22T13:00:00Z"), result.orderedAt());
|
||||
assertThrows(SmsOrderClient.SmsOrderException.class,
|
||||
() -> SmsOrderClient.parse("{\"sms_message_id\":\"not-a-uuid\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesServerFailureWithSameOrder() throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
UUID messageId = UUID.randomUUID();
|
||||
server.createContext("/internal/sms/v1/send", exchange -> {
|
||||
assertEquals("Bearer test-token", exchange.getRequestHeaders().getFirst("Authorization"));
|
||||
int call = calls.incrementAndGet();
|
||||
byte[] response = (call == 1 ? "{}" :
|
||||
"{\"sms_message_id\":\"" + messageId
|
||||
+ "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(call == 1 ? 503 : 202, response.length);
|
||||
exchange.getResponseBody().write(response);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort()
|
||||
+ "/internal/sms/v1/send");
|
||||
SmsOrderClient client = new SmsOrderClient(HttpClient.newHttpClient(), uri, "test-token");
|
||||
SmsOrderClient.OrderResult result =
|
||||
client.order("challenge-1", "+79001234567", "482193", SETTINGS, "request-1", null);
|
||||
assertEquals(messageId, result.smsMessageId());
|
||||
assertEquals(2, calls.get());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class YandexSmartCaptchaClientTest {
|
||||
@Test
|
||||
void passesValidTokenAndUrlEncodesSensitiveFormValues() throws Exception {
|
||||
try (CaptchaServer server = new CaptchaServer(200, "{\"status\":\"ok\"}", 0)) {
|
||||
YandexSmartCaptchaClient client = server.client("secret +&=", Duration.ofSeconds(1));
|
||||
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.PASSED,
|
||||
client.verify("token +&=", "203.0.113.7"));
|
||||
assertTrue(server.body().contains("secret=secret+%2B%26%3D"));
|
||||
assertTrue(server.body().contains("token=token+%2B%26%3D"));
|
||||
assertTrue(server.body().contains("ip=203.0.113.7"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProviderFailureAndMissingToken() throws Exception {
|
||||
try (CaptchaServer server = new CaptchaServer(200, "{\"status\":\"failed\"}", 0)) {
|
||||
YandexSmartCaptchaClient client = server.client("secret", Duration.ofSeconds(1));
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.REJECTED,
|
||||
client.verify("token", "203.0.113.7"));
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.REJECTED,
|
||||
client.verify("", "203.0.113.7"));
|
||||
assertEquals(1, server.calls());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bypassesHttpMalformedAndTimeoutFailures() throws Exception {
|
||||
try (CaptchaServer http = new CaptchaServer(503, "unavailable", 0);
|
||||
CaptchaServer malformed = new CaptchaServer(
|
||||
200, "{\"data\":{\"status\":\"ok\"}}", 0);
|
||||
CaptchaServer slow = new CaptchaServer(200, "{\"status\":\"ok\"}", 250)) {
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.BYPASSED,
|
||||
http.client("secret", Duration.ofSeconds(1)).verify("token", null));
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.BYPASSED,
|
||||
malformed.client("secret", Duration.ofSeconds(1)).verify("token", null));
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.BYPASSED,
|
||||
slow.client("secret", Duration.ofMillis(50)).verify("token", null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonTemporaryHttpClientErrors() throws Exception {
|
||||
try (CaptchaServer server = new CaptchaServer(
|
||||
400, "{\"status\":\"failed\"}", 0)) {
|
||||
assertEquals(
|
||||
YandexSmartCaptchaClient.Result.REJECTED,
|
||||
server.client("secret", Duration.ofSeconds(1)).verify("token", null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenAndSecretNeverAppearInResult() throws Exception {
|
||||
try (CaptchaServer server = new CaptchaServer(500, "secret-token", 0)) {
|
||||
String secret = "server-secret-must-not-leak";
|
||||
String token = "captcha-token-must-not-leak";
|
||||
String result = server.client(secret, Duration.ofSeconds(1))
|
||||
.verify(token, null).name();
|
||||
assertFalse(result.contains(secret));
|
||||
assertFalse(result.contains(token));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CaptchaServer implements AutoCloseable {
|
||||
private final HttpServer server;
|
||||
private final AtomicReference<String> body = new AtomicReference<>("");
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
CaptchaServer(int status, String responseBody, long delayMs) throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
server.createContext("/validate", exchange -> {
|
||||
calls.incrementAndGet();
|
||||
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
try {
|
||||
if (delayMs > 0) Thread.sleep(delayMs);
|
||||
byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, response.length);
|
||||
exchange.getResponseBody().write(response);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
YandexSmartCaptchaClient client(String secret, Duration timeout) {
|
||||
URI uri = URI.create(
|
||||
"http://127.0.0.1:" + server.getAddress().getPort() + "/validate");
|
||||
return new YandexSmartCaptchaClient(
|
||||
HttpClient.newHttpClient(), uri, secret, timeout);
|
||||
}
|
||||
|
||||
String body() {
|
||||
return body.get();
|
||||
}
|
||||
|
||||
int calls() {
|
||||
return calls.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
phoneTitle=Вход в HAN
|
||||
authBrandSubtitle=Персональный консультант мигранта
|
||||
phoneWelcome=Добро пожаловать
|
||||
phoneIntro=Введите номер телефона — отправим код подтверждения
|
||||
phoneLabel=Номер телефона
|
||||
phoneCountry=Россия · Код страны +7
|
||||
phoneLegalPrefix=Нажимая «Получить код», вы соглашаетесь с
|
||||
termsOfUse=условиями использования
|
||||
phoneLegalAnd=и
|
||||
privacyPolicy=политикой конфиденциальности
|
||||
sendOtp=Получить код
|
||||
otpTitle=Подтверждение телефона
|
||||
otpHeading=Введите код
|
||||
otpSent=Отправили SMS на номер {0}
|
||||
otpDigit=Цифра кода {0}
|
||||
otpResendCountdown=Отправить повторно через
|
||||
otpResend=Отправить код снова
|
||||
authBack=Назад
|
||||
verifyOtp=Подтвердить
|
||||
mockMode=Тестовый режим отправки кода
|
||||
phoneInvalid=Проверьте формат номера телефона.
|
||||
otpInvalid=Код неверен, истёк или уже использован.
|
||||
otpVerifyLimited=Превышен лимит неуспешных авторизаций. Начните процедуру заново.
|
||||
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
|
||||
otpLimited=Превышен лимит попыток авторизации. Попробуйте через 24 часа.
|
||||
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
|
||||
captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.
|
||||
captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите.
|
||||
@@ -0,0 +1,75 @@
|
||||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<#if section = "header">${msg("otpTitle")}
|
||||
<#elseif section = "form">
|
||||
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=6"/>
|
||||
<div class="han-auth-screen han-otp-screen">
|
||||
<button class="han-back-button" type="button" onclick="window.history.back()">
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>${msg("authBack")}</span>
|
||||
</button>
|
||||
|
||||
<div class="han-auth-heading han-otp-heading">
|
||||
<h1>${msg("otpHeading")}</h1>
|
||||
<p>${msg("otpSent", maskedPhone!"***")}</p>
|
||||
</div>
|
||||
|
||||
<form id="kc-otp-form" action="${url.loginAction}" method="post">
|
||||
<input id="otp" name="otp" type="hidden" value=""/>
|
||||
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
|
||||
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
|
||||
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
|
||||
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
|
||||
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
|
||||
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
|
||||
<#if captchaEnabled!false>
|
||||
<input id="han-captcha-token" name="smart-token" type="hidden" value=""/>
|
||||
<div id="han-captcha-container" class="han-captcha"
|
||||
data-sitekey="${captchaClientKey!""}"
|
||||
data-form-id="kc-otp-form"
|
||||
data-submit-id="han-resend-button"
|
||||
data-resend-only="true"></div>
|
||||
<div id="han-captcha-client-error" class="han-error" role="alert" hidden>
|
||||
<span class="han-error-icon">!</span>
|
||||
<span>${msg("captchaUnavailable")}</span>
|
||||
</div>
|
||||
</#if>
|
||||
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>"
|
||||
style="grid-template-columns: repeat(${otpCodeLength!6}, minmax(0, 1fr));">
|
||||
<#list 0..((otpCodeLength!6) - 1) as index>
|
||||
<input class="han-otp-digit" type="text" inputmode="numeric" maxlength="1"
|
||||
aria-label="${msg("otpDigit", index + 1)}"
|
||||
<#if index == 0>autocomplete="one-time-code" autofocus</#if>
|
||||
aria-invalid="<#if message?has_content>true<#else>false</#if>"/>
|
||||
</#list>
|
||||
</div>
|
||||
|
||||
<#if message?has_content>
|
||||
<div class="han-error han-otp-error" role="alert">
|
||||
<span class="han-error-icon">!</span>
|
||||
<span>${kcSanitize(message.summary)?no_esc}</span>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<div class="han-resend">
|
||||
<p id="han-resend-countdown" data-expires-at="${(otpExpiresAt!0)?c}">
|
||||
${msg("otpResendCountdown")} <strong>—</strong>
|
||||
</p>
|
||||
<button id="han-resend-button" type="submit" name="otp_action" value="resend" hidden>
|
||||
<span aria-hidden="true">↻</span>
|
||||
<span>${msg("otpResend")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button id="han-otp-submit" class="han-primary-button" type="submit" disabled>
|
||||
${msg("verifyOtp")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
|
||||
<#if captchaEnabled!false>
|
||||
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=hanCaptchaOnload"
|
||||
async defer></script>
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
@@ -0,0 +1,67 @@
|
||||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<#if section = "header">${msg("phoneTitle")}
|
||||
<#elseif section = "form">
|
||||
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=6"/>
|
||||
<div class="han-auth-screen han-phone-screen">
|
||||
<div class="han-auth-main">
|
||||
<div class="han-wordmark" aria-label="HAN">
|
||||
<span class="han-wordmark-text">HAN</span>
|
||||
<span class="han-wordmark-dot"></span>
|
||||
</div>
|
||||
<p class="han-brand-subtitle">${msg("authBrandSubtitle")}</p>
|
||||
|
||||
<div class="han-auth-heading">
|
||||
<h1>${msg("phoneWelcome")}</h1>
|
||||
<p>${msg("phoneIntro")}</p>
|
||||
</div>
|
||||
|
||||
<form id="kc-phone-form" action="${url.loginAction}" method="post">
|
||||
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
|
||||
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
|
||||
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
|
||||
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
|
||||
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
|
||||
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
|
||||
<div class="han-field">
|
||||
<label for="phone">${msg("phoneLabel")}</label>
|
||||
<input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel"
|
||||
placeholder="+7 (___) ___ __ __" required autofocus
|
||||
aria-invalid="<#if message?has_content>true<#else>false</#if>"/>
|
||||
<p class="han-field-hint">${msg("phoneCountry")}</p>
|
||||
</div>
|
||||
|
||||
<#if captchaEnabled!false>
|
||||
<input id="han-captcha-token" name="smart-token" type="hidden" value=""/>
|
||||
<div id="han-captcha-container" class="han-captcha"
|
||||
data-sitekey="${captchaClientKey!""}"
|
||||
data-form-id="kc-phone-form"
|
||||
data-submit-id="han-phone-submit"></div>
|
||||
<div id="han-captcha-client-error" class="han-error" role="alert" hidden>
|
||||
<span class="han-error-icon">!</span>
|
||||
<span>${msg("captchaUnavailable")}</span>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<#if message?has_content>
|
||||
<div class="han-error" role="alert">
|
||||
<span class="han-error-icon">!</span>
|
||||
<span>${kcSanitize(message.summary)?no_esc}</span>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<button id="han-phone-submit" class="han-primary-button" type="submit" disabled>
|
||||
<span>${msg("sendOtp")}</span>
|
||||
<span class="han-button-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
|
||||
<#if captchaEnabled!false>
|
||||
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=hanCaptchaOnload"
|
||||
async defer></script>
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
@@ -0,0 +1,407 @@
|
||||
:root {
|
||||
--han-primary: #030213;
|
||||
--han-text: #252525;
|
||||
--han-muted: #717182;
|
||||
--han-border: rgba(0, 0, 0, 0.12);
|
||||
--han-surface: #ffffff;
|
||||
--han-input: #f3f3f5;
|
||||
--han-error: #d4183d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
body.login-pf {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background: var(--han-surface);
|
||||
color: var(--han-text);
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.login-pf-page,
|
||||
.pf-v5-c-login,
|
||||
.pf-c-login {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
background: var(--han-surface);
|
||||
}
|
||||
|
||||
.login-pf-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__container,
|
||||
.pf-c-login__container {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
grid-template-areas: "main";
|
||||
grid-template-columns: minmax(0, 390px);
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.login-pf-page .card-pf,
|
||||
.pf-v5-c-login__main,
|
||||
.pf-c-login__main {
|
||||
grid-area: main;
|
||||
width: 100%;
|
||||
max-width: 390px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--han-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#kc-header,
|
||||
#kc-header-wrapper,
|
||||
#kc-page-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#kc-content,
|
||||
#kc-content-wrapper,
|
||||
#kc-form,
|
||||
#kc-form-wrapper {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.han-auth-screen {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
padding: 56px 24px 32px;
|
||||
background: var(--han-surface);
|
||||
}
|
||||
|
||||
.han-phone-screen {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.han-auth-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.han-wordmark {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.han-wordmark-text {
|
||||
color: var(--han-text);
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.han-wordmark-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--han-primary);
|
||||
}
|
||||
|
||||
.han-brand-subtitle {
|
||||
margin: 0 0 48px;
|
||||
color: var(--han-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.han-auth-heading {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.han-auth-heading h1 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--han-text);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.han-auth-heading p {
|
||||
margin: 0;
|
||||
color: var(--han-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.han-auth-heading p strong,
|
||||
.han-auth-heading p span {
|
||||
color: var(--han-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.han-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.han-field label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--han-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.han-field input {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--han-border);
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
background: var(--han-input);
|
||||
color: var(--han-text);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.han-field input::placeholder {
|
||||
color: rgba(113, 113, 130, 0.6);
|
||||
}
|
||||
|
||||
.han-field input:focus {
|
||||
border-color: rgba(3, 2, 19, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(3, 2, 19, 0.12);
|
||||
}
|
||||
|
||||
.han-field input[aria-invalid="true"] {
|
||||
border-color: var(--han-error);
|
||||
}
|
||||
|
||||
.han-field-hint {
|
||||
margin: 6px 4px 0;
|
||||
color: var(--han-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.han-primary-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--han-primary);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.han-primary-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.han-primary-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.han-primary-button:focus-visible,
|
||||
.han-back-button:focus-visible,
|
||||
.han-resend button:focus-visible {
|
||||
outline: 3px solid rgba(3, 2, 19, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.han-primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.han-button-arrow {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.han-captcha {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.han-captcha + .han-error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.han-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(212, 24, 61, 0.25);
|
||||
border-radius: 12px;
|
||||
background: rgba(212, 24, 61, 0.08);
|
||||
color: var(--han-error);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.han-error[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.han-error-icon {
|
||||
display: inline-flex;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 17px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.han-otp-screen {
|
||||
padding-top: 52px;
|
||||
}
|
||||
|
||||
.han-back-button {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 40px -4px;
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--han-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.han-back-button:hover {
|
||||
color: var(--han-text);
|
||||
}
|
||||
|
||||
.han-otp-heading {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.han-otp-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.han-otp-digit {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--han-border);
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
background: var(--han-input);
|
||||
color: var(--han-text);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.han-otp-digit:focus {
|
||||
border-color: rgba(3, 2, 19, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(3, 2, 19, 0.12);
|
||||
}
|
||||
|
||||
.han-otp-digit.han-filled {
|
||||
background: rgba(3, 2, 19, 0.05);
|
||||
}
|
||||
|
||||
.han-otp-digit[aria-invalid="true"] {
|
||||
border-color: var(--han-error);
|
||||
background: rgba(212, 24, 61, 0.05);
|
||||
color: var(--han-error);
|
||||
}
|
||||
|
||||
.han-otp-error {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.han-resend {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 0 24px;
|
||||
color: var(--han-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.han-resend p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.han-resend strong {
|
||||
color: var(--han-text);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.han-resend button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--han-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.han-shake {
|
||||
animation: han-shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes han-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15% { transform: translateX(-6px); }
|
||||
30% { transform: translateX(6px); }
|
||||
45% { transform: translateX(-5px); }
|
||||
60% { transform: translateX(5px); }
|
||||
75% { transform: translateX(-3px); }
|
||||
90% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.han-auth-screen {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.han-otp-inputs {
|
||||
gap: 7px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
(function () {
|
||||
function randomId() {
|
||||
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (char) {
|
||||
var value = Math.random() * 16 | 0;
|
||||
return (char === "x" ? value : (value & 3 | 8)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function initDeviceMetadata() {
|
||||
var deviceId = window.localStorage.getItem("han_device_id") || randomId();
|
||||
var fingerprint = window.localStorage.getItem("han_fingerprint") || randomId();
|
||||
window.localStorage.setItem("han_device_id", deviceId);
|
||||
window.localStorage.setItem("han_fingerprint", fingerprint);
|
||||
document.querySelectorAll(".han-device-id").forEach(function (input) {
|
||||
if (!input.value) input.value = deviceId;
|
||||
});
|
||||
document.querySelectorAll(".han-fingerprint").forEach(function (input) {
|
||||
if (!input.value) input.value = fingerprint;
|
||||
});
|
||||
document.querySelectorAll(".han-os-name").forEach(function (input) {
|
||||
if (!input.value) {
|
||||
input.value = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPhoneForm() {
|
||||
var input = document.getElementById("phone");
|
||||
var submit = document.getElementById("han-phone-submit");
|
||||
if (!input || !submit) return;
|
||||
|
||||
function formatPhone(value) {
|
||||
var digits = value.replace(/\D/g, "");
|
||||
if (!digits) return "";
|
||||
if (digits.indexOf("8") === 0) digits = "7" + digits.slice(1);
|
||||
else if (digits.indexOf("7") !== 0) digits = "7" + digits;
|
||||
digits = digits.slice(0, 11);
|
||||
|
||||
var result = "+7";
|
||||
if (digits.length > 1) result += " (" + digits.slice(1, 4);
|
||||
if (digits.length >= 4) result += ") " + digits.slice(4, 7);
|
||||
if (digits.length >= 7) result += " " + digits.slice(7, 9);
|
||||
if (digits.length >= 9) result += " " + digits.slice(9, 11);
|
||||
return result;
|
||||
}
|
||||
|
||||
function updatePhone() {
|
||||
input.value = formatPhone(input.value);
|
||||
submit.disabled = input.value.replace(/\D/g, "").length !== 11;
|
||||
}
|
||||
|
||||
input.addEventListener("input", updatePhone);
|
||||
updatePhone();
|
||||
}
|
||||
|
||||
function initOtpForm() {
|
||||
var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit"));
|
||||
var hidden = document.getElementById("otp");
|
||||
var submit = document.getElementById("han-otp-submit");
|
||||
if (!fields.length || !hidden || !submit) return;
|
||||
|
||||
function syncOtp() {
|
||||
fields.forEach(function (field) {
|
||||
field.classList.toggle("han-filled", Boolean(field.value));
|
||||
});
|
||||
hidden.value = fields.map(function (field) { return field.value; }).join("");
|
||||
submit.disabled = hidden.value.length !== fields.length;
|
||||
}
|
||||
|
||||
fields.forEach(function (field, index) {
|
||||
field.addEventListener("input", function () {
|
||||
var entered = field.value.replace(/\D/g, "");
|
||||
if (entered.length > 1) {
|
||||
entered.slice(0, fields.length).split("").forEach(function (digit, digitIndex) {
|
||||
fields[digitIndex].value = digit;
|
||||
});
|
||||
fields[Math.min(entered.length, fields.length) - 1].focus();
|
||||
} else {
|
||||
field.value = entered.slice(-1);
|
||||
if (field.value && index < fields.length - 1) fields[index + 1].focus();
|
||||
}
|
||||
syncOtp();
|
||||
});
|
||||
|
||||
field.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Backspace" && !field.value && index > 0) {
|
||||
fields[index - 1].value = "";
|
||||
fields[index - 1].focus();
|
||||
syncOtp();
|
||||
}
|
||||
if (event.key === "ArrowLeft" && index > 0) fields[index - 1].focus();
|
||||
if (event.key === "ArrowRight" && index < fields.length - 1) fields[index + 1].focus();
|
||||
});
|
||||
|
||||
field.addEventListener("paste", function (event) {
|
||||
event.preventDefault();
|
||||
var pasted = event.clipboardData.getData("text").replace(/\D/g, "").slice(0, fields.length);
|
||||
fields.forEach(function (otpField) { otpField.value = ""; });
|
||||
pasted.split("").forEach(function (digit, digitIndex) {
|
||||
fields[digitIndex].value = digit;
|
||||
});
|
||||
if (pasted.length) fields[Math.min(pasted.length, fields.length) - 1].focus();
|
||||
syncOtp();
|
||||
});
|
||||
});
|
||||
|
||||
syncOtp();
|
||||
|
||||
var countdown = document.getElementById("han-resend-countdown");
|
||||
var countdownValue = countdown && countdown.querySelector("strong");
|
||||
var resend = document.getElementById("han-resend-button");
|
||||
if (!countdown || !countdownValue || !resend) return;
|
||||
|
||||
var expiresAt = Number(countdown.getAttribute("data-expires-at"));
|
||||
if (!Number.isFinite(expiresAt)) expiresAt = Date.now();
|
||||
var timer;
|
||||
function updateCountdown() {
|
||||
var seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
|
||||
countdownValue.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
|
||||
if (seconds <= 0) {
|
||||
if (timer) window.clearInterval(timer);
|
||||
countdown.hidden = true;
|
||||
resend.hidden = false;
|
||||
}
|
||||
}
|
||||
updateCountdown();
|
||||
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
|
||||
resend.addEventListener("click", function () {
|
||||
if (document.getElementById("han-captcha-container")) return;
|
||||
window.setTimeout(function () { resend.disabled = true; }, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function initCaptcha() {
|
||||
var container = document.getElementById("han-captcha-container");
|
||||
if (!container) return;
|
||||
var form = document.getElementById(container.getAttribute("data-form-id"));
|
||||
var tokenInput = document.getElementById("han-captcha-token");
|
||||
var error = document.getElementById("han-captcha-client-error");
|
||||
var resendOnly = container.getAttribute("data-resend-only") === "true";
|
||||
var widgetId = null;
|
||||
var executing = false;
|
||||
var pendingSubmitter = null;
|
||||
|
||||
function showError() {
|
||||
executing = false;
|
||||
if (pendingSubmitter) pendingSubmitter.disabled = false;
|
||||
pendingSubmitter = null;
|
||||
if (tokenInput) tokenInput.value = "";
|
||||
if (error) error.hidden = false;
|
||||
}
|
||||
|
||||
window.hanCaptchaOnload = function () {
|
||||
if (!window.smartCaptcha || !form || !tokenInput) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
widgetId = window.smartCaptcha.render(container, {
|
||||
sitekey: container.getAttribute("data-sitekey"),
|
||||
invisible: true,
|
||||
hl: "ru",
|
||||
callback: function (token) {
|
||||
if (!token || !pendingSubmitter) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
var submitter = pendingSubmitter;
|
||||
pendingSubmitter = null;
|
||||
executing = false;
|
||||
tokenInput.value = token;
|
||||
if (submitter.name) {
|
||||
var action = document.createElement("input");
|
||||
action.type = "hidden";
|
||||
action.name = submitter.name;
|
||||
action.value = submitter.value;
|
||||
form.appendChild(action);
|
||||
}
|
||||
HTMLFormElement.prototype.submit.call(form);
|
||||
}
|
||||
});
|
||||
window.smartCaptcha.subscribe(widgetId, "network-error", showError);
|
||||
window.smartCaptcha.subscribe(widgetId, "javascript-error", showError);
|
||||
window.smartCaptcha.subscribe(widgetId, "token-expired", function () {
|
||||
tokenInput.value = "";
|
||||
if (executing) showError();
|
||||
});
|
||||
} catch (_error) {
|
||||
showError();
|
||||
}
|
||||
};
|
||||
|
||||
if (!form || !tokenInput) return;
|
||||
form.addEventListener("submit", function (event) {
|
||||
var submitter = event.submitter;
|
||||
var requiresCaptcha = !resendOnly
|
||||
|| (submitter && submitter.name === "otp_action" && submitter.value === "resend");
|
||||
if (!requiresCaptcha) return;
|
||||
event.preventDefault();
|
||||
if (executing) return;
|
||||
if (error) error.hidden = true;
|
||||
if (widgetId === null || !window.smartCaptcha) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
pendingSubmitter = submitter || document.getElementById(container.getAttribute("data-submit-id"));
|
||||
executing = true;
|
||||
if (pendingSubmitter) pendingSubmitter.disabled = true;
|
||||
tokenInput.value = "";
|
||||
try {
|
||||
window.smartCaptcha.execute(widgetId);
|
||||
} catch (_error) {
|
||||
showError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initDeviceMetadata();
|
||||
initPhoneForm();
|
||||
initOtpForm();
|
||||
initCaptcha();
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
parent=keycloak.v2
|
||||
import=common/keycloak
|
||||
styles=css/han-login.css
|
||||
locales=ru
|
||||
Reference in New Issue
Block a user