Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
+16 -2
View File
@@ -10,6 +10,7 @@ MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
KEYCLOAK_IMAGE=han-chat-keycloak:local
SMS_SERVICE_IMAGE=han-chat-sms-service:local
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
HAN_PG_HOST=managed-pg.private.example
@@ -22,6 +23,7 @@ BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.e
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
SMS_DATABASE_URL=postgresql+asyncpg://sms_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_SCHEMA=keycloak
KEYCLOAK_DB_USERNAME=keycloak_user
@@ -40,11 +42,12 @@ NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
NGINX_HSTS_MAX_AGE=0
NGINX_CLIENT_MAX_BODY_SIZE=8m
NGINX_RATE_LIMIT_API=60r/m
NGINX_RATE_LIMIT_AUTH=10r/m
NGINX_RATE_LIMIT_AUTH=60r/m
NGINX_RATE_LIMIT_PUBLIC=60r/m
NGINX_RATE_LIMIT_POLLING=60r/m
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
NGINX_RATE_LIMIT_BITRIX=120r/m
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
NGINX_RATE_LIMIT_WS=30r/m
NGINX_MESSAGE_READ_TIMEOUT_SEC=330
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
@@ -67,9 +70,11 @@ KEYCLOAK_OTP_MOCK_CODE=change-me
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
# (openssl rand -hex 32)
KEYCLOAK_OTP_HMAC_KEY=change-me
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
# Должен совпадать с SMS_SERVICE_TOKEN.
KEYCLOAK_SMS_SERVICE_TOKEN=change-me
KEYCLOAK_ADMIN=bootstrap-admin
# (openssl rand -hex 32)
KEYCLOAK_ADMIN_PASSWORD=change-me
@@ -101,6 +106,15 @@ BITRIX_API_INBOX_TOKEN=change-me
BITRIX_SYNC_SERVICE_TOKEN=change-me
#token5 (openssl rand -hex 32)
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
#token6 (openssl rand -hex 32), должен совпадать с KEYCLOAK_SMS_SERVICE_TOKEN
SMS_SERVICE_TOKEN=change-me
# i-Digital Direct. Перед production заменить placeholders согласованными значениями.
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=change-me
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=change-me
IDGTL_SMS_CALLBACK_PASSWORD=change-me
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
@@ -0,0 +1,37 @@
"""Seed runtime OTP settings.
Revision ID: 0005_otp_settings
Revises: 0004_device_otp
Create Date: 2026-07-22
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0005_otp_settings"
down_revision: str | None = "0004_device_otp"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('otp.phone.code_length', '6', 'integer', false,
'Length of the numeric phone OTP', 'A', now()),
('otp.phone.ttl_seconds', '60', 'integer', false,
'Phone OTP lifetime from durable order time', 'A', now()),
('otp.phone.sms_order_timeout_ms', '3000', 'integer', false,
'Keycloak timeout for durable SMS order creation', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("OTP runtime settings migration is forward-only")
@@ -10,6 +10,7 @@ from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert
from app.db import AppSetting, Database
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
@@ -32,8 +33,12 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
value_type = raw.get("type")
if value_type not in VALUE_TYPES:
raise ValueError(f"{key}: unsupported type {value_type!r}")
if key in OTP_SETTING_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
@@ -47,6 +52,7 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
"record_status": "A",
}
)
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
return rows
+11 -2
View File
@@ -58,6 +58,7 @@ from app.schemas import (
ConsentsRequest,
MessageRequest,
OpenLinesInbox,
OtpSettingsResponse,
SessionStartRequest,
decode_cursor,
encode_cursor,
@@ -415,7 +416,7 @@ async def ready(request: Request, db: Session):
try:
await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0004_device_otp":
if revision != "0005_otp_settings":
raise RuntimeError("unexpected database revision")
await load_settings(db)
components["postgres"] = "ok"
@@ -963,7 +964,12 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
return JSONResponse(body, status_code=status)
@app.get("/internal/settings/v1/otp", tags=["internal"])
@app.get(
"/internal/settings/v1/otp",
tags=["internal"],
response_model=OtpSettingsResponse,
responses={304: {"description": "Cached settings are still current"}},
)
async def otp_settings(
request: Request,
settings: SnapshotDep,
@@ -980,6 +986,9 @@ async def otp_settings(
"max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"),
"min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"),
"max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"),
"code_length": settings.integer("otp.phone.code_length"),
"ttl_seconds": settings.integer("otp.phone.ttl_seconds"),
"sms_order_timeout_ms": settings.integer("otp.phone.sms_order_timeout_ms"),
"version": settings.version,
"cache_ttl_seconds": 60,
}, headers=headers)
@@ -0,0 +1,44 @@
from collections.abc import Mapping
OTP_SETTING_KEYS = {
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"otp.phone.max_verify_attempts",
"otp.phone.code_length",
"otp.phone.ttl_seconds",
"otp.phone.sms_order_timeout_ms",
}
def validate_otp_settings(values: Mapping[str, str]) -> None:
parsed: dict[str, int] = {}
for key in OTP_SETTING_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{key}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{key}: canonical integer value expected")
parsed[key] = value
positive = OTP_SETTING_KEYS - {"otp.phone.min_seconds_between_attempts"}
for key in positive:
if key in parsed and parsed[key] <= 0:
raise ValueError(f"{key}: value must be positive")
if parsed.get("otp.phone.min_seconds_between_attempts", 0) < 0:
raise ValueError("otp.phone.min_seconds_between_attempts: value must be non-negative")
code_length = parsed.get("otp.phone.code_length")
if code_length is not None and not 4 <= code_length <= 10:
raise ValueError("otp.phone.code_length: value must be between 4 and 10")
ttl_seconds = parsed.get("otp.phone.ttl_seconds")
if ttl_seconds is not None and (
not 60 <= ttl_seconds <= 900 or ttl_seconds % 60 != 0
):
raise ValueError(
"otp.phone.ttl_seconds: value must be between 60 and 900 and divisible by 60"
)
@@ -14,6 +14,17 @@ class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class OtpSettingsResponse(StrictModel):
max_send_attempts_per_24h: int = Field(strict=True, gt=0)
min_seconds_between_attempts: int = Field(strict=True, ge=0)
max_verify_attempts: int = Field(strict=True, gt=0)
code_length: int = Field(strict=True, ge=4, le=10)
ttl_seconds: int = Field(strict=True, ge=60, le=900, multiple_of=60)
sms_order_timeout_ms: int = Field(strict=True, gt=0)
version: str = Field(min_length=1, max_length=64)
cache_ttl_seconds: int = Field(strict=True, gt=0)
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
+26 -4
View File
@@ -37,6 +37,7 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.realtime import RealtimeFanout
from app.schemas import (
AttachmentCompleteRequest,
@@ -84,7 +85,7 @@ REQUIRED_SETTINGS = {
"ux.session.idle_timeout_minutes",
"security.cors.allowed_origins",
"security.public_cache.max_age_seconds",
}
} | OTP_SETTING_KEYS
@dataclass(frozen=True, slots=True)
@@ -126,9 +127,11 @@ class AuditContext:
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
rows = (
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
rows = list(
(
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
)
values = {row.setting_key: row.setting_value for row in rows}
missing = REQUIRED_SETTINGS - values.keys()
if missing:
@@ -138,6 +141,25 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
"Required settings are unavailable",
{"missing": sorted(missing)},
)
try:
invalid_metadata = sorted(
row.setting_key
for row in rows
if row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
)
if invalid_metadata:
raise ValueError(
f"OTP settings must have integer type and be private: {invalid_metadata}"
)
validate_otp_settings(values)
except ValueError as error:
raise DomainError(
"dependency_unavailable",
503,
"OTP settings are invalid",
{"reason": str(error)},
) from error
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
return SettingsSnapshot(values, version)
+27 -1
View File
@@ -196,7 +196,12 @@ paths:
operationId: getOtpSettings
security: [{serviceBearer: []}]
responses:
"200": {description: Product OTP limits and cache metadata}
"200":
description: Product OTP limits and cache metadata
content:
application/json:
schema: {$ref: "#/components/schemas/OtpSettingsResponse"}
"304": {description: Cached settings are still current}
"503": {$ref: "#/components/responses/DependencyUnavailable"}
components:
securitySchemes:
@@ -218,6 +223,27 @@ components:
description: Required dependency is unavailable
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
schemas:
OtpSettingsResponse:
type: object
additionalProperties: false
required:
- max_send_attempts_per_24h
- min_seconds_between_attempts
- max_verify_attempts
- code_length
- ttl_seconds
- sms_order_timeout_ms
- version
- cache_ttl_seconds
properties:
max_send_attempts_per_24h: {type: integer, minimum: 1}
min_seconds_between_attempts: {type: integer, minimum: 0}
max_verify_attempts: {type: integer, minimum: 1}
code_length: {type: integer, minimum: 4, maximum: 10}
ttl_seconds: {type: integer, minimum: 60, maximum: 900, multipleOf: 60}
sms_order_timeout_ms: {type: integer, minimum: 1}
version: {type: string, minLength: 1, maxLength: 64}
cache_ttl_seconds: {type: integer, minimum: 1}
ErrorEnvelope:
type: object
required: [error]
@@ -1,10 +1,13 @@
import base64
import json
from pathlib import Path
from types import SimpleNamespace
import yaml
from pydantic import SecretStr
from app.main import app, websocket_token
from app.main import app, otp_settings, websocket_token
from app.services import SettingsSnapshot
EXPECTED_PATHS = {
"/health/live",
@@ -56,3 +59,68 @@ def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
assert committed["servers"] == [{"url": "/"}]
def test_otp_settings_contract_is_strict_and_complete() -> None:
generated = app.openapi()
response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"]
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
assert set(schema["required"]) == {
"max_send_attempts_per_24h",
"min_seconds_between_attempts",
"max_verify_attempts",
"code_length",
"ttl_seconds",
"sms_order_timeout_ms",
"version",
"cache_ttl_seconds",
}
assert schema["additionalProperties"] is False
assert schema["properties"]["code_length"] == {
"type": "integer",
"maximum": 10.0,
"minimum": 4.0,
"title": "Code Length",
}
assert schema["properties"]["ttl_seconds"]["multipleOf"] == 60
async def test_otp_settings_returns_runtime_values_and_supports_etag() -> None:
request = SimpleNamespace(
headers={"Authorization": "Bearer bridge-token"},
app=SimpleNamespace(
state=SimpleNamespace(
settings=SimpleNamespace(
keycloak_settings_bridge_token=SecretStr("bridge-token")
)
)
),
)
settings = SettingsSnapshot(
{
"otp.phone.max_send_attempts_per_24h": "3",
"otp.phone.min_seconds_between_attempts": "30",
"otp.phone.max_verify_attempts": "5",
"otp.phone.code_length": "6",
"otp.phone.ttl_seconds": "60",
"otp.phone.sms_order_timeout_ms": "3000",
},
"settings-version",
)
response = await otp_settings(request, settings)
assert json.loads(response.body) == {
"max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "settings-version",
"cache_ttl_seconds": 60,
}
cached = await otp_settings(request, settings, response.headers["etag"])
assert cached.status_code == 304
@@ -12,6 +12,10 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
assert all(row["record_status"] == "A" for row in rows)
values = {row["setting_key"]: row["setting_value"] for row in rows}
assert values["otp.phone.code_length"] == "6"
assert values["otp.phone.ttl_seconds"] == "60"
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
@@ -24,3 +28,37 @@ def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="integer value expected"):
load_seed(path)
@pytest.mark.parametrize(
("key", "value", "message"),
[
("otp.phone.code_length", 3, "between 4 and 10"),
("otp.phone.ttl_seconds", 61, "divisible by 60"),
("otp.phone.sms_order_timeout_ms", 0, "must be positive"),
],
)
def test_seed_rejects_invalid_otp_settings(
tmp_path: Path, key: str, value: int, message: str
) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
f" {key}: {{type: integer, value: {value}, public: false}}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match=message):
load_seed(path)
def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" otp.phone.code_length: {type: integer, value: 6, public: true}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="must not be public"):
load_seed(path)
@@ -372,7 +372,7 @@ MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@redis:63
### 8.5. Mock OTP
В MVP реализован только mock OTP. Для запуска:
В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout:
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=true
@@ -383,6 +383,10 @@ KEYCLOAK_OTP_MOCK_CODE=<ТЕСТОВЫЙ_КОД_НЕ_КОРОЧЕ_16_СИМВО
Этот код будет вводиться пользователем при тестовой авторизации. Не используйте
его как production-механизм доставки OTP.
Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
До переключения необходимы: schema/role `sms` и migrations/seed, active approved `auth_otp` (`code`, `ttl_min`), согласованный sender, Direct `TOKEN_1`, парные service tokens, отдельные callback credentials, exact nginx callback route, подтверждённый source IP Direct и статический egress IP worker. Сначала deploy при mock=true, затем provider smoke/callback/redaction evidence и только после этого cutover. Rollback возвращает mock без удаления SMS schema/journal.
### 8.6. S3
```dotenv
+6
View File
@@ -211,6 +211,12 @@ SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
```
## Real SMS rollout addendum
This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP.
Order: App DB OTP seed → SMS schema/migrations/seed → mock Direct tests → production SMS deployment while Keycloak remains in mock mode → Keycloak expand migration/SPI → controlled provider smoke plus callback/redaction evidence → real mode. Roll back by restoring mock mode without deleting the journal/schema; stop new real orders and drain or record in-flight/`uncertain` rows. Downgrade only with proven schema compatibility.
Never run Alembic downgrade. After a backward-incompatible migration choose a
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
@@ -216,6 +216,12 @@ SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
```
## Дополнение: rollout реальной SMS-авторизации
Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker.
Порядок: App DB OTP seed → SMS schema/migrations/seed → test с mock Direct → production SMS deploy при `KEYCLOAK_OTP_MOCK_ENABLED=true` → Keycloak expand migration/SPI → provider smoke и callback/redaction evidence → real mode. Rollback: вернуть mock, не удалять journal/schema, остановить новые real orders и зафиксировать in-flight/`uncertain`; downgrade только при доказанной совместимости.
Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте
исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время
технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение
@@ -5,6 +5,9 @@ settings:
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
otp.phone.max_verify_attempts: {type: integer, value: 5, public: false}
otp.phone.code_length: {type: integer, value: 6, public: false}
otp.phone.ttl_seconds: {type: integer, value: 60, public: false}
otp.phone.sms_order_timeout_ms: {type: integer, value: 3000, public: false}
operator.call.phone: {type: string, value: "+74999591007", public: true}
consent.personal_data.required: {type: boolean, value: true, public: true}
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
@@ -1,3 +1,11 @@
x-no-sms-secrets: &no-sms-secrets
SMS_DATABASE_URL: ""
SMS_SERVICE_TOKEN: ""
KEYCLOAK_SMS_SERVICE_TOKEN: ""
IDGTL_SMS_API_KEY: ""
IDGTL_SMS_CALLBACK_USERNAME: ""
IDGTL_SMS_CALLBACK_PASSWORD: ""
services:
migrate-api:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
@@ -5,6 +13,7 @@ services:
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
@@ -19,6 +28,7 @@ services:
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
@@ -33,6 +43,25 @@ services:
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
migrate-sms:
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
profiles: ["ops"]
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
entrypoint: []
command: ["alembic", "upgrade", "head"]
volumes:
@@ -47,6 +76,7 @@ services:
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
command:
- /bin/sh
@@ -12,7 +12,9 @@ docker compose --env-file "${ENV_FILE:-.env}" config --quiet
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-sms alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-sms
echo "Migrations completed; record revisions in release evidence."
@@ -42,6 +42,12 @@ curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-co
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
sms_internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000")
[ "$sms_internal_code" = "404" ] || { echo "Public SMS internal API returned $sms_internal_code, expected 404" >&2; exit 1; }
sms_callback_code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' --data '[]' \
"${PUBLIC_WEB_URL}/callbacks/idgtl/sms")
[ "$sms_callback_code" = "403" ] || { echo "SMS callback without provider IP returned $sms_callback_code, expected 403" >&2; exit 1; }
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
@@ -4,6 +4,7 @@ import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { env, oidcIssuer } from "./config";
import { buildOidcDeviceMetadata } from "./oidc-device";
import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types";
@@ -84,6 +85,7 @@ export async function beginAuthorization() {
encoding: Crypto.CryptoEncoding.BASE64,
});
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId,
@@ -94,6 +96,7 @@ export async function beginAuthorization() {
code_challenge_method: "S256",
state,
nonce,
...deviceMetadata,
})}`;
if (Platform.OS === "web" && typeof window !== "undefined") {
window.location.assign(url);
@@ -0,0 +1,94 @@
import Constants from "expo-constants";
import * as Crypto from "expo-crypto";
import { Platform } from "react-native";
const DEVICE_ID_KEY = "han.web-device-id";
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
type Store = {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
export type OidcDeviceMetadata = Partial<Record<
| "han_device_id"
| "han_fingerprint"
| "han_platform"
| "han_os_name"
| "han_os_version"
| "han_app_version",
string
>>;
function safe(value: unknown, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || CONTROL_CHARACTERS.test(normalized)) {
return undefined;
}
return normalized;
}
type BrowserDetails = {
osName?: string;
osVersion?: string;
fingerprintSource?: string;
};
function browserDetails(): BrowserDetails {
if (typeof navigator === "undefined") return {};
const userAgent = navigator.userAgent;
const platform = safe(navigator.platform, 64);
const windows = userAgent.match(/Windows NT ([\d.]+)/);
const android = userAgent.match(/Android ([\d.]+)/);
const ios = userAgent.match(/(?:iPhone )?OS ([\d_]+)/);
const osName = windows ? "Windows" : android ? "Android" : ios ? "iOS" : platform;
const osVersion = windows?.[1] ?? android?.[1] ?? ios?.[1]?.replaceAll("_", ".");
return {
...(osName ? { osName } : {}),
...(osVersion ? { osVersion } : {}),
fingerprintSource: [
userAgent,
navigator.language,
platform,
Intl.DateTimeFormat().resolvedOptions().timeZone,
typeof screen === "undefined" ? "" : `${screen.width}x${screen.height}`,
].join("|"),
};
}
export async function buildOidcDeviceMetadata(store: Store): Promise<OidcDeviceMetadata> {
const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web";
let deviceId = safe(await store.get(DEVICE_ID_KEY), 256);
if (!deviceId) {
deviceId = Crypto.randomUUID();
await store.set(DEVICE_ID_KEY, deviceId);
}
const browser = platform === "web" ? browserDetails() : {};
const constants = Platform.constants as unknown as Record<string, unknown>;
const fingerprintSource = browser.fingerprintSource
?? [platform, constants.Brand, constants.Model, constants.osVersion].join("|");
const fingerprint = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
`${deviceId}|${fingerprintSource}`,
);
const osName = browser.osName
?? safe(constants.systemName, 64)
?? (platform === "ios" ? "iOS" : platform === "android" ? "Android" : undefined);
const osVersion = browser.osVersion
?? safe(String(constants.osVersion ?? Platform.Version ?? ""), 64);
const appVersion = safe(Constants.expoConfig?.version, 64);
const safeOsName = safe(osName, 64);
const safeOsVersion = safe(osVersion, 64);
return {
han_device_id: deviceId,
han_fingerprint: fingerprint,
han_platform: platform,
...(safeOsName ? { han_os_name: safeOsName } : {}),
...(safeOsVersion ? { han_os_version: safeOsVersion } : {}),
...(appVersion ? { han_app_version: appVersion } : {}),
};
}
@@ -1,4 +1,18 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-constants", () => ({
default: { expoConfig: { version: "1.0.0" } },
}));
vi.mock("expo-crypto", () => ({
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"),
digestStringAsync: vi.fn(async () => "stable-fingerprint"),
}));
vi.mock("react-native", () => ({
Platform: { OS: "web", Version: "test", constants: {} },
}));
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
import { reconcileMessages } from "../../src/reconcile";
import { sessionMemory } from "../../src/session";
import { SingleFlight } from "../../src/single-flight";
@@ -95,3 +109,26 @@ describe("WebSocket authentication protocol", () => {
expect(protocol).not.toContain("=");
});
});
describe("OIDC device metadata", () => {
it("создаёт стабильный web UUID и передаёт доступные han_* поля", async () => {
const values = new Map<string, string>();
const store = {
get: async (key: string) => values.get(key) ?? null,
set: async (key: string, value: string) => {
values.set(key, value);
},
};
const first = await buildOidcDeviceMetadata(store);
const second = await buildOidcDeviceMetadata(store);
expect(first.han_device_id).toBe("123e4567-e89b-42d3-a456-426614174000");
expect(second.han_device_id).toBe(first.han_device_id);
expect(first).toMatchObject({
han_fingerprint: "stable-fingerprint",
han_platform: "web",
han_app_version: "1.0.0",
});
});
});
+79 -2
View File
@@ -1,3 +1,11 @@
x-no-sms-secrets: &no-sms-secrets
SMS_DATABASE_URL: ""
SMS_SERVICE_TOKEN: ""
KEYCLOAK_SMS_SERVICE_TOKEN: ""
IDGTL_SMS_API_KEY: ""
IDGTL_SMS_CALLBACK_USERNAME: ""
IDGTL_SMS_CALLBACK_PASSWORD: ""
x-api-runtime: &api-runtime
build:
context: ../../api-backend
@@ -5,6 +13,7 @@ x-api-runtime: &api-runtime
env_file:
- path: ../../.env
required: false
environment: *no-sms-secrets
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
@@ -16,6 +25,29 @@ x-api-runtime: &api-runtime
driver: json-file
options: {max-size: "50m", max-file: "5"}
x-sms-runtime: &sms-runtime
build:
context: ../../sms-service
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
target: /run/secrets/pg-ca.pem
read_only: true
security_opt: ["no-new-privileges:true"]
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
services:
frontend-static:
build:
@@ -45,6 +77,7 @@ services:
- path: ../../.env
required: false
environment:
<<: *no-sms-secrets
KC_DB: postgres
KC_DB_URL: ${KEYCLOAK_DB_URL}
KC_DB_SCHEMA: ${KEYCLOAK_DB_SCHEMA:-keycloak}
@@ -59,12 +92,13 @@ services:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
KEYCLOAK_SMS_SERVICE_URL: ${KEYCLOAK_SMS_SERVICE_URL:-http://sms-service:8080}
KEYCLOAK_SMS_SERVICE_TOKEN: ${KEYCLOAK_SMS_SERVICE_TOKEN:?KEYCLOAK_SMS_SERVICE_TOKEN is required}
command: ["start", "--optimized", "--import-realm"]
expose: ["8080", "9000"]
volumes:
@@ -85,6 +119,43 @@ services:
driver: json-file
options: {max-size: "50m", max-file: "5"}
sms-service:
<<: *sms-runtime
expose: ["8080"]
networks: [backend, observability, egress]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
sms-worker:
<<: *sms-runtime
entrypoint: []
command: ["han-sms-worker"]
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_API_KEY: ${IDGTL_SMS_API_KEY:?IDGTL_SMS_API_KEY is required}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
networks: [backend, observability, egress]
depends_on:
sms-service: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-sms-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
message-safety:
build:
context: ../../message-safety
@@ -92,6 +163,8 @@ services:
env_file:
- path: ../../.env
required: false
environment:
<<: *no-sms-secrets
expose: ["8080"]
volumes:
- type: bind
@@ -179,6 +252,8 @@ services:
env_file:
- path: ../../.env
required: false
environment:
<<: *no-sms-secrets
expose: ["8080"]
volumes:
- type: bind
@@ -207,6 +282,8 @@ services:
env_file:
- path: ../../.env
required: false
environment:
<<: *no-sms-secrets
expose: ["8080"]
volumes:
- type: bind
+2 -1
View File
@@ -7,10 +7,11 @@ KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
KEYCLOAK_SMS_SERVICE_TOKEN=replace-with-independent-service-token
KEYCLOAK_LOG_LEVEL=INFO
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
+1
View File
@@ -6,6 +6,7 @@ COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
COPY src ./src
COPY realm ./realm
COPY themes ./themes
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
+15 -5
View File
@@ -9,10 +9,12 @@ Production-like Keycloak 26.1.4 image and realm for OTP-only phone authenticatio
- Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`.
- Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days.
- Realm brute-force protection uses temporary bounded lockouts.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migration `han-otp-1.0.0` is applied by Keycloak's JPA entity provider.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migrations are applied by Keycloak's JPA entity provider.
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
- Mock mode is explicit. Startup rejects missing values, code `1234`, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup.
- Every challenge snapshots code length, TTL, SMS-order timeout and settings version. Runtime OTP values are not read from environment variables.
- Mock mode is explicit and retains the configured test code. SMS mode generates a cryptographically secure numeric OTP, stores only its HMAC and orders delivery through `POST /internal/sms/v1/send`; Keycloak never calls or polls the provider.
- SMS mode requires `KEYCLOAK_SMS_SERVICE_URL` and an independent `KEYCLOAK_SMS_SERVICE_TOKEN`. No real credentials are committed.
## Build and test
@@ -73,14 +75,22 @@ Private signing keys are generated and stored by Keycloak and are absent from th
Provider tables:
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock;
- `han_otp_challenge`: expiring, one-time challenges with explicit ordering/active/final statuses, settings snapshot and optional `sms_message_id`;
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
- `han_otp_security_event`: append-only minimal outcomes without raw phone or OTP.
- `han_otp_security_event`: append-only send/verify outcomes with SMS correlation and validated device audit metadata, without raw phone or OTP.
Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use.
Resend creates a new durable order and marks earlier active/ordering challenges as superseded. Verification accepts only active, unexpired challenges, locks the row, increments attempts, and atomically consumes a valid code. Provider delivery status never participates in verification.
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
The provider schedules a once-per-minute expiry update and also performs lazy expiry on send and verify. The theme renders digit inputs and countdown from the challenge snapshot, submits a real resend action and carries optional `han_*` device metadata.
## SMS order behavior
`200` or `202` with a valid UUID `sms_message_id` and ISO-8601 `ordered_at` activates a real-mode challenge. Timeout, I/O failure or 5xx is retried once with the same `keycloak:challenge:{id}` idempotency key; final failure marks that challenge `order_failed`. The retry creates neither another challenge nor another send-counter increment.
Reserve, SMS HTTP order, and activation/order-failure run as separate transaction phases. The HTTP call holds no challenge/counter database lock, and every retry retains the same challenge id.
## Release and recovery
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
+3 -2
View File
@@ -21,12 +21,13 @@ services:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:-}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
KEYCLOAK_SMS_SERVICE_URL: ${KEYCLOAK_SMS_SERVICE_URL:-http://sms-service:8080}
KEYCLOAK_SMS_SERVICE_TOKEN: ${KEYCLOAK_SMS_SERVICE_TOKEN:-}
KC_LOG_CONSOLE_OUTPUT: json
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
@@ -5,21 +5,26 @@ import java.time.Duration;
final class Config {
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE");
static final String MOCK_CODE = env("KEYCLOAK_OTP_MOCK_CODE", "");
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
"http://api-backend:8000/internal/settings/v1/otp"));
static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN");
static final URI SMS_SERVICE_URL = URI.create(env("KEYCLOAK_SMS_SERVICE_URL",
"http://sms-service:8080")).resolve("/internal/sms/v1/send");
static final String SMS_SERVICE_TOKEN = env("KEYCLOAK_SMS_SERVICE_TOKEN", "");
static {
if (!MOCK_ENABLED) {
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start");
if (MOCK_ENABLED && (!MOCK_CODE.matches("\\d{6,10}") || "1234".equals(MOCK_CODE))) {
throw new IllegalStateException(
"KEYCLOAK_OTP_MOCK_CODE must be a non-default numeric code of 6 to 10 digits");
}
if (MOCK_CODE.isBlank() || "1234".equals(MOCK_CODE) || MOCK_CODE.length() < 6) {
throw new IllegalStateException("KEYCLOAK_OTP_MOCK_CODE must be a non-default secret of at least 6 characters");
if (!MOCK_ENABLED
&& SMS_SERVICE_TOKEN.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException(
"KEYCLOAK_SMS_SERVICE_TOKEN must contain at least 32 bytes in SMS mode");
}
if (HMAC_KEY.length < 32) {
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
@@ -28,6 +33,10 @@ final class Config {
private Config() {}
static void validate() {
// Class initialization performs the fail-closed validation.
}
private static String required(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
@@ -18,6 +18,17 @@ final class Crypto {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String randomNumericCode(int length) {
if (length < 4 || length > 10) {
throw new IllegalArgumentException("OTP length must be between 4 and 10");
}
StringBuilder code = new StringBuilder(length);
for (int index = 0; index < length; index++) {
code.append(RANDOM.nextInt(10));
}
return code.toString();
}
static String hmac(String purpose, String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
@@ -0,0 +1,66 @@
package ru.han.chat.keycloak;
import jakarta.ws.rs.core.MultivaluedMap;
import java.util.Set;
import org.keycloak.authentication.AuthenticationFlowContext;
record DeviceMetadata(
String clientIp,
String userAgent,
String deviceId,
String fingerprint,
String osName,
String osVersion,
String platform,
String appVersion) {
private static final Set<String> PLATFORMS = Set.of("web", "ios", "android");
static DeviceMetadata capture(AuthenticationFlowContext context) {
MultivaluedMap<String, String> form = context.getHttpRequest().getDecodedFormParameters();
var session = context.getAuthenticationSession();
MultivaluedMap<String, String> query = context.getHttpRequest().getUri().getQueryParameters();
String deviceId = value(form, query, session.getAuthNote("han.device_id"), "han_device_id", 256);
String fingerprint = value(form, query, session.getAuthNote("han.fingerprint"), "han_fingerprint", 256);
String osName = value(form, query, session.getAuthNote("han.os_name"), "han_os_name", 64);
String osVersion = value(form, query, session.getAuthNote("han.os_version"), "han_os_version", 64);
String platform = value(form, query, session.getAuthNote("han.platform"), "han_platform", 16);
String appVersion = value(form, query, session.getAuthNote("han.app_version"), "han_app_version", 64);
if (platform != null && !PLATFORMS.contains(platform)) platform = null;
save(session, "han.device_id", deviceId);
save(session, "han.fingerprint", fingerprint);
save(session, "han.os_name", osName);
save(session, "han.os_version", osVersion);
save(session, "han.platform", platform);
save(session, "han.app_version", appVersion);
return new DeviceMetadata(
clean(context.getConnection().getRemoteAddr(), 64),
clean(context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent"), 1024),
deviceId, fingerprint, osName, osVersion, platform, appVersion);
}
private static String value(
MultivaluedMap<String, String> form,
MultivaluedMap<String, String> query,
String saved,
String name,
int max) {
String submitted = form.getFirst(name);
if (submitted == null) submitted = query.getFirst(name);
return clean(submitted == null ? saved : submitted, max);
}
private static void save(
org.keycloak.sessions.AuthenticationSessionModel session, String name, String value) {
if (value == null) session.removeAuthNote(name);
else session.setAuthNote(name, value);
}
private static String clean(String value, int max) {
if (value == null || value.isBlank() || value.length() > max) return null;
for (int i = 0; i < value.length(); i++) {
if (Character.isISOControl(value.charAt(i))) return null;
}
return value;
}
}
@@ -0,0 +1,38 @@
package ru.han.chat.keycloak;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.utils.KeycloakModelUtils;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
final class OtpFlow {
private OtpFlow() {}
static OtpChallengeEntity start(
AuthenticationFlowContext context,
String phone,
SettingsBridge.Settings settings,
DeviceMetadata device) {
OtpStore.Reservation reservation = KeycloakModelUtils.runJobInTransactionWithResult(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).reserve(phone, settings, device));
OtpChallengeEntity challenge = reservation.challenge();
if (!Config.MOCK_ENABLED) {
String challengeId = challenge.id;
try {
String requestId = context.getHttpRequest().getHttpHeaders().getHeaderString("X-Request-ID");
String traceparent = context.getHttpRequest().getHttpHeaders().getHeaderString("traceparent");
SmsOrderClient.OrderResult order = new SmsOrderClient().order(
challengeId, phone, reservation.otp(), settings, requestId, traceparent);
challenge = KeycloakModelUtils.runJobInTransactionWithResult(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).activate(challengeId, order, device));
} catch (RuntimeException exception) {
KeycloakModelUtils.runJobInTransaction(
context.getSession().getKeycloakSessionFactory(),
session -> new OtpStore(session).orderFailed(challengeId, device));
throw exception;
}
}
return challenge;
}
}
@@ -4,6 +4,7 @@ import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import ru.han.chat.keycloak.entity.OtpChallengeEntity;
@@ -17,7 +18,7 @@ final class OtpStore {
this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
OtpChallengeEntity reserve(String phone, SettingsBridge.Limits limits) {
Reservation reserve(String phone, SettingsBridge.Settings settings, DeviceMetadata device) {
Instant now = Instant.now();
String phoneHmac = Crypto.hmac("phone", phone);
OtpSendCounterEntity counter = entityManager.find(
@@ -34,77 +35,169 @@ final class OtpStore {
counter.windowStart = now;
counter.sendCount = 0;
}
if (counter.sendCount >= limits.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, "limited", "daily_limit");
expireDue(now);
if (counter.sendCount >= settings.maxSendsPer24h()) {
event("otp_send", phoneHmac, null, null, "limited", "daily_limit", device);
throw new OtpLimitException("otp_send_limited");
}
if (counter.lastSentAt.plusSeconds(limits.minSecondsBetween()).isAfter(now)) {
event("otp_send", phoneHmac, null, "limited", "cooldown");
throw new OtpLimitException("otp_send_limited");
if (counter.lastSentAt.plusSeconds(settings.minSecondsBetween()).isAfter(now)) {
event("otp_send", phoneHmac, null, null, "limited", "cooldown", device);
throw new OtpLimitException("otp_send_cooldown");
}
counter.sendCount++;
counter.lastSentAt = now;
entityManager.createQuery("""
update OtpChallengeEntity c set c.consumedAt = :now, c.providerStatus = 'superseded'
where c.phoneHmac = :phone and c.consumedAt is null and c.expiresAt > :now
""").setParameter("now", now).setParameter("phone", phoneHmac).executeUpdate();
update OtpChallengeEntity c set c.challengeStatus = 'superseded'
where c.phoneHmac = :phone and c.challengeStatus in ('active', 'ordering')
""").setParameter("phone", phoneHmac).executeUpdate();
OtpChallengeEntity challenge = new OtpChallengeEntity();
challenge.id = Crypto.randomId();
String otp = Config.MOCK_ENABLED ? Config.MOCK_CODE : Crypto.randomNumericCode(settings.codeLength());
if (Config.MOCK_ENABLED && otp.length() != settings.codeLength()) {
throw new IllegalStateException("Mock OTP length must match the settings snapshot");
}
challenge.phoneHmac = phoneHmac;
challenge.destinationMasked = PhoneNormalizer.mask(phone);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, Config.MOCK_CODE);
challenge.otpHash = Crypto.hmac("otp:" + challenge.id, otp);
challenge.createdAt = now;
challenge.expiresAt = now.plus(Config.OTP_TTL);
challenge.expiresAt = now.plusSeconds(settings.ttlSeconds());
challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = limits.maxVerifyAttempts();
challenge.settingsVersion = limits.version();
challenge.providerId = "mock-" + Crypto.randomId();
challenge.providerStatus = "accepted";
challenge.maxVerifyAttempts = settings.maxVerifyAttempts();
challenge.settingsVersion = settings.version();
challenge.deliveryMode = Config.MOCK_ENABLED ? "mock" : "sms";
challenge.challengeStatus = Config.MOCK_ENABLED ? "active" : "ordering";
challenge.orderedAt = Config.MOCK_ENABLED ? now : null;
challenge.otpTtlSec = settings.ttlSeconds();
challenge.otpCodeLength = settings.codeLength();
entityManager.persist(challenge);
event("otp_send", phoneHmac, challenge.id, "success", "mock");
if (Config.MOCK_ENABLED) {
event("otp_send", phoneHmac, challenge.id, null, "success", "mock", device);
}
return new Reservation(challenge, otp);
}
OtpChallengeEntity activate(
String challengeId, SmsOrderClient.OrderResult order, DeviceMetadata device) {
OtpChallengeEntity challenge = locked(challengeId);
if (!"ordering".equals(challenge.challengeStatus)) return challenge;
challenge.smsMessageId = order.smsMessageId();
challenge.orderedAt = order.orderedAt();
challenge.expiresAt = order.orderedAt().plusSeconds(challenge.otpTtlSec);
challenge.challengeStatus = "active";
event("otp_send", challenge.phoneHmac, challenge.id, challenge.smsMessageId,
"success", "ordered", device);
return challenge;
}
boolean consume(String challengeId, String suppliedCode) {
void orderFailed(String challengeId, DeviceMetadata device) {
OtpChallengeEntity challenge = locked(challengeId);
if (!"ordering".equals(challenge.challengeStatus)) return;
challenge.challengeStatus = "order_failed";
event("otp_send", challenge.phoneHmac, challenge.id, null,
"failure", "order_failed", device);
}
boolean consume(String challengeId, String suppliedCode, DeviceMetadata device) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now();
if (challenge == null || challenge.consumedAt != null || !challenge.expiresAt.isAfter(now)) {
if (challenge != null) event("otp_verify", challenge.phoneHmac, challengeId, "failure", "expired_or_used");
if (challenge == null) return false;
if (!"active".equals(challenge.challengeStatus)) {
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"already_used", challenge.challengeStatus, device);
return false;
}
if (!challenge.expiresAt.isAfter(now)) {
challenge.challengeStatus = "expired";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"expired", "ttl", device);
return false;
}
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
event("otp_verify", challenge.phoneHmac, challengeId, "limited", "attempt_limit");
challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"limited", "attempt_limit", device);
return false;
}
challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
challenge.otpHash, Crypto.hmac("otp:" + challenge.id, suppliedCode));
if (!valid) {
event("otp_verify", challenge.phoneHmac, challengeId, "failure", "invalid");
boolean limited = challenge.verifyAttempts >= challenge.maxVerifyAttempts;
if (limited) challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device);
return false;
}
challenge.consumedAt = now;
challenge.providerStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, "success", "verified");
challenge.challengeStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"success", "verified", device);
return true;
}
private void event(String type, String phoneHmac, String challengeId, String outcome, String details) {
OtpChallengeEntity get(String challengeId) {
return entityManager.find(OtpChallengeEntity.class, challengeId);
}
void expireDue() {
expireDue(Instant.now());
}
private OtpChallengeEntity locked(String challengeId) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
if (challenge == null) throw new IllegalStateException("OTP challenge not found");
return challenge;
}
private void expireDue(Instant now) {
entityManager.createQuery("""
update OtpChallengeEntity c set c.challengeStatus = 'expired'
where c.challengeStatus = 'active' and c.expiresAt <= :now
""").setParameter("now", now).executeUpdate();
}
private void event(
String type,
String phoneHmac,
String challengeId,
UUID smsMessageId,
String outcome,
String details,
DeviceMetadata device) {
OtpSecurityEventEntity event = new OtpSecurityEventEntity();
event.id = Crypto.randomId();
event.occurredAt = Instant.now();
event.eventType = type;
event.phoneHmac = phoneHmac;
event.challengeId = challengeId;
event.smsMessageId = smsMessageId;
event.outcome = outcome;
event.details = details;
if (device != null) {
event.clientIp = device.clientIp();
event.userAgent = device.userAgent();
event.deviceId = device.deviceId();
event.fingerprint = device.fingerprint();
event.osName = device.osName();
event.osVersion = device.osVersion();
event.platform = device.platform();
event.appVersion = device.appVersion();
}
entityManager.persist(event);
}
record Reservation(OtpChallengeEntity challenge, String otp) {}
static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); }
boolean isCooldown() {
return "otp_send_cooldown".equals(getMessage());
}
}
}
@@ -12,40 +12,62 @@ public final class PhoneIdentityAuthenticator implements Authenticator {
static final String PHONE_NOTE = "han.phone";
static final String CHALLENGE_NOTE = "han.otp.challenge";
static final String MASKED_NOTE = "han.phone.masked";
static final String CODE_LENGTH_NOTE = "han.otp.code_length";
static final String EXPIRES_AT_NOTE = "han.otp.expires_at";
private final PhoneNormalizer normalizer = new PhoneNormalizer();
@Override
public void authenticate(AuthenticationFlowContext context) {
DeviceMetadata device = DeviceMetadata.capture(context);
if (context.getAuthenticationSession().getAuthNote(CHALLENGE_NOTE) != null) {
context.success();
return;
}
context.challenge(context.form().createForm("phone.ftl"));
context.challenge(phoneForm(context, null, device));
}
@Override
public void action(AuthenticationFlowContext context) {
String rawPhone = context.getHttpRequest().getDecodedFormParameters().getFirst("phone");
DeviceMetadata device = DeviceMetadata.capture(context);
try {
String phone = normalizer.normalize(rawPhone);
SettingsBridge.Limits limits = SettingsBridge.get();
var challenge = new OtpStore(context.getSession()).reserve(phone, limits);
SettingsBridge.Settings settings = SettingsBridge.get();
var challenge = OtpFlow.start(context, phone, settings, device);
context.getAuthenticationSession().setAuthNote(PHONE_NOTE, phone);
context.getAuthenticationSession().setAuthNote(CHALLENGE_NOTE, challenge.id);
context.getAuthenticationSession().setAuthNote(MASKED_NOTE, challenge.destinationMasked);
context.getAuthenticationSession().setAuthNote(
CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
context.getAuthenticationSession().setAuthNote(
EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
context.success();
} catch (IllegalArgumentException exception) {
Response response = context.form().setError("phoneInvalid").createForm("phone.ftl");
Response response = phoneForm(context, "phoneInvalid", device);
context.failureChallenge(AuthenticationFlowError.INVALID_USER, response);
} catch (OtpStore.OtpLimitException exception) {
Response response = context.form().setError("otpLimited").createForm("phone.ftl");
Response response = phoneForm(
context, exception.isCooldown() ? "otpCooldown" : "otpLimited", device);
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, response);
} catch (RuntimeException exception) {
Response response = context.form().setError("otpUnavailable").createForm("phone.ftl");
Response response = phoneForm(context, "otpUnavailable", device);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response);
}
}
private static Response phoneForm(
AuthenticationFlowContext context, String messageKey, DeviceMetadata device) {
var form = context.form()
.setAttribute("hanDeviceId", device.deviceId())
.setAttribute("hanFingerprint", device.fingerprint())
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.setAttribute("hanAppVersion", device.appVersion());
if (messageKey != null) form.setError(messageKey);
return form.createForm("phone.ftl");
}
@Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@@ -19,7 +19,7 @@ public final class PhoneOtpAuthenticator implements Authenticator {
}
String masked = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.MASKED_NOTE);
context.challenge(context.form().setAttribute("maskedPhone", masked).createForm("otp.ftl"));
context.challenge(otpForm(context, masked, null));
}
@Override
@@ -27,16 +27,37 @@ public final class PhoneOtpAuthenticator implements Authenticator {
String challengeId = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CHALLENGE_NOTE);
String phone = context.getAuthenticationSession().getAuthNote(PhoneIdentityAuthenticator.PHONE_NOTE);
String action = context.getHttpRequest().getDecodedFormParameters().getFirst("otp_action");
String code = context.getHttpRequest().getDecodedFormParameters().getFirst("otp");
if (challengeId == null || phone == null) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
if (!new OtpStore(context.getSession()).consume(challengeId, code)) {
Response response = context.form()
.setAttribute("maskedPhone", PhoneNormalizer.mask(phone))
.setError("otpInvalid")
.createForm("otp.ftl");
DeviceMetadata device = DeviceMetadata.capture(context);
if ("resend".equals(action)) {
try {
var challenge = OtpFlow.start(context, phone, SettingsBridge.get(), device);
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.CHALLENGE_NOTE, challenge.id);
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.CODE_LENGTH_NOTE, Integer.toString(challenge.otpCodeLength));
context.getAuthenticationSession().setAuthNote(
PhoneIdentityAuthenticator.EXPIRES_AT_NOTE, Long.toString(challenge.expiresAt.toEpochMilli()));
context.challenge(otpForm(context, challenge.destinationMasked, null));
} catch (OtpStore.OtpLimitException exception) {
context.failureChallenge(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
otpForm(
context,
PhoneNormalizer.mask(phone),
exception.isCooldown() ? "otpCooldown" : "otpLimited"));
} catch (RuntimeException exception) {
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
otpForm(context, PhoneNormalizer.mask(phone), "otpUnavailable"));
}
return;
}
if (!new OtpStore(context.getSession()).consume(challengeId, code, device)) {
Response response = otpForm(context, PhoneNormalizer.mask(phone), "otpInvalid");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
@@ -62,6 +83,26 @@ public final class PhoneOtpAuthenticator implements Authenticator {
context.success();
}
private static Response otpForm(AuthenticationFlowContext context, String masked, String messageKey) {
DeviceMetadata device = DeviceMetadata.capture(context);
String codeLength = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.CODE_LENGTH_NOTE);
String expiresAt = context.getAuthenticationSession()
.getAuthNote(PhoneIdentityAuthenticator.EXPIRES_AT_NOTE);
var form = context.form()
.setAttribute("maskedPhone", masked)
.setAttribute("otpCodeLength", codeLength == null ? 6 : Integer.parseInt(codeLength))
.setAttribute("otpExpiresAt", expiresAt == null ? 0 : Long.parseLong(expiresAt))
.setAttribute("hanDeviceId", device.deviceId())
.setAttribute("hanFingerprint", device.fingerprint())
.setAttribute("hanPlatform", device.platform())
.setAttribute("hanOsName", device.osName())
.setAttribute("hanOsVersion", device.osVersion())
.setAttribute("hanAppVersion", device.appVersion());
if (messageKey != null) form.setError(messageKey);
return form.createForm("otp.ftl");
}
@Override public boolean requiresUser() { return false; }
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
@@ -7,7 +7,9 @@ import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.timer.TimerProvider;
public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "han-phone-otp";
@@ -25,11 +27,16 @@ public final class PhoneOtpAuthenticatorFactory implements AuthenticatorFactory
@Override public boolean isUserSetupAllowed() { return false; }
@Override public String getHelpText() { return "Verifies and atomically consumes a durable phone OTP challenge."; }
@Override public List<ProviderConfigProperty> getConfigProperties() { return List.of(); }
@Override public void init(Config.Scope config) {
if (!ru.han.chat.keycloak.Config.MOCK_ENABLED) {
throw new IllegalStateException("OTP delivery provider is not configured");
@Override public void init(Config.Scope config) { ru.han.chat.keycloak.Config.validate(); }
@Override
public void postInit(KeycloakSessionFactory factory) {
try (KeycloakSession session = factory.create()) {
session.getProvider(TimerProvider.class).schedule(
() -> KeycloakModelUtils.runJobInTransaction(
factory, jobSession -> new OtpStore(jobSession).expireDue()),
60_000L,
"han-otp-expiry");
}
}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
}
@@ -17,18 +17,25 @@ final class SettingsBridge {
.connectTimeout(Duration.ofSeconds(2)).build();
private static volatile Cached cached;
record Limits(int maxSendsPer24h, int minSecondsBetween, int maxVerifyAttempts, String version) {}
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {}
record Settings(
int maxSendsPer24h,
int minSecondsBetween,
int maxVerifyAttempts,
int codeLength,
int ttlSeconds,
int smsOrderTimeoutMs,
String version) {}
private record Cached(Settings settings, Instant fetchedAt, Instant refreshAfter, String etag) {}
private SettingsBridge() {}
static Limits get() {
static Settings get() {
Cached local = cached;
Instant now = Instant.now();
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
synchronized (SettingsBridge.class) {
local = cached;
if (local != null && now.isBefore(local.refreshAfter)) return local.limits;
if (local != null && now.isBefore(local.refreshAfter)) return local.settings;
try {
HttpRequest.Builder builder = HttpRequest.newBuilder(Config.SETTINGS_URL)
.timeout(Duration.ofSeconds(3))
@@ -38,27 +45,35 @@ final class SettingsBridge {
if (local != null && local.etag != null) builder.header("If-None-Match", local.etag);
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 304 && local != null) {
cached = new Cached(local.limits, now, now.plusSeconds(60), local.etag);
return local.limits;
cached = new Cached(local.settings, now, now.plusSeconds(60), local.etag);
return local.settings;
}
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
int max = integer(response.body(), "max_send_attempts_per_24h");
int minimum = integer(response.body(), "min_seconds_between_attempts");
int maxVerify = integer(response.body(), "max_verify_attempts");
int codeLength = integer(response.body(), "code_length");
int otpTtl = integer(response.body(), "ttl_seconds");
int orderTimeout = integer(response.body(), "sms_order_timeout_ms");
int ttl = integer(response.body(), "cache_ttl_seconds");
String version = string(response.body(), "version");
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400
|| maxVerify < 1 || maxVerify > 10 || ttl < 1 || ttl > 3600) {
|| maxVerify < 1 || maxVerify > 10
|| codeLength < 4 || codeLength > 10
|| otpTtl < 60 || otpTtl > 900 || otpTtl % 60 != 0
|| orderTimeout < 100 || orderTimeout > 30000
|| ttl < 1 || ttl > 3600) {
throw new IllegalStateException("settings_invalid_range");
}
Limits limits = new Limits(max, minimum, maxVerify, version);
cached = new Cached(limits, now, now.plusSeconds(ttl),
Settings settings = new Settings(
max, minimum, maxVerify, codeLength, otpTtl, orderTimeout, version);
cached = new Cached(settings, now, now.plusSeconds(ttl),
response.headers().firstValue("ETag").orElse(null));
return limits;
return settings;
} catch (Exception exception) {
if (local != null && now.isBefore(local.fetchedAt.plus(Config.SETTINGS_MAX_STALE))) {
LOG.warn("OTP settings refresh failed; using bounded last-known-good");
return local.limits;
return local.settings;
}
throw new IllegalStateException("OTP settings unavailable; send denied", exception);
}
@@ -0,0 +1,109 @@
package ru.han.chat.keycloak;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class SmsOrderClient {
private static final Pattern MESSAGE_ID =
Pattern.compile("\"sms_message_id\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern ORDERED_AT =
Pattern.compile("\"ordered_at\"\\s*:\\s*\"([^\"]+)\"");
private final HttpClient client;
private final URI serviceUrl;
private final String serviceToken;
SmsOrderClient() {
this(HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2))
.build(),
Config.SMS_SERVICE_URL, Config.SMS_SERVICE_TOKEN);
}
SmsOrderClient(HttpClient client, URI serviceUrl, String serviceToken) {
this.client = client;
this.serviceUrl = serviceUrl;
this.serviceToken = serviceToken;
}
OrderResult order(
String challengeId,
String phone,
String otp,
SettingsBridge.Settings settings,
String requestId,
String traceparent) {
String body = requestBody(challengeId, phone, otp, settings);
HttpRequest.Builder builder = HttpRequest.newBuilder(serviceUrl)
.timeout(Duration.ofMillis(settings.smsOrderTimeoutMs()))
.header("Authorization", "Bearer " + serviceToken)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Request-ID", requestId == null ? challengeId : requestId)
.POST(HttpRequest.BodyPublishers.ofString(body));
if (traceparent != null && !traceparent.isBlank()) builder.header("traceparent", traceparent);
HttpRequest request = builder.build();
for (int attempt = 0; attempt < 2; attempt++) {
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 202) {
return parse(response.body());
}
if (response.statusCode() < 500 || attempt == 1) {
throw new SmsOrderException("sms_order_http_" + response.statusCode());
}
} catch (java.net.http.HttpTimeoutException exception) {
if (attempt == 1) throw new SmsOrderException("sms_order_timeout", exception);
} catch (java.io.IOException exception) {
if (attempt == 1) throw new SmsOrderException("sms_order_io", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new SmsOrderException("sms_order_interrupted", exception);
}
}
throw new SmsOrderException("sms_order_unavailable");
}
static OrderResult parse(String json) {
Matcher idMatcher = MESSAGE_ID.matcher(json);
Matcher orderedMatcher = ORDERED_AT.matcher(json);
if (!idMatcher.find() || !orderedMatcher.find()) {
throw new SmsOrderException("sms_order_invalid_response");
}
try {
return new OrderResult(UUID.fromString(idMatcher.group(1)), Instant.parse(orderedMatcher.group(1)));
} catch (RuntimeException exception) {
throw new SmsOrderException("sms_order_invalid_response", exception);
}
}
static String requestBody(
String challengeId, String phone, String otp, SettingsBridge.Settings settings) {
return ("{\"idempotency_key\":\"keycloak:challenge:%s\","
+ "\"template_code\":\"auth_otp\",\"locale\":\"ru\","
+ "\"phone_e164\":\"%s\",\"substitutions\":{\"code\":\"%s\",\"ttl_min\":\"%d\"},"
+ "\"customer_ref\":\"%s\",\"message_ttl_sec\":%d}").formatted(
escape(challengeId), escape(phone), escape(otp), settings.ttlSeconds() / 60,
escape(challengeId), settings.ttlSeconds());
}
private static String escape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
record OrderResult(UUID smsMessageId, Instant orderedAt) {}
static final class SmsOrderException extends RuntimeException {
SmsOrderException(String message) { super(message); }
SmsOrderException(String message, Throwable cause) { super(message, cause); }
}
}
@@ -6,6 +6,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "han_otp_challenge")
@@ -20,7 +21,13 @@ public class OtpChallengeEntity {
@Column(name = "verify_attempts", nullable = false) public int verifyAttempts;
@Column(name = "max_verify_attempts", nullable = false) public int maxVerifyAttempts;
@Column(name = "settings_version", nullable = false, length = 128) public String settingsVersion;
@Column(name = "provider_id", nullable = false, length = 128) public String providerId;
@Column(name = "provider_status", nullable = false, length = 32) public String providerStatus;
@Column(name = "provider_id", length = 128) public String providerId;
@Column(name = "provider_status", length = 32) public String providerStatus;
@Column(name = "sms_message_id") public UUID smsMessageId;
@Column(name = "delivery_mode", nullable = false, length = 16) public String deliveryMode;
@Column(name = "challenge_status", nullable = false, length = 16) public String challengeStatus;
@Column(name = "ordered_at") public Instant orderedAt;
@Column(name = "otp_ttl_sec", nullable = false) public int otpTtlSec;
@Column(name = "otp_code_length", nullable = false) public int otpCodeLength;
@Version public long version;
}
@@ -5,6 +5,8 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.ColumnTransformer;
@Entity
@Table(name = "han_otp_security_event")
@@ -16,4 +18,15 @@ public class OtpSecurityEventEntity {
@Column(name = "challenge_id", length = 32) public String challengeId;
@Column(name = "outcome", nullable = false, length = 32) public String outcome;
@Column(name = "details", length = 256) public String details;
@Column(name = "sms_message_id") public UUID smsMessageId;
@Column(name = "client_ip", columnDefinition = "inet")
@ColumnTransformer(write = "cast(? as inet)")
public String clientIp;
@Column(name = "user_agent") public String userAgent;
@Column(name = "device_id", length = 256) public String deviceId;
@Column(name = "fingerprint", length = 256) public String fingerprint;
@Column(name = "os_name", length = 64) public String osName;
@Column(name = "os_version", length = 64) public String osVersion;
@Column(name = "platform", length = 16) public String platform;
@Column(name = "app_version", length = 64) public String appVersion;
}
@@ -50,4 +50,65 @@
<column name="occurred_at"/>
</createIndex>
</changeSet>
<changeSet id="han-otp-1.1.0-sms-lifecycle" author="han-chat">
<addColumn tableName="han_otp_challenge">
<column name="sms_message_id" type="uuid"/>
<column name="delivery_mode" type="varchar(16)"/>
<column name="challenge_status" type="varchar(16)"/>
<column name="ordered_at" type="timestamp with time zone"/>
<column name="otp_ttl_sec" type="int"/>
<column name="otp_code_length" type="smallint"/>
</addColumn>
<sql>
UPDATE han_otp_challenge
SET delivery_mode = 'mock',
challenge_status = CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END,
ordered_at = created_at,
otp_ttl_sec = 60,
otp_code_length = 6;
ALTER TABLE han_otp_challenge ALTER COLUMN delivery_mode SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN challenge_status SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN otp_ttl_sec SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN otp_code_length SET NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN provider_id DROP NOT NULL;
ALTER TABLE han_otp_challenge ALTER COLUMN provider_status DROP NOT NULL;
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_delivery_mode
CHECK (delivery_mode IN ('mock', 'sms'));
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_challenge_status
CHECK (challenge_status IN
('ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'));
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_ttl
CHECK (otp_ttl_sec BETWEEN 60 AND 900 AND otp_ttl_sec % 60 = 0);
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_code_length
CHECK (otp_code_length BETWEEN 4 AND 10);
ALTER TABLE han_otp_challenge ADD CONSTRAINT ck_han_otp_active_sms
CHECK (challenge_status != 'active' OR delivery_mode != 'sms' OR sms_message_id IS NOT NULL);
</sql>
<createIndex tableName="han_otp_challenge" indexName="ix_han_otp_challenge_status_expiry">
<column name="challenge_status"/><column name="expires_at"/>
</createIndex>
<sql>
CREATE INDEX ix_han_otp_challenge_sms_message
ON han_otp_challenge (sms_message_id) WHERE sms_message_id IS NOT NULL;
</sql>
<addColumn tableName="han_otp_security_event">
<column name="sms_message_id" type="uuid"/>
<column name="client_ip" type="inet"/>
<column name="user_agent" type="text"/>
<column name="device_id" type="varchar(256)"/>
<column name="fingerprint" type="varchar(256)"/>
<column name="os_name" type="varchar(64)"/>
<column name="os_version" type="varchar(64)"/>
<column name="platform" type="varchar(16)"/>
<column name="app_version" type="varchar(64)"/>
</addColumn>
<sql>
ALTER TABLE han_otp_security_event ADD CONSTRAINT ck_han_otp_event_platform
CHECK (platform IS NULL OR platform IN ('web', 'ios', 'android'));
CREATE INDEX ix_han_otp_event_sms_message
ON han_otp_security_event (sms_message_id) WHERE sms_message_id IS NOT NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -2,6 +2,7 @@ package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
@@ -19,4 +20,12 @@ class CryptoTest {
assertTrue(Crypto.constantTimeEquals("same-value", "same-value"));
assertFalse(Crypto.constantTimeEquals("same-value", "same-valuf"));
}
@Test
void randomOtpIsNumericAndUsesRequestedLength() {
String code = Crypto.randomNumericCode(8);
assertTrue(code.matches("\\d{8}"));
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(3));
assertThrows(IllegalArgumentException.class, () -> Crypto.randomNumericCode(11));
}
}
@@ -0,0 +1,41 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class SmsLifecycleContractTest {
@Test
void migrationContainsLifecycleSnapshotAndAuditColumns() throws Exception {
String migration = Files.readString(
Path.of("src/main/resources/META-INF/han-otp-changelog.xml"));
for (String required : new String[] {
"sms_message_id", "delivery_mode", "challenge_status", "ordered_at",
"otp_ttl_sec", "otp_code_length", "client_ip", "user_agent",
"device_id", "fingerprint", "os_name", "os_version", "platform", "app_version",
"'ordering', 'active', 'consumed', 'superseded', 'expired', 'limited', 'order_failed'"
}) {
assertTrue(migration.contains(required), "Missing migration contract: " + required);
}
assertTrue(migration.contains("delivery_mode = 'mock'"));
assertTrue(migration.contains(
"CASE WHEN consumed_at IS NOT NULL THEN 'consumed' ELSE 'expired' END"));
}
@Test
void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception {
String template = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
assertTrue(template.contains("otpCodeLength"));
assertTrue(template.contains("otpExpiresAt"));
assertTrue(template.contains("(otpExpiresAt!0)?c"));
assertTrue(template.contains("name=\"otp_action\" value=\"resend\""));
assertTrue(template.contains("han_device_id"));
assertTrue(script.contains("han_device_id"));
assertTrue(script.contains("expiresAt - Date.now()"));
assertTrue(script.contains("Number.isFinite(expiresAt)"));
}
}
@@ -0,0 +1,75 @@
package ru.han.chat.keycloak;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
class SmsOrderClientTest {
private static final SettingsBridge.Settings SETTINGS =
new SettingsBridge.Settings(3, 30, 5, 6, 120, 3000, "v1");
@Test
void requestUsesStableIdempotencyAndSnapshot() {
String body = SmsOrderClient.requestBody(
"challenge-1", "+79001234567", "482193", SETTINGS);
assertTrue(body.contains("\"idempotency_key\":\"keycloak:challenge:challenge-1\""));
assertTrue(body.contains("\"template_code\":\"auth_otp\""));
assertTrue(body.contains("\"code\":\"482193\""));
assertTrue(body.contains("\"ttl_min\":\"2\""));
assertTrue(body.contains("\"message_ttl_sec\":120"));
assertFalse(body.contains("Authorization"));
}
@Test
void parsesOnlyUuidAndIsoOrderedTimestamp() {
UUID id = UUID.randomUUID();
SmsOrderClient.OrderResult result = SmsOrderClient.parse(
"{\"sms_message_id\":\"" + id + "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}");
assertEquals(id, result.smsMessageId());
assertEquals(Instant.parse("2026-07-22T13:00:00Z"), result.orderedAt());
assertThrows(SmsOrderClient.SmsOrderException.class,
() -> SmsOrderClient.parse("{\"sms_message_id\":\"not-a-uuid\"}"));
}
@Test
void retriesServerFailureWithSameOrder() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
AtomicInteger calls = new AtomicInteger();
UUID messageId = UUID.randomUUID();
server.createContext("/internal/sms/v1/send", exchange -> {
assertEquals("Bearer test-token", exchange.getRequestHeaders().getFirst("Authorization"));
int call = calls.incrementAndGet();
byte[] response = (call == 1 ? "{}" :
"{\"sms_message_id\":\"" + messageId
+ "\",\"ordered_at\":\"2026-07-22T13:00:00Z\"}")
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(call == 1 ? 503 : 202, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start();
try {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort()
+ "/internal/sms/v1/send");
SmsOrderClient client = new SmsOrderClient(HttpClient.newHttpClient(), uri, "test-token");
SmsOrderClient.OrderResult result =
client.order("challenge-1", "+79001234567", "482193", SETTINGS, "request-1", null);
assertEquals(messageId, result.smsMessageId());
assertEquals(2, calls.get());
} finally {
server.stop(0);
}
}
}
@@ -20,5 +20,6 @@ verifyOtp=Подтвердить
mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован.
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
@@ -16,8 +16,15 @@
<form id="kc-otp-form" action="${url.loginAction}" method="post">
<input id="otp" name="otp" type="hidden" value=""/>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>">
<#list 0..5 as index>
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>"
style="grid-template-columns: repeat(${otpCodeLength!6}, minmax(0, 1fr));">
<#list 0..((otpCodeLength!6) - 1) as index>
<input class="han-otp-digit" type="text" inputmode="numeric" maxlength="1"
aria-label="${msg("otpDigit", index + 1)}"
<#if index == 0>autocomplete="one-time-code" autofocus</#if>
@@ -33,8 +40,10 @@
</#if>
<div class="han-resend">
<p id="han-resend-countdown">${msg("otpResendCountdown")} <strong>0:59</strong></p>
<button id="han-resend-button" type="button" hidden onclick="window.history.back()">
<p id="han-resend-countdown" data-expires-at="${(otpExpiresAt!0)?c}">
${msg("otpResendCountdown")} <strong>—</strong>
</p>
<button id="han-resend-button" type="submit" name="otp_action" value="resend" hidden>
<span aria-hidden="true">↻</span>
<span>${msg("otpResend")}</span>
</button>
@@ -45,6 +54,6 @@
</button>
</form>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
</#if>
</@layout.registrationLayout>
@@ -17,6 +17,12 @@
</div>
<form id="kc-phone-form" action="${url.loginAction}" method="post">
<input type="hidden" name="han_device_id" class="han-device-id" value="${hanDeviceId!""}"/>
<input type="hidden" name="han_fingerprint" class="han-fingerprint" value="${hanFingerprint!""}"/>
<input type="hidden" name="han_platform" value="${hanPlatform!"web"}"/>
<input type="hidden" name="han_os_name" class="han-os-name" value="${hanOsName!""}"/>
<input type="hidden" name="han_os_version" class="han-os-version" value="${hanOsVersion!""}"/>
<input type="hidden" name="han_app_version" class="han-app-version" value="${hanAppVersion!""}"/>
<div class="han-field">
<label for="phone">${msg("phoneLabel")}</label>
<input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel"
@@ -46,6 +52,6 @@
<span>${msg("privacyPolicy")}</span>
</p>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=4"></script>
</#if>
</@layout.registrationLayout>
@@ -1,4 +1,30 @@
(function () {
function randomId() {
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (char) {
var value = Math.random() * 16 | 0;
return (char === "x" ? value : (value & 3 | 8)).toString(16);
});
}
function initDeviceMetadata() {
var deviceId = window.localStorage.getItem("han_device_id") || randomId();
var fingerprint = window.localStorage.getItem("han_fingerprint") || randomId();
window.localStorage.setItem("han_device_id", deviceId);
window.localStorage.setItem("han_fingerprint", fingerprint);
document.querySelectorAll(".han-device-id").forEach(function (input) {
if (!input.value) input.value = deviceId;
});
document.querySelectorAll(".han-fingerprint").forEach(function (input) {
if (!input.value) input.value = fingerprint;
});
document.querySelectorAll(".han-os-name").forEach(function (input) {
if (!input.value) {
input.value = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
}
});
}
function initPhoneForm() {
var input = document.getElementById("phone");
var submit = document.getElementById("han-phone-submit");
@@ -81,23 +107,31 @@
syncOtp();
var seconds = 59;
var countdown = document.getElementById("han-resend-countdown");
var countdownValue = countdown && countdown.querySelector("strong");
var resend = document.getElementById("han-resend-button");
if (!countdown || !countdownValue || !resend) return;
var timer = window.setInterval(function () {
seconds -= 1;
countdownValue.textContent = "0:" + String(seconds).padStart(2, "0");
var expiresAt = Number(countdown.getAttribute("data-expires-at"));
if (!Number.isFinite(expiresAt)) expiresAt = Date.now();
var timer;
function updateCountdown() {
var seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
countdownValue.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
if (seconds <= 0) {
window.clearInterval(timer);
if (timer) window.clearInterval(timer);
countdown.hidden = true;
resend.hidden = false;
}
}, 1000);
}
updateCountdown();
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
resend.addEventListener("click", function () {
window.setTimeout(function () { resend.disabled = true; }, 0);
});
}
initDeviceMetadata();
initPhoneForm();
initOtpForm();
})();
+3 -1
View File
@@ -12,11 +12,12 @@ services:
NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m}
NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m}
NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-10r/m}
NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-60r/m}
NGINX_RATE_LIMIT_PUBLIC: ${NGINX_RATE_LIMIT_PUBLIC:-60r/m}
NGINX_RATE_LIMIT_POLLING: ${NGINX_RATE_LIMIT_POLLING:-60r/m}
NGINX_RATE_LIMIT_DOWNLOADS: ${NGINX_RATE_LIMIT_DOWNLOADS:-30r/m}
NGINX_RATE_LIMIT_BITRIX: ${NGINX_RATE_LIMIT_BITRIX:-120r/m}
NGINX_RATE_LIMIT_SMS_CALLBACK: ${NGINX_RATE_LIMIT_SMS_CALLBACK:-120r/m}
NGINX_RATE_LIMIT_WS: ${NGINX_RATE_LIMIT_WS:-30r/m}
NGINX_MESSAGE_READ_TIMEOUT_SEC: ${NGINX_MESSAGE_READ_TIMEOUT_SEC:-330}
FRONTEND_DEV_PROXY_ENABLED: ${FRONTEND_DEV_PROXY_ENABLED:-false}
@@ -37,6 +38,7 @@ services:
frontend-static: {condition: service_completed_successfully}
api-backend: {condition: service_healthy}
keycloak: {condition: service_healthy}
sms-service: {condition: service_healthy}
bitrix-local-app: {condition: service_healthy}
bitrix-sync: {condition: service_healthy}
healthcheck:
@@ -50,6 +50,7 @@ http {
limit_req_zone $polling_key zone=polling:10m rate=${NGINX_RATE_LIMIT_POLLING};
limit_req_zone $binary_remote_addr zone=downloads:10m rate=${NGINX_RATE_LIMIT_DOWNLOADS};
limit_req_zone $binary_remote_addr zone=bitrix_callbacks:10m rate=${NGINX_RATE_LIMIT_BITRIX};
limit_req_zone $binary_remote_addr zone=sms_callbacks:10m rate=${NGINX_RATE_LIMIT_SMS_CALLBACK};
limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=${NGINX_RATE_LIMIT_WS};
limit_conn_zone $binary_remote_addr zone=connections:10m;
@@ -61,6 +62,7 @@ http {
upstream api_backend { server api-backend:8000; keepalive 32; }
upstream keycloak_upstream { server keycloak:8080; keepalive 16; }
upstream sms_service_upstream { server sms-service:8080; keepalive 8; }
upstream bitrix_local { server bitrix-local-app:8080; keepalive 16; }
upstream bitrix_sync_upstream { server bitrix-sync:8080; keepalive 8; }
upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; }
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC"
required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_SMS_CALLBACK NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC"
for name in $required; do
eval "value=\${$name:-}"
if [ -z "$value" ]; then
@@ -24,7 +24,7 @@ if [ "${FRONTEND_DEV_PROXY_ENABLED:-false}" = "true" ] \
fi
umask 027
common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}'
common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_SMS_CALLBACK} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}'
site_vars='${PUBLIC_HOST} ${NGINX_TLS_CERTIFICATE} ${NGINX_TLS_CERTIFICATE_KEY} ${NGINX_MESSAGE_READ_TIMEOUT_SEC} ${BITRIX_FRAME_ANCESTORS}'
security_vars='${NGINX_HSTS_MAX_AGE} ${S3_CONNECT_SRC}'
@@ -120,6 +120,20 @@ server {
proxy_pass http://keycloak_upstream;
}
location = /callbacks/idgtl/sms {
if ($request_method != POST) { return 405; }
allow 185.203.96.7;
deny all;
limit_req zone=sms_callbacks burst=30 nodelay;
client_max_body_size 256k;
proxy_buffering off;
proxy_cache off;
include /etc/nginx/snippets/proxy-common.conf;
proxy_read_timeout 15s;
proxy_pass http://sms_service_upstream;
}
location ^~ /callbacks/idgtl/ { return 404; }
location = /bitrix/handler {
limit_req zone=bitrix_callbacks burst=60 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
+36 -5
View File
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
REQUIRED = {
"APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "DATABASE_URL",
"BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
"MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST",
"MESSAGE_SAFETY_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST",
"PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL",
"KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL",
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL",
@@ -18,6 +18,9 @@ REQUIRED = {
"BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN",
"BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY",
"KEYCLOAK_SMS_SERVICE_URL", "KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN",
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
"IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD",
"KEYCLOAK_ADMIN", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET",
"BITRIX_TOKEN_ENCRYPTION_KEY",
"SELECTEL_S3_ENDPOINT_URL",
@@ -29,7 +32,10 @@ REQUIRED = {
SECRET_KEYS = {
key for key in REQUIRED
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY"))
} | {"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN"}
} | {
"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN",
"IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD",
}
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)
@@ -64,16 +70,25 @@ def main() -> int:
value = env.get(key, "")
if value and (len(value) < 16 or PLACEHOLDER.search(value)):
errors.append(f"{key}: секрет должен быть непустым, уникальным и длиной >=16")
for key in ("SMS_SERVICE_TOKEN", "KEYCLOAK_SMS_SERVICE_TOKEN"):
if env.get(key) and len(env[key]) < 32:
errors.append(f"{key}: service token должен иметь длину >=32")
production = env.get("APP_ENV") in {"production-like", "production"}
if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false":
errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false")
if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true":
errors.append("NGINX_TLS_ENABLED: production-like/production требует true")
for key in ("PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL"):
for key in (
"PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
):
if env.get(key) and urlparse(env[key]).scheme != "https":
errors.append(f"{key}: публичный URL должен использовать https")
for key in ("KEYCLOAK_INTERNAL_URL", "MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL"):
for key in (
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_SMS_SERVICE_URL",
"MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL",
):
parsed = urlparse(env.get(key, ""))
if parsed.scheme != "http" or "." in (parsed.hostname or ""):
errors.append(f"{key}: ожидается http URL с Docker DNS service name")
@@ -96,14 +111,20 @@ def main() -> int:
errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}")
for key in (
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
"MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL",
"MESSAGE_SAFETY_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_URL",
):
value = env.get(key, "")
if "sslmode=verify-full" not in value or "sslrootcert=" not in value:
errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert")
if re.search(r"(?:[?&](?:options|currentSchema)=)", value, re.I):
errors.append(
f"{key}: options/currentSchema запрещены через PgBouncer; "
"используйте database-level search_path роли"
)
pairs = (
("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"),
("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"),
("KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN"),
)
for left, right in pairs:
if env.get(left) != env.get(right):
@@ -121,6 +142,16 @@ def main() -> int:
if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true":
if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true":
errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP")
real_sms = env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "false"
if production and real_sms and (
env.get("IDGTL_SMS_BASE_URL", "").rstrip("/") != "https://direct.i-dgtl.ru"
):
errors.append("IDGTL_SMS_BASE_URL: production contract требует https://direct.i-dgtl.ru")
expected_callback = f"https://{env.get('PUBLIC_HOST', '')}/callbacks/idgtl/sms"
if production and env.get("IDGTL_SMS_CALLBACK_PUBLIC_URL") != expected_callback:
errors.append(
"IDGTL_SMS_CALLBACK_PUBLIC_URL должен совпадать с публичным host и callback path"
)
if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}:
errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint")
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim AS builder
WORKDIR /build
RUN pip install --no-cache-dir --upgrade pip build
COPY pyproject.toml ./
COPY app ./app
RUN python -m build --wheel
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
WORKDIR /app
COPY --from=builder /build/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
COPY alembic.ini ./
COPY migrations ./migrations
USER 10001:10001
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-proxy-headers"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_table_schema = sms
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -0,0 +1 @@
"""HAN SMS service."""
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
import asyncpg
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SCHEMA = "sms"
class Channel(StrEnum):
SMS = "SMS"
class SendStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
FAILED = "failed"
UNCERTAIN = "uncertain"
SKIPPED = "skipped"
class DeliveryStatus(StrEnum):
UNKNOWN = "unknown"
SENT = "sent"
DELIVERED = "delivered"
UNDELIVERED = "undelivered"
UNSENT = "unsent"
class Base(DeclarativeBase):
pass
class SmsTemplate(Base):
__tablename__ = "sms_template"
__table_args__ = (
UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
Index(
"uq_template_active",
"code",
"channel",
"locale",
unique=True,
postgresql_where=text("is_active"),
),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
code: Mapped[str] = mapped_column(String(64), nullable=False)
channel: Mapped[Channel] = mapped_column(
Enum(
Channel,
name="sms_channel",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
)
)
locale: Mapped[str] = mapped_column(String(16), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
body_template: Mapped[str] = mapped_column(Text, nullable=False)
placeholders: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
sender_name: Mapped[str | None] = mapped_column(String(64))
max_parts: Mapped[int] = mapped_column(SmallInteger, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
created_by: Mapped[str] = mapped_column(String(64), nullable=False)
class SmsSetting(Base):
__tablename__ = "sms_setting"
__table_args__ = {"schema": SCHEMA}
setting_key: Mapped[str] = mapped_column(String(128), primary_key=True)
setting_value: Mapped[object] = mapped_column(JSONB, nullable=False)
value_type: Mapped[str] = mapped_column(String(16), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class SmsOutboundMessage(Base):
__tablename__ = "sms_outbound_message"
__table_args__ = (
UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
Index(
"uq_outbound_provider_message",
"provider",
"provider_message_id",
unique=True,
postgresql_where=text("provider_message_id IS NOT NULL"),
),
Index("ix_outbound_phone_created", "phone_e164", text("created_at DESC")),
Index(
"ix_outbound_requester_process_created",
"requester_service",
"process",
text("created_at DESC"),
),
Index("ix_outbound_customer_ref", "customer_ref"),
Index("ix_outbound_send_created", "send_status", "created_at"),
Index("ix_outbound_delivery_updated", "delivery_status", "updated_at"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
requester_service: Mapped[str] = mapped_column(String(64), nullable=False)
process: Mapped[str] = mapped_column(String(64), nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
phone_e164: Mapped[str] = mapped_column(String(16), nullable=False)
phone_digits: Mapped[str] = mapped_column(String(15), nullable=False)
phone_masked: Mapped[str] = mapped_column(String(32), nullable=False)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.sms_template.id"), nullable=False
)
template_code: Mapped[str] = mapped_column(String(64), nullable=False)
body_rendered: Mapped[str] = mapped_column(Text, nullable=False)
substitutions: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
send_status: Mapped[SendStatus] = mapped_column(
Enum(
SendStatus,
name="sms_send_status",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
delivery_status: Mapped[DeliveryStatus] = mapped_column(
Enum(
DeliveryStatus,
name="sms_delivery_status",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
provider_message_id: Mapped[str | None] = mapped_column(String(128))
provider_external_id: Mapped[str | None] = mapped_column(String(128))
customer_ref: Mapped[str | None] = mapped_column(String(128))
idempotency_key: Mapped[str] = mapped_column(String(192), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
request_id: Mapped[str | None] = mapped_column(String(128))
traceparent: Mapped[str | None] = mapped_column(String(55))
provider_http_status: Mapped[int | None] = mapped_column(Integer)
provider_error_code: Mapped[str | None] = mapped_column(String(64))
provider_error_message: Mapped[str | None] = mapped_column(String(256))
sender_name: Mapped[str] = mapped_column(String(64), nullable=False)
message_ttl_sec: Mapped[int | None] = mapped_column(Integer)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
worker_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
parts: Mapped[int | None] = mapped_column(Integer)
price: Mapped[Decimal | None] = mapped_column(Numeric(14, 4))
currency: Mapped[str | None] = mapped_column(String(3))
callback_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SmsCallbackEvent(Base):
__tablename__ = "sms_callback_event"
__table_args__ = (
UniqueConstraint(
"message_uuid",
"callback_event",
"status",
"status_time",
name="uq_callback_event",
),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
message_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
callback_event: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
status_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def asyncpg_dsn(url: str) -> str:
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
def create_postgres_engine(url: str) -> AsyncEngine:
dsn = asyncpg_dsn(url)
async def connect() -> asyncpg.Connection:
return await asyncpg.connect(dsn=dsn)
return create_async_engine(
"postgresql+asyncpg://", async_creator=connect, pool_pre_ping=True
)
class Database:
def __init__(self, url: str) -> None:
self.engine = create_postgres_engine(url)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.sessions() as session:
yield session
async def close(self) -> None:
await self.engine.dispose()
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import hashlib
import hmac
import json
import re
import string
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import phonenumbers
from app.db import DeliveryStatus, SendStatus
GSM_BASIC = (
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ "
"!\"%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
GSM_EXTENDED = "^{}\\[~]|€"
PHONE_RE = re.compile(r"^\+[1-9]\d{7,14}$")
class DomainError(Exception):
def __init__(
self, code: str, status: int, message: str, details: dict[str, Any] | None = None
) -> None:
self.code = code
self.status = status
self.message = message
self.details = details or {}
super().__init__(message)
def normalize_phone(value: str) -> tuple[str, str, str]:
if not PHONE_RE.fullmatch(value):
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
try:
parsed = phonenumbers.parse(value, None)
except phonenumbers.NumberParseException:
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164") from None
if not phonenumbers.is_valid_number(parsed):
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
if normalized != value:
raise DomainError("sms_request_invalid", 422, "phone_e164 must be canonical E.164")
digits = normalized[1:]
masked = f"+{digits[:1]}{'*' * max(0, len(digits) - 5)}{digits[-4:]}"
return normalized, digits, masked
def request_fingerprint(payload: dict[str, Any]) -> str:
canonical = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def destination_hmac(phone_e164: str, key: bytes) -> str:
return hmac.new(key, phone_e164.encode(), hashlib.sha256).hexdigest()
def sms_parts(body: str) -> int:
if not body or "\ufeff" in body or "\x00" in body:
raise DomainError("sms_request_invalid", 422, "Rendered message contains invalid text")
gsm_units = 0
for char in body:
if char in GSM_BASIC:
gsm_units += 1
elif char in GSM_EXTENDED:
gsm_units += 2
else:
total = len(body.encode("utf-16-be")) // 2
return 1 if total <= 70 else (total + 66) // 67
return 1 if gsm_units <= 160 else (gsm_units + 152) // 153
def render_template(
body_template: str,
placeholders: list[str],
substitutions: dict[str, Any],
max_parts: int,
) -> str:
expected = set(placeholders)
supplied = set(substitutions)
if expected != supplied:
raise DomainError(
"sms_request_invalid",
422,
"Substitutions do not match template placeholders",
{"missing": sorted(expected - supplied), "unknown": sorted(supplied - expected)},
)
parsed = {
field_name
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
if field_name is not None
and not format_spec
and not conversion
and field_name.isidentifier()
}
if parsed != expected or any(
format_spec or conversion
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
if field_name is not None
):
raise DomainError("sms_request_invalid", 422, "Template placeholder contract is invalid")
body = body_template.format_map({key: str(value) for key, value in substitutions.items()})
if len(body.encode("utf-8")) > 2048 or sms_parts(body) > max_parts:
raise DomainError("sms_request_invalid", 422, "Rendered message exceeds template limit")
return body
@dataclass(frozen=True)
class ProviderResult:
send_status: SendStatus
http_status: int | None = None
message_uuid: str | None = None
external_id: str | None = None
error_code: str | None = None
error_message: str | None = None
retry_safe: bool = False
contract_violation: bool = False
DELIVERY_RANK = {
DeliveryStatus.UNKNOWN: 0,
DeliveryStatus.SENT: 1,
DeliveryStatus.DELIVERED: 2,
DeliveryStatus.UNDELIVERED: 2,
DeliveryStatus.UNSENT: 2,
}
def delivery_transition(current: DeliveryStatus, incoming: str) -> DeliveryStatus | None:
try:
target = DeliveryStatus(incoming.lower())
except ValueError:
return None
if DELIVERY_RANK[target] < DELIVERY_RANK[current]:
return current
if DELIVERY_RANK[target] == DELIVERY_RANK[current] and target != current:
return current
return target
def parse_status_time(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
raise DomainError("callback_invalid", 422, "Invalid callback status_time") from None
if parsed.tzinfo is None:
raise DomainError("callback_invalid", 422, "Callback status_time requires timezone")
return parsed
+322
View File
@@ -0,0 +1,322 @@
from __future__ import annotations
import base64
import binascii
import hmac
import logging
import time
import uuid
from contextlib import asynccontextmanager
from typing import Annotated, Any
import structlog
import uvicorn
from fastapi import Body, Depends, FastAPI, Header, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from pydantic import ValidationError
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.db import Database, SmsTemplate
from app.domain import DomainError
from app.metrics import CALLBACK_LAG, CALLBACK_TOTAL
from app.schemas import CallbackItem, ErrorEnvelope, MessageResponse, SendRequest, SendResponse
from app.service import (
apply_callback,
create_order,
load_runtime_settings,
read_message,
)
from app.settings import get_settings
log = structlog.get_logger()
def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
configure_logging(settings.log_level)
app.state.settings = settings
app.state.db = Database(settings.database_url)
yield
await app.state.db.close()
app = FastAPI(
title="HAN SMS Service",
version="1.0.0",
openapi_version="3.1.0",
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
@app.middleware("http")
async def request_context(request: Request, call_next: Any) -> Response:
supplied = request.headers.get("X-Request-ID", "").strip()
request_id = supplied[:128] if supplied and supplied.isprintable() else str(uuid.uuid4())
request.state.request_id = request_id
started = time.monotonic()
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
request_id=request_id,
method=request.method,
route=request.url.path,
**{"service.name": "sms-service"},
)
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
log.info(
"request.complete",
status_code=response.status_code,
duration_ms=round((time.monotonic() - started) * 1000, 2),
)
return response
def error_response(
request: Request,
code: str,
message: str,
status: int,
details: dict[str, Any] | list[dict[str, Any]] | None = None,
) -> JSONResponse:
return JSONResponse(
status_code=status,
content={
"error": {
"code": code,
"message": message,
"request_id": getattr(request.state, "request_id", str(uuid.uuid4())),
"details": details or {},
}
},
)
@app.exception_handler(DomainError)
async def domain_error(request: Request, exc: DomainError) -> JSONResponse:
response = error_response(request, exc.code, exc.message, exc.status, exc.details)
if "retry_after" in exc.details:
response.headers["Retry-After"] = str(exc.details["retry_after"])
return response
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
details = [
{"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]}
for item in exc.errors()
]
log.warning("request.validation_failed", details=details)
return error_response(
request, "sms_request_invalid", "SMS request validation failed", 422, details
)
@app.exception_handler(StarletteHTTPException)
async def http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = "not_found" if exc.status_code == 404 else "method_not_allowed"
return error_response(request, code, "Resource was not found", exc.status_code)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
log.exception("request.failed", error_code="internal_error")
return error_response(request, "internal_error", "Internal server error", 500)
async def session(request: Request):
async for value in request.app.state.db.session():
yield value
Session = Annotated[AsyncSession, Depends(session)]
async def bearer_auth(request: Request) -> None:
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
raise DomainError("unauthorized", 401, "Authentication failed")
supplied = authorization.removeprefix("Bearer ").strip()
expected = request.app.state.settings.service_token.get_secret_value()
if not supplied or not hmac.compare_digest(supplied, expected):
raise DomainError("unauthorized", 401, "Authentication failed")
InternalAuth = Annotated[None, Depends(bearer_auth)]
def basic_auth(request: Request) -> None:
authorization = request.headers.get("Authorization", "")
encoded = (
authorization.removeprefix("Basic ").strip() if authorization.startswith("Basic ") else ""
)
try:
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
username, password = decoded.split(":", 1)
except (binascii.Error, UnicodeDecodeError, ValueError):
raise DomainError("unauthorized", 401, "Authentication failed") from None
settings = request.app.state.settings
valid_user = hmac.compare_digest(username, settings.callback_username.get_secret_value())
valid_password = hmac.compare_digest(password, settings.callback_password.get_secret_value())
if not (valid_user and valid_password):
raise DomainError("unauthorized", 401, "Authentication failed")
CallbackAuth = Annotated[None, Depends(basic_auth)]
@app.get("/health/live", tags=["health"])
async def live() -> dict[str, str]:
return {"status": "live"}
@app.get("/health/ready", tags=["health"])
async def ready(db: Session) -> JSONResponse:
components = {
"postgres": "failed",
"schema": "failed",
"settings": "failed",
"template": "failed",
}
try:
await db.execute(text("SELECT 1"))
components["postgres"] = "ok"
revision = await db.scalar(text("SELECT version_num FROM sms.alembic_version LIMIT 1"))
if revision != "0002_seed":
raise RuntimeError("unexpected sms schema revision")
components["schema"] = "ok"
runtime = await load_runtime_settings(db)
components["settings"] = "ok"
template_count = await db.scalar(
select(func.count(SmsTemplate.id)).where(
SmsTemplate.code == "auth_otp",
SmsTemplate.is_active.is_(True),
SmsTemplate.approved_at.is_not(None),
(SmsTemplate.sender_name.is_not(None))
| (text(":sender <> ''").bindparams(sender=runtime.default_sender_name)),
)
)
if template_count != 1:
raise RuntimeError("active approved auth_otp template is missing")
components["template"] = "ok"
except Exception:
log.warning("readiness.failed")
failed = "failed" in components.values()
return JSONResponse(
{"status": "not_ready" if failed else "ready", "components": components},
status_code=503 if failed else 200,
)
@app.get("/metrics", include_in_schema=False)
async def metrics() -> Response:
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
@app.post(
"/internal/sms/v1/send",
response_model=SendResponse,
responses={
401: {"model": ErrorEnvelope},
409: {"model": ErrorEnvelope},
422: {"model": ErrorEnvelope},
429: {"model": ErrorEnvelope},
503: {"model": ErrorEnvelope},
},
tags=["internal"],
)
async def send(
body: SendRequest,
request: Request,
db: Session,
_auth: InternalAuth,
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
traceparent: Annotated[
str | None,
Header(pattern=r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$"),
] = None,
) -> JSONResponse:
result, created = await create_order(
db,
body,
x_request_id,
traceparent,
request.app.state.settings.service_token.get_secret_value().encode(),
)
return JSONResponse(result.model_dump(mode="json"), status_code=202 if created else 200)
@app.get(
"/internal/sms/v1/messages/{sms_message_id}",
response_model=MessageResponse,
responses={401: {"model": ErrorEnvelope}, 404: {"model": ErrorEnvelope}},
tags=["internal"],
)
async def message(sms_message_id: uuid.UUID, db: Session, _auth: InternalAuth) -> MessageResponse:
return await read_message(db, sms_message_id)
@app.post(
"/callbacks/idgtl/sms",
status_code=204,
responses={401: {"model": ErrorEnvelope}, 422: {"model": ErrorEnvelope}},
tags=["callback"],
)
async def callback(
payload: Annotated[list[dict[str, Any]], Body(min_length=1, max_length=1000)],
request: Request,
db: Session,
_auth: CallbackAuth,
) -> Response:
valid_count = 0
for raw in payload:
try:
item = CallbackItem.model_validate(raw)
except ValidationError:
CALLBACK_TOTAL.labels("idgtl", "invalid").inc()
log.warning("callback.rejected", reason="schema_invalid")
continue
accepted = await apply_callback(db, item)
CALLBACK_TOTAL.labels("idgtl", "accepted" if accepted else "rejected").inc()
if accepted:
valid_count += 1
lag = max(0.0, (datetime_now() - item.status_time).total_seconds())
CALLBACK_LAG.labels("idgtl", item.status.lower()).observe(lag)
await db.commit()
return Response(status_code=204, headers={"X-Callback-Items-Accepted": str(valid_count)})
def datetime_now():
from datetime import UTC, datetime
return datetime.now(UTC)
def run() -> None:
settings = get_settings()
uvicorn.run(
"app.main:app",
host="0.0.0.0", # noqa: S104 - required container listener
port=settings.api_port,
proxy_headers=False,
)
@@ -0,0 +1,39 @@
from prometheus_client import Counter, Gauge, Histogram
SEND_TOTAL = Counter(
"sms_send_total",
"Provider send outcomes",
("provider", "send_status"),
)
PROVIDER_LATENCY = Histogram(
"sms_provider_request_duration_seconds",
"Provider request latency",
("provider",),
)
UNCERTAIN_TOTAL = Counter(
"sms_uncertain_total",
"Ambiguous provider outcomes",
("provider",),
)
CALLBACK_TOTAL = Counter(
"sms_callback_total",
"Callback items",
("provider", "result"),
)
CALLBACK_LAG = Histogram(
"sms_callback_lag_seconds",
"Callback status-to-receipt lag",
("provider", "status"),
)
PENDING_AGE = Gauge(
"sms_pending_oldest_age_seconds",
"Age of oldest pending message",
)
JOURNAL_ROWS = Gauge(
"sms_journal_rows",
"Outbound journal row count",
)
SETTINGS_VALID = Gauge(
"sms_settings_valid",
"Whether cached technical settings are valid",
)
@@ -0,0 +1,161 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
from app.db import SendStatus, SmsOutboundMessage
from app.domain import ProviderResult
@dataclass(frozen=True)
class IdgtlConfig:
base_url: str
api_key: str
callback_url: str
callback_username: str
callback_password: str
connect_timeout_ms: int
request_timeout_ms: int
callback_enabled: bool
def callback_url_with_credentials(config: IdgtlConfig) -> str:
parts = urlsplit(config.callback_url)
credentials = (
f"{quote(config.callback_username, safe='')}:{quote(config.callback_password, safe='')}"
)
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
return urlunsplit((parts.scheme, f"{credentials}@{host}", parts.path, parts.query, ""))
def build_payload(message: SmsOutboundMessage, config: IdgtlConfig) -> list[dict[str, object]]:
item: dict[str, object] = {
"channelType": "SMS",
"senderName": message.sender_name,
"destination": message.phone_digits,
"content": message.body_rendered,
"externalMessageId": str(message.id),
"ttl": message.message_ttl_sec,
}
if config.callback_enabled:
item["callbackUrl"] = callback_url_with_credentials(config)
item["callbackEvents"] = ["delivered", "sent"]
return [item]
def classify_response(response: httpx.Response, expected_external_id: str) -> ProviderResult:
if response.status_code != 200:
if 400 <= response.status_code < 500:
return ProviderResult(
SendStatus.REJECTED,
response.status_code,
error_code=f"http_{response.status_code}",
error_message="provider_rejected",
)
return ProviderResult(
SendStatus.UNCERTAIN,
response.status_code,
error_code=f"http_{response.status_code}",
error_message="provider_result_uncertain",
)
try:
payload = response.json()
except ValueError:
return ProviderResult(
SendStatus.REJECTED,
200,
error_code="malformed_json",
error_message="provider_contract_violation",
contract_violation=True,
)
items = payload.get("items") if isinstance(payload, dict) else None
if isinstance(payload, dict) and items is None:
items = payload.get("messages") or payload.get("results") or payload.get("response")
errors = payload.get("errors") if isinstance(payload, dict) else None
if errors is not False or not isinstance(items, list) or len(items) != 1:
return ProviderResult(
SendStatus.REJECTED,
200,
error_code="invalid_response",
error_message="provider_contract_violation",
contract_violation=True,
)
item = items[0]
if not isinstance(item, dict):
return ProviderResult(
SendStatus.REJECTED,
200,
error_code="invalid_item",
error_message="provider_contract_violation",
contract_violation=True,
)
message_uuid = item.get("messageUuid")
external_id = item.get("externalMessageId")
try:
uuid.UUID(str(message_uuid))
except (ValueError, TypeError, AttributeError):
message_uuid = None
valid = item.get("code") == 201 and message_uuid and external_id == expected_external_id
if not valid:
return ProviderResult(
SendStatus.REJECTED,
200,
error_code=str(item.get("code") or "invalid_item"),
error_message="provider_contract_violation",
contract_violation=True,
)
return ProviderResult(
SendStatus.ACCEPTED,
200,
message_uuid=str(message_uuid),
external_id=str(external_id),
)
class IdgtlClient:
def __init__(self, client: httpx.AsyncClient, config: IdgtlConfig) -> None:
self.client = client
self.config = config
async def send(self, message: SmsOutboundMessage) -> ProviderResult:
timeout = httpx.Timeout(
self.config.request_timeout_ms / 1000,
connect=self.config.connect_timeout_ms / 1000,
)
try:
headers = {"Authorization": f"Basic {self.config.api_key}"}
if message.request_id:
headers["X-Request-ID"] = message.request_id
if message.traceparent:
headers["traceparent"] = message.traceparent
response = await self.client.post(
f"{self.config.base_url.rstrip('/')}/api/v1/message",
headers=headers,
json=build_payload(message, self.config),
timeout=timeout,
)
except (httpx.ConnectError, httpx.ConnectTimeout):
return ProviderResult(
SendStatus.FAILED,
error_code="connect_failure",
error_message="provider_connect_failure",
retry_safe=True,
)
except (httpx.ReadTimeout, httpx.WriteError, httpx.ReadError, httpx.RemoteProtocolError):
return ProviderResult(
SendStatus.UNCERTAIN,
error_code="ambiguous_transport_failure",
error_message="provider_result_uncertain",
)
except httpx.RequestError:
return ProviderResult(
SendStatus.UNCERTAIN,
error_code="transport_failure",
error_message="provider_result_uncertain",
)
return classify_response(response, str(message.id))
@@ -0,0 +1,93 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
class SendRequest(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
idempotency_key: str = Field(min_length=8, max_length=192)
template_code: Literal["auth_otp"]
locale: Literal["ru"]
phone_e164: str = Field(min_length=9, max_length=16)
substitutions: dict[str, str | int] = Field(min_length=1, max_length=16)
customer_ref: str = Field(min_length=1, max_length=128)
message_ttl_sec: int = Field(ge=60, le=86400)
class SendResponse(BaseModel):
sms_message_id: uuid.UUID
ordered_at: datetime
class MessageResponse(BaseModel):
sms_message_id: uuid.UUID
ordered_at: datetime
updated_at: datetime
requester_service: str
process: str
channel: str
provider: str
phone_masked: str
template_code: str
customer_ref: str | None
send_status: str
delivery_status: str
provider_message_id: str | None
accepted_at: datetime | None
sent_at: datetime | None
delivered_at: datetime | None
attempt_count: int
provider_error_code: str | None
class CallbackItem(BaseModel):
model_config = ConfigDict(extra="allow")
channel_type: str = Field(validation_alias=AliasChoices("channel_type", "channelType"))
message_uuid: str = Field(
min_length=1,
max_length=128,
validation_alias=AliasChoices("message_uuid", "messageUuid"),
)
external_message_id: str = Field(
min_length=1,
max_length=128,
validation_alias=AliasChoices("external_message_id", "externalMessageId"),
)
callback_event: str = Field(
min_length=1,
max_length=32,
validation_alias=AliasChoices("callback_event", "callbackEvent", "event"),
)
status: str = Field(min_length=1, max_length=32)
status_time: datetime = Field(validation_alias=AliasChoices("status_time", "statusTime"))
error_code: str | None = Field(
default=None, validation_alias=AliasChoices("error_code", "errorCode")
)
parts: int | None = Field(default=None, ge=0)
price: Decimal | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, min_length=3, max_length=3)
@field_validator("status_time")
@classmethod
def require_timezone(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("status_time requires a timezone")
return value
class ErrorDetail(BaseModel):
code: str
message: str
request_id: str
details: dict[str, Any] | list[dict[str, Any]]
class ErrorEnvelope(BaseModel):
error: ErrorDetail
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, cast
import structlog
from sqlalchemy import func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import (
Channel,
DeliveryStatus,
SendStatus,
SmsCallbackEvent,
SmsOutboundMessage,
SmsSetting,
SmsTemplate,
)
from app.domain import (
DomainError,
delivery_transition,
destination_hmac,
normalize_phone,
render_template,
request_fingerprint,
)
from app.schemas import CallbackItem, MessageResponse, SendRequest, SendResponse
log = structlog.get_logger()
@dataclass(frozen=True)
class RuntimeSettings:
default_sender_name: str
connect_timeout_ms: int
request_timeout_ms: int
callback_enabled: bool
poll_interval_ms: int
lease_seconds: int
SETTING_RULES: dict[str, tuple[type, int | None, int | None]] = {
"provider.idgtl.default_sender_name": (str, 1, 64),
"provider.idgtl.connect_timeout_ms": (int, 100, 30_000),
"provider.idgtl.request_timeout_ms": (int, 1_000, 120_000),
"provider.idgtl.callback_enabled": (bool, None, None),
"worker.poll_interval_ms": (int, 100, 60_000),
"worker.lease_seconds": (int, 10, 600),
}
async def load_runtime_settings(db: AsyncSession) -> RuntimeSettings:
rows = (
await db.execute(select(SmsSetting).where(SmsSetting.setting_key.in_(SETTING_RULES)))
).scalars()
values = {row.setting_key: row.setting_value for row in rows}
if values.keys() != SETTING_RULES.keys():
raise RuntimeError("required sms settings are missing")
for key, (expected_type, minimum, maximum) in SETTING_RULES.items():
value = values[key]
if type(value) is not expected_type: # bool is an int subclass
raise RuntimeError(f"invalid sms setting type: {key}")
if isinstance(value, (int, str)):
size = value if isinstance(value, int) else len(value)
if minimum is not None and size < minimum:
raise RuntimeError(f"sms setting below minimum: {key}")
if maximum is not None and size > maximum:
raise RuntimeError(f"sms setting above maximum: {key}")
sender = str(values["provider.idgtl.default_sender_name"])
if sender.startswith("__"):
raise RuntimeError("provider sender name is not configured")
return RuntimeSettings(
default_sender_name=sender,
connect_timeout_ms=cast(int, values["provider.idgtl.connect_timeout_ms"]),
request_timeout_ms=cast(int, values["provider.idgtl.request_timeout_ms"]),
callback_enabled=cast(bool, values["provider.idgtl.callback_enabled"]),
poll_interval_ms=cast(int, values["worker.poll_interval_ms"]),
lease_seconds=cast(int, values["worker.lease_seconds"]),
)
def fingerprint_payload(body: SendRequest, phone_e164: str) -> dict[str, Any]:
return {
"idempotency_key": body.idempotency_key,
"template_code": body.template_code,
"locale": body.locale,
"phone_e164": phone_e164,
"substitutions": body.substitutions,
"customer_ref": body.customer_ref,
"message_ttl_sec": body.message_ttl_sec,
}
def validate_otp_request(body: SendRequest) -> None:
code = body.substitutions.get("code")
ttl_min = body.substitutions.get("ttl_min")
if (
not isinstance(code, str)
or not code.isascii()
or not code.isdigit()
or not 4 <= len(code) <= 10
or body.message_ttl_sec % 60 != 0
or str(ttl_min) != str(body.message_ttl_sec // 60)
):
raise DomainError(
"sms_request_invalid", 422, "OTP substitutions and message TTL are inconsistent"
)
def send_response(message: SmsOutboundMessage) -> SendResponse:
return SendResponse(sms_message_id=message.id, ordered_at=message.requested_at)
async def existing_order(
db: AsyncSession, idempotency_key: str, fingerprint: str
) -> SmsOutboundMessage | None:
message = await db.scalar(
select(SmsOutboundMessage).where(
SmsOutboundMessage.requester_service == "keycloak",
SmsOutboundMessage.idempotency_key == idempotency_key,
)
)
if message and message.request_fingerprint != fingerprint:
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
return message
async def enforce_rate_limit(db: AsyncSession, phone_e164: str, destination_key: bytes) -> None:
digest = destination_hmac(phone_e164, destination_key)
lock_id = int.from_bytes(bytes.fromhex(digest[:16]), byteorder="big", signed=True)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_id})
since = datetime.now(UTC) - timedelta(minutes=10)
count = await db.scalar(
select(func.count(SmsOutboundMessage.id)).where(
SmsOutboundMessage.requester_service == "keycloak",
SmsOutboundMessage.phone_e164 == phone_e164,
SmsOutboundMessage.created_at >= since,
)
)
if (count or 0) >= 5:
raise DomainError(
"rate_limit_exceeded",
429,
"Rate limit exceeded",
{"retry_after": 600},
)
async def create_order(
db: AsyncSession,
body: SendRequest,
request_id: str | None,
traceparent: str | None,
destination_key: bytes,
) -> tuple[SendResponse, bool]:
validate_otp_request(body)
phone_e164, phone_digits, phone_masked = normalize_phone(body.phone_e164)
fingerprint = request_fingerprint(fingerprint_payload(body, phone_e164))
existing = await existing_order(db, body.idempotency_key, fingerprint)
if existing:
return send_response(existing), False
runtime = await load_runtime_settings(db)
template = await db.scalar(
select(SmsTemplate).where(
SmsTemplate.code == body.template_code,
SmsTemplate.channel == Channel.SMS,
SmsTemplate.locale == body.locale,
SmsTemplate.is_active.is_(True),
SmsTemplate.approved_at.is_not(None),
)
)
if not template:
raise DomainError("sms_service_unavailable", 503, "SMS service is unavailable")
sender = template.sender_name or runtime.default_sender_name
rendered = render_template(
template.body_template, template.placeholders, body.substitutions, template.max_parts
)
await enforce_rate_limit(db, phone_e164, destination_key)
now = datetime.now(UTC)
message = SmsOutboundMessage(
id=uuid.uuid4(),
requested_at=now,
updated_at=now,
requester_service="keycloak",
process="auth_otp",
channel="SMS",
provider="idgtl",
phone_e164=phone_e164,
phone_digits=phone_digits,
phone_masked=phone_masked,
template_id=template.id,
template_code=template.code,
body_rendered=rendered,
substitutions=body.substitutions,
send_status=SendStatus.PENDING,
delivery_status=DeliveryStatus.UNKNOWN,
customer_ref=body.customer_ref,
idempotency_key=body.idempotency_key,
request_fingerprint=fingerprint,
request_id=request_id,
traceparent=traceparent,
sender_name=sender,
message_ttl_sec=body.message_ttl_sec,
attempt_count=0,
next_attempt_at=now,
)
db.add(message)
try:
await db.commit()
except IntegrityError:
await db.rollback()
concurrent = await existing_order(db, body.idempotency_key, fingerprint)
if concurrent:
return send_response(concurrent), False
raise
return send_response(message), True
def message_response(message: SmsOutboundMessage) -> MessageResponse:
return MessageResponse(
sms_message_id=message.id,
ordered_at=message.requested_at,
updated_at=message.updated_at,
requester_service=message.requester_service,
process=message.process,
channel=message.channel,
provider=message.provider,
phone_masked=message.phone_masked,
template_code=message.template_code,
customer_ref=message.customer_ref,
send_status=message.send_status.value,
delivery_status=message.delivery_status.value,
provider_message_id=message.provider_message_id,
accepted_at=message.accepted_at,
sent_at=message.sent_at,
delivered_at=message.delivered_at,
attempt_count=message.attempt_count,
provider_error_code=message.provider_error_code,
)
async def read_message(db: AsyncSession, message_id: uuid.UUID) -> MessageResponse:
message = await db.scalar(
select(SmsOutboundMessage).where(
SmsOutboundMessage.id == message_id,
SmsOutboundMessage.requester_service == "keycloak",
)
)
if not message:
raise DomainError("not_found", 404, "Resource was not found")
return message_response(message)
async def apply_callback(db: AsyncSession, item: CallbackItem) -> bool:
if item.channel_type.upper() != "SMS":
log.warning("callback.rejected", reason="wrong_channel")
return False
message = await db.scalar(
select(SmsOutboundMessage)
.where(
SmsOutboundMessage.provider == "idgtl",
SmsOutboundMessage.provider_message_id == item.message_uuid,
)
.with_for_update()
)
if not message or item.external_message_id != str(message.id):
digest = hashlib.sha256(item.message_uuid.encode()).hexdigest()[:16]
log.warning(
"callback.rejected", reason="unknown_or_conflicting_message", message_hash=digest
)
return False
target = delivery_transition(message.delivery_status, item.status)
if target is None:
log.warning("callback.rejected", reason="unknown_status", sms_message_id=str(message.id))
return False
inserted = await db.scalar(
pg_insert(SmsCallbackEvent)
.values(
id=uuid.uuid4(),
message_uuid=item.message_uuid,
callback_event=item.callback_event.lower(),
status=item.status.lower(),
status_time=item.status_time,
)
.on_conflict_do_nothing(constraint="uq_callback_event")
.returning(SmsCallbackEvent.id)
)
if inserted is None:
return True
now = datetime.now(UTC)
message.delivery_status = target
message.callback_last_at = now
message.updated_at = now
message.provider_error_code = item.error_code
message.parts = item.parts if item.parts is not None else message.parts
message.price = item.price if item.price is not None else message.price
message.currency = item.currency if item.currency is not None else message.currency
if target == DeliveryStatus.SENT and message.sent_at is None:
message.sent_at = item.status_time
elif target == DeliveryStatus.DELIVERED and message.delivered_at is None:
message.delivered_at = item.status_time
return True
@@ -0,0 +1,53 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
database_url: str = Field(alias="SMS_DATABASE_URL")
service_token: SecretStr = Field(alias="SMS_SERVICE_TOKEN", min_length=32)
idgtl_base_url: AnyHttpUrl = Field(
default=AnyHttpUrl("https://direct.i-dgtl.ru"), alias="IDGTL_SMS_BASE_URL"
)
idgtl_api_key: SecretStr | None = Field(default=None, alias="IDGTL_SMS_API_KEY")
callback_public_url: AnyHttpUrl = Field(alias="IDGTL_SMS_CALLBACK_PUBLIC_URL")
callback_username: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_USERNAME")
callback_password: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_PASSWORD")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
api_port: int = Field(default=8080, alias="SMS_API_PORT", ge=1, le=65535)
@field_validator(
"service_token",
"callback_username",
"callback_password",
)
@classmethod
def reject_placeholders(cls, value: SecretStr) -> SecretStr:
raw = value.get_secret_value().strip()
if not raw or raw.lower() in {"changeme", "secret", "token", "<secret>"}:
raise ValueError("secret is missing or is a placeholder")
return value
@field_validator("idgtl_api_key")
@classmethod
def reject_api_key_placeholder(cls, value: SecretStr | None) -> SecretStr | None:
if value is None:
return None
return cls.reject_placeholders(value)
@field_validator("callback_public_url")
@classmethod
def callback_must_be_https(cls, value: AnyHttpUrl) -> AnyHttpUrl:
if value.scheme != "https":
raise ValueError("callback URL must use HTTPS")
if value.username or value.password:
raise ValueError("callback URL must not contain credentials")
return value
@lru_cache
def get_settings() -> Settings:
return Settings()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import asyncio
import logging
import random
import signal
import time
from datetime import UTC, datetime, timedelta
import httpx
import structlog
from sqlalchemy import and_, func, or_, select, update
from app.db import Database, SendStatus, SmsOutboundMessage
from app.metrics import (
JOURNAL_ROWS,
PENDING_AGE,
PROVIDER_LATENCY,
SEND_TOTAL,
SETTINGS_VALID,
UNCERTAIN_TOTAL,
)
from app.provider import IdgtlClient, IdgtlConfig
from app.service import RuntimeSettings, load_runtime_settings
from app.settings import Settings, get_settings
log = structlog.get_logger()
MAX_CONNECT_ATTEMPTS = 3
def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
)
async def reconcile_expired_leases(db: Database) -> int:
now = datetime.now(UTC)
async with db.sessions.begin() as session:
result = await session.execute(
update(SmsOutboundMessage)
.where(
SmsOutboundMessage.send_status == SendStatus.PENDING,
SmsOutboundMessage.attempt_count > 0,
SmsOutboundMessage.worker_locked_until < now,
)
.values(
send_status=SendStatus.UNCERTAIN,
worker_locked_until=None,
next_attempt_at=None,
updated_at=now,
provider_error_code="worker_lease_expired",
provider_error_message="provider_result_uncertain",
)
.returning(SmsOutboundMessage.id)
)
ids = list(result.scalars())
for message_id in ids:
SEND_TOTAL.labels("idgtl", SendStatus.UNCERTAIN.value).inc()
UNCERTAIN_TOTAL.labels("idgtl").inc()
log.error("worker.lease_expired", sms_message_id=str(message_id))
return len(ids)
async def lease_message(db: Database, runtime: RuntimeSettings) -> SmsOutboundMessage | None:
now = datetime.now(UTC)
eligible = or_(
and_(
SmsOutboundMessage.send_status == SendStatus.PENDING,
SmsOutboundMessage.attempt_count == 0,
),
and_(
SmsOutboundMessage.send_status == SendStatus.FAILED,
SmsOutboundMessage.attempt_count < MAX_CONNECT_ATTEMPTS,
),
)
async with db.sessions.begin() as session:
message = await session.scalar(
select(SmsOutboundMessage)
.where(
eligible,
SmsOutboundMessage.next_attempt_at <= now,
or_(
SmsOutboundMessage.worker_locked_until.is_(None),
SmsOutboundMessage.worker_locked_until < now,
),
)
.order_by(SmsOutboundMessage.next_attempt_at, SmsOutboundMessage.created_at)
.with_for_update(skip_locked=True)
.limit(1)
)
if message:
message.send_status = SendStatus.PENDING
message.attempt_count += 1
message.last_attempt_at = now
message.worker_locked_until = now + timedelta(seconds=runtime.lease_seconds)
message.updated_at = now
return message
async def save_result(db: Database, message_id, result, attempt_count: int) -> None:
now = datetime.now(UTC)
status = result.send_status
next_attempt = None
if result.retry_safe and attempt_count < MAX_CONNECT_ATTEMPTS:
next_attempt = now + timedelta(seconds=(2**attempt_count) + random.uniform(0, 1)) # noqa: S311
async with db.sessions.begin() as session:
values = {
"send_status": status,
"provider_http_status": result.http_status,
"provider_message_id": result.message_uuid,
"provider_external_id": result.external_id,
"provider_error_code": result.error_code,
"provider_error_message": result.error_message,
"worker_locked_until": None,
"next_attempt_at": next_attempt,
"updated_at": now,
}
if status == SendStatus.ACCEPTED:
values["accepted_at"] = now
await session.execute(
update(SmsOutboundMessage)
.where(
SmsOutboundMessage.id == message_id,
SmsOutboundMessage.send_status == SendStatus.PENDING,
SmsOutboundMessage.attempt_count == attempt_count,
)
.values(**values)
)
SEND_TOTAL.labels("idgtl", status.value).inc()
if status == SendStatus.UNCERTAIN:
UNCERTAIN_TOTAL.labels("idgtl").inc()
if result.contract_violation:
log.error("provider.contract_violation", sms_message_id=str(message_id))
def provider_config(settings: Settings, runtime: RuntimeSettings) -> IdgtlConfig:
if settings.idgtl_api_key is None:
raise RuntimeError("IDGTL_SMS_API_KEY is required by sms-worker")
return IdgtlConfig(
base_url=str(settings.idgtl_base_url),
api_key=settings.idgtl_api_key.get_secret_value(),
callback_url=str(settings.callback_public_url),
callback_username=settings.callback_username.get_secret_value(),
callback_password=settings.callback_password.get_secret_value(),
connect_timeout_ms=runtime.connect_timeout_ms,
request_timeout_ms=runtime.request_timeout_ms,
callback_enabled=runtime.callback_enabled,
)
async def update_queue_metrics(db: Database) -> None:
async with db.sessions() as session:
oldest = await session.scalar(
select(func.min(SmsOutboundMessage.created_at)).where(
SmsOutboundMessage.send_status == SendStatus.PENDING
)
)
count = await session.scalar(select(func.count(SmsOutboundMessage.id)))
age = max(0.0, (datetime.now(UTC) - oldest).total_seconds()) if oldest else 0.0
PENDING_AGE.set(age)
JOURNAL_ROWS.set(count or 0)
async def worker_loop(stop: asyncio.Event) -> None:
settings = get_settings()
configure_logging(settings.log_level)
db = Database(settings.database_url)
async with httpx.AsyncClient() as http:
try:
while not stop.is_set():
try:
await reconcile_expired_leases(db)
async with db.sessions() as session:
runtime = await load_runtime_settings(session)
SETTINGS_VALID.set(1)
message = await lease_message(db, runtime)
if message is None:
await update_queue_metrics(db)
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
continue
client = IdgtlClient(http, provider_config(settings, runtime))
started = time.monotonic()
result = await client.send(message)
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
await save_result(db, message.id, result, message.attempt_count)
except TimeoutError:
continue
except Exception:
SETTINGS_VALID.set(0)
log.exception("worker.iteration_failed")
try:
await asyncio.wait_for(stop.wait(), timeout=5)
except TimeoutError:
pass
finally:
await db.close()
def run() -> None:
stop = asyncio.Event()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for name in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(name, stop.set)
except NotImplementedError:
pass
try:
loop.run_until_complete(worker_loop(stop))
finally:
loop.close()
@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from app.db import Base, create_postgres_engine
from app.settings import get_settings
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
database_url = get_settings().database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_schema="sms",
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema="sms",
include_schemas=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_postgres_engine(database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_async_migrations())
@@ -0,0 +1,231 @@
"""Create SMS journal schema objects.
Revision ID: 0001_initial
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
SCHEMA = "sms"
channel = postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA, create_type=False)
send_status = postgresql.ENUM(
"pending",
"accepted",
"rejected",
"failed",
"uncertain",
"skipped",
name="sms_send_status",
schema=SCHEMA,
create_type=False,
)
delivery_status = postgresql.ENUM(
"unknown",
"sent",
"delivered",
"undelivered",
"unsent",
name="sms_delivery_status",
schema=SCHEMA,
create_type=False,
)
def upgrade() -> None:
bind = op.get_bind()
postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA).create(bind)
postgresql.ENUM(
"pending",
"accepted",
"rejected",
"failed",
"uncertain",
"skipped",
name="sms_send_status",
schema=SCHEMA,
).create(bind)
postgresql.ENUM(
"unknown",
"sent",
"delivered",
"undelivered",
"unsent",
name="sms_delivery_status",
schema=SCHEMA,
).create(bind)
op.create_table(
"sms_template",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("code", sa.String(64), nullable=False),
sa.Column("channel", channel, nullable=False),
sa.Column("locale", sa.String(16), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("body_template", sa.Text(), nullable=False),
sa.Column("placeholders", postgresql.JSONB(), nullable=False),
sa.Column("sender_name", sa.String(64)),
sa.Column("max_parts", sa.SmallInteger(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("approved_at", sa.DateTime(timezone=True)),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("created_by", sa.String(64), nullable=False),
sa.CheckConstraint("version > 0", name="ck_template_version_positive"),
sa.CheckConstraint("max_parts BETWEEN 1 AND 10", name="ck_template_max_parts"),
sa.UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
schema=SCHEMA,
)
op.create_index(
"uq_template_active",
"sms_template",
["code", "channel", "locale"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("is_active"),
)
op.create_table(
"sms_setting",
sa.Column("setting_key", sa.String(128), primary_key=True),
sa.Column("setting_value", postgresql.JSONB(), nullable=False),
sa.Column("value_type", sa.String(16), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"value_type IN ('string','integer','boolean')", name="ck_setting_value_type"
),
schema=SCHEMA,
)
op.create_table(
"sms_outbound_message",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column("sent_at", sa.DateTime(timezone=True)),
sa.Column("delivered_at", sa.DateTime(timezone=True)),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("requester_service", sa.String(64), nullable=False),
sa.Column("process", sa.String(64), nullable=False),
sa.Column("channel", sa.String(16), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("phone_e164", sa.String(16), nullable=False),
sa.Column("phone_digits", sa.String(15), nullable=False),
sa.Column("phone_masked", sa.String(32), nullable=False),
sa.Column(
"template_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(f"{SCHEMA}.sms_template.id"),
nullable=False,
),
sa.Column("template_code", sa.String(64), nullable=False),
sa.Column("body_rendered", sa.Text(), nullable=False),
sa.Column("substitutions", postgresql.JSONB(), nullable=False),
sa.Column("send_status", send_status, nullable=False),
sa.Column("delivery_status", delivery_status, nullable=False),
sa.Column("provider_message_id", sa.String(128)),
sa.Column("provider_external_id", sa.String(128)),
sa.Column("customer_ref", sa.String(128)),
sa.Column("idempotency_key", sa.String(192), nullable=False),
sa.Column("request_fingerprint", sa.String(64), nullable=False),
sa.Column("request_id", sa.String(128)),
sa.Column("traceparent", sa.String(55)),
sa.Column("provider_http_status", sa.Integer()),
sa.Column("provider_error_code", sa.String(64)),
sa.Column("provider_error_message", sa.String(256)),
sa.Column("sender_name", sa.String(64), nullable=False),
sa.Column("message_ttl_sec", sa.Integer()),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_attempt_at", sa.DateTime(timezone=True)),
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
sa.Column("worker_locked_until", sa.DateTime(timezone=True)),
sa.Column("parts", sa.Integer()),
sa.Column("price", sa.Numeric(14, 4)),
sa.Column("currency", sa.String(3)),
sa.Column("callback_last_at", sa.DateTime(timezone=True)),
sa.CheckConstraint("channel = 'SMS'", name="ck_outbound_channel"),
sa.CheckConstraint("provider = 'idgtl'", name="ck_outbound_provider"),
sa.CheckConstraint("process = 'auth_otp'", name="ck_outbound_process"),
sa.CheckConstraint("message_ttl_sec BETWEEN 60 AND 86400", name="ck_outbound_ttl"),
sa.CheckConstraint("attempt_count >= 0", name="ck_outbound_attempts"),
sa.UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
schema=SCHEMA,
)
op.create_index(
"uq_outbound_provider_message",
"sms_outbound_message",
["provider", "provider_message_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("provider_message_id IS NOT NULL"),
)
op.create_index(
"ix_outbound_phone_created",
"sms_outbound_message",
["phone_e164", sa.text("created_at DESC")],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_requester_process_created",
"sms_outbound_message",
["requester_service", "process", sa.text("created_at DESC")],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_customer_ref", "sms_outbound_message", ["customer_ref"], schema=SCHEMA
)
op.create_index(
"ix_outbound_send_created",
"sms_outbound_message",
["send_status", "created_at"],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_delivery_updated",
"sms_outbound_message",
["delivery_status", "updated_at"],
schema=SCHEMA,
)
op.create_table(
"sms_callback_event",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("message_uuid", sa.String(128), nullable=False),
sa.Column("callback_event", sa.String(32), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("status_time", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"received_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.UniqueConstraint(
"message_uuid", "callback_event", "status", "status_time", name="uq_callback_event"
),
schema=SCHEMA,
)
def downgrade() -> None:
op.drop_table("sms_callback_event", schema=SCHEMA)
op.drop_table("sms_outbound_message", schema=SCHEMA)
op.drop_table("sms_setting", schema=SCHEMA)
op.drop_table("sms_template", schema=SCHEMA)
delivery_status.drop(op.get_bind())
send_status.drop(op.get_bind())
channel.drop(op.get_bind())
@@ -0,0 +1,108 @@
"""Seed versioned technical settings and OTP template placeholder.
Revision ID: 0002_seed
"""
import uuid
import sqlalchemy as sa
from alembic import op
revision = "0002_seed"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
TEMPLATE_ID = uuid.UUID("5ac2a77e-590c-4b24-87d8-baa0f1240cd1")
def upgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"""
INSERT INTO sms.sms_template (
id, code, channel, locale, version, body_template, placeholders,
sender_name, max_parts, is_active, approved_at, created_by
) VALUES (
:id, 'auth_otp', 'SMS', 'ru', 1,
'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
'["code","ttl_min"]'::jsonb, NULL, 1, true, NULL, 'migration'
)
ON CONFLICT (code, channel, locale, version) DO NOTHING
"""
),
{"id": TEMPLATE_ID},
)
settings = (
(
"provider.idgtl.default_sender_name",
'"__SET_ME_AFTER_PROVIDER_APPROVAL__"',
"string",
"Provider-approved default sender name",
),
(
"provider.idgtl.connect_timeout_ms",
"3000",
"integer",
"Direct connection timeout in milliseconds",
),
(
"provider.idgtl.request_timeout_ms",
"70000",
"integer",
"Direct total request timeout in milliseconds",
),
(
"provider.idgtl.callback_enabled",
"true",
"boolean",
"Include delivery callback in provider requests",
),
(
"worker.poll_interval_ms",
"500",
"integer",
"Queue polling interval in milliseconds",
),
(
"worker.lease_seconds",
"90",
"integer",
"Exclusive provider-call lease duration",
),
)
for key, value, value_type, description in settings:
bind.execute(
sa.text(
"""
INSERT INTO sms.sms_setting (
setting_key, setting_value, value_type, description
) VALUES (:key, CAST(:value AS jsonb), :value_type, :description)
ON CONFLICT (setting_key) DO NOTHING
"""
),
{
"key": key,
"value": value,
"value_type": value_type,
"description": description,
},
)
def downgrade() -> None:
op.execute(sa.text("DELETE FROM sms.sms_template WHERE id = :id").bindparams(id=TEMPLATE_ID))
op.execute(
"""
DELETE FROM sms.sms_setting
WHERE setting_key IN (
'provider.idgtl.default_sender_name',
'provider.idgtl.connect_timeout_ms',
'provider.idgtl.request_timeout_ms',
'provider.idgtl.callback_enabled',
'worker.poll_interval_ms',
'worker.lease_seconds'
)
"""
)
+314
View File
@@ -0,0 +1,314 @@
openapi: 3.1.0
info:
title: HAN SMS Service
version: 1.0.0
description: Durable internal SMS ordering and i-Digital delivery callbacks.
servers:
- url: http://sms-service:8080
paths:
/internal/sms/v1/send:
post:
operationId: orderSms
security:
- serviceBearer: []
parameters:
- $ref: "#/components/parameters/RequestId"
- $ref: "#/components/parameters/Traceparent"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SendRequest"
responses:
"202":
description: New order durably committed; provider has not necessarily been called.
content:
application/json:
schema:
$ref: "#/components/schemas/SendResponse"
"200":
description: Idempotent replay of an existing order.
content:
application/json:
schema:
$ref: "#/components/schemas/SendResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"409":
$ref: "#/components/responses/IdempotencyConflict"
"422":
$ref: "#/components/responses/InvalidRequest"
"429":
$ref: "#/components/responses/RateLimited"
"503":
$ref: "#/components/responses/Unavailable"
/internal/sms/v1/messages/{sms_message_id}:
get:
operationId: readSmsOrder
security:
- serviceBearer: []
parameters:
- name: sms_message_id
in: path
required: true
schema:
type: string
format: uuid
- $ref: "#/components/parameters/RequestId"
responses:
"200":
description: Redacted message diagnostics; never contains OTP, body, or full phone.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/callbacks/idgtl/sms:
post:
operationId: acceptIdgtlCallback
security:
- callbackBasic: []
requestBody:
required: true
content:
application/json:
schema:
type: array
minItems: 1
maxItems: 1000
items:
$ref: "#/components/schemas/IdgtlCallbackItem"
responses:
"204":
description: Valid callback items committed; invalid items were safely ignored.
"401":
$ref: "#/components/responses/Unauthorized"
"422":
$ref: "#/components/responses/InvalidRequest"
webhooks:
idgtlDeliveryStatus:
post:
summary: The same payload accepted at /callbacks/idgtl/sms.
security:
- callbackBasic: []
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/IdgtlCallbackItem"
responses:
"204":
description: Callback committed.
components:
securitySchemes:
serviceBearer:
type: http
scheme: bearer
bearerFormat: opaque-service-token
callbackBasic:
type: http
scheme: basic
parameters:
RequestId:
name: X-Request-ID
in: header
required: false
schema:
type: string
maxLength: 128
Traceparent:
name: traceparent
in: header
required: false
schema:
type: string
pattern: "^[\\da-f]{2}-[\\da-f]{32}-[\\da-f]{16}-[\\da-f]{2}$"
schemas:
SendRequest:
type: object
additionalProperties: false
required:
- idempotency_key
- template_code
- locale
- phone_e164
- substitutions
- customer_ref
- message_ttl_sec
properties:
idempotency_key:
type: string
minLength: 8
maxLength: 192
template_code:
const: auth_otp
locale:
const: ru
phone_e164:
type: string
pattern: "^\\+[1-9]\\d{7,14}$"
substitutions:
type: object
additionalProperties: false
required: [code, ttl_min]
properties:
code:
type: string
pattern: "^\\d{4,10}$"
ttl_min:
oneOf:
- type: string
pattern: "^\\d{1,3}$"
- type: integer
minimum: 1
maximum: 1440
customer_ref:
type: string
minLength: 1
maxLength: 128
message_ttl_sec:
type: integer
minimum: 60
maximum: 86400
SendResponse:
type: object
additionalProperties: false
required: [sms_message_id, ordered_at]
properties:
sms_message_id:
type: string
format: uuid
ordered_at:
type: string
format: date-time
Message:
type: object
additionalProperties: false
description: Deliberately excludes phone_e164, body_rendered, and substitutions.
required:
- sms_message_id
- ordered_at
- updated_at
- requester_service
- process
- channel
- provider
- phone_masked
- template_code
- send_status
- delivery_status
- attempt_count
properties:
sms_message_id: {type: string, format: uuid}
ordered_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
requester_service: {const: keycloak}
process: {const: auth_otp}
channel: {const: SMS}
provider: {const: idgtl}
phone_masked: {type: string}
template_code: {const: auth_otp}
customer_ref: {type: [string, "null"]}
send_status:
enum: [pending, accepted, rejected, failed, uncertain, skipped]
delivery_status:
enum: [unknown, sent, delivered, undelivered, unsent]
provider_message_id: {type: [string, "null"]}
accepted_at: {type: [string, "null"], format: date-time}
sent_at: {type: [string, "null"], format: date-time}
delivered_at: {type: [string, "null"], format: date-time}
attempt_count: {type: integer, minimum: 0}
provider_error_code: {type: [string, "null"]}
IdgtlCallbackItem:
type: object
required:
- channelType
- messageUuid
- externalMessageId
- callbackEvent
- status
- statusTime
properties:
channelType:
const: SMS
messageUuid:
type: string
externalMessageId:
type: string
callbackEvent:
type: string
status:
enum: [sent, delivered, undelivered, unsent]
statusTime:
type: string
format: date-time
errorCode:
type: [string, "null"]
parts:
type: [integer, "null"]
minimum: 0
price:
type: [number, "null"]
minimum: 0
currency:
type: [string, "null"]
minLength: 3
maxLength: 3
Error:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: object
additionalProperties: false
required: [code, message, request_id, details]
properties:
code: {type: string}
message: {type: string}
request_id: {type: string}
details:
oneOf:
- type: object
- type: array
responses:
Unauthorized:
description: Missing or invalid credentials.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
IdempotencyConflict:
description: The key was already used with another meaningful payload.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
InvalidRequest:
description: Strict request or callback validation failed.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
RateLimited:
description: Caller and destination rate limit exceeded.
headers:
Retry-After:
schema: {type: integer}
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
Unavailable:
description: The order could not be durably committed.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
NotFound:
description: Message was not found in the caller scope.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
@@ -0,0 +1,59 @@
[project]
name = "han-sms-service"
version = "0.1.0"
description = "HAN Chat durable SMS delivery service"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"phonenumbers>=9,<10",
"prometheus-client>=0.22,<1",
"pydantic-settings>=2.10,<3",
"sqlalchemy[asyncio]>=2.0.41,<3",
"structlog>=25,<26",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = [
"aiosqlite>=0.21,<1",
"mypy>=1.16,<2",
"pytest>=8.4,<9",
"pytest-asyncio>=1.0,<2",
"pyyaml>=6,<7",
"ruff>=0.12,<1",
]
[project.scripts]
han-sms-api = "app.main:run"
han-sms-worker = "app.worker:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["S101"]
[tool.mypy]
python_version = "3.12"
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
ignore_missing_imports = true
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
exclude = ["migrations/"]
@@ -0,0 +1,25 @@
from pathlib import Path
import yaml
def test_static_contract_is_openapi_31_and_redacted() -> None:
contract = yaml.safe_load(
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
)
assert contract["openapi"] == "3.1.0"
paths = contract["paths"]
assert "/internal/sms/v1/send" in paths
assert "/internal/sms/v1/messages/{sms_message_id}" in paths
assert "/callbacks/idgtl/sms" in paths
message_fields = contract["components"]["schemas"]["Message"]["properties"]
assert {"phone_e164", "body_rendered", "substitutions"}.isdisjoint(message_fields)
assert "idgtlDeliveryStatus" in contract["webhooks"]
def test_send_contract_distinguishes_new_and_replayed_order() -> None:
contract = yaml.safe_load(
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
)
responses = contract["paths"]["/internal/sms/v1/send"]["post"]["responses"]
assert {"200", "202", "401", "409", "422", "429", "503"} <= responses.keys()
@@ -0,0 +1,35 @@
import base64
from types import SimpleNamespace
import pytest
from pydantic import SecretStr
from app.domain import DomainError
from app.main import basic_auth, bearer_auth
def request_with(authorization: str):
settings = SimpleNamespace(
service_token=SecretStr("s" * 43),
callback_username=SecretStr("callback-user"),
callback_password=SecretStr("callback-password"),
)
return SimpleNamespace(
headers={"Authorization": authorization},
app=SimpleNamespace(state=SimpleNamespace(settings=settings)),
)
@pytest.mark.asyncio
async def test_internal_api_requires_exact_bearer_token() -> None:
await bearer_auth(request_with(f"Bearer {'s' * 43}"))
with pytest.raises(DomainError) as error:
await bearer_auth(request_with("Bearer wrong"))
assert error.value.code == "unauthorized"
def test_callback_requires_exact_basic_credentials() -> None:
encoded = base64.b64encode(b"callback-user:callback-password").decode()
basic_auth(request_with(f"Basic {encoded}"))
with pytest.raises(DomainError):
basic_auth(request_with("Basic invalid"))
@@ -0,0 +1,85 @@
import pytest
from app.db import DeliveryStatus
from app.domain import (
DomainError,
delivery_transition,
normalize_phone,
render_template,
request_fingerprint,
sms_parts,
)
def test_phone_is_canonical_and_masked() -> None:
e164, digits, masked = normalize_phone("+79001234567")
assert e164 == "+79001234567"
assert digits == "79001234567"
assert masked == "+7******4567"
@pytest.mark.parametrize("phone", ["79001234567", "+012345678", "+7900", "+7999999999999999"])
def test_invalid_phone_is_rejected(phone: str) -> None:
with pytest.raises(DomainError) as error:
normalize_phone(phone)
assert error.value.code == "sms_request_invalid"
def test_strict_template_render() -> None:
result = render_template(
"Код входа: {code}. Действителен {ttl_min} мин.",
["code", "ttl_min"],
{"code": "482193", "ttl_min": 1},
1,
)
assert result == "Код входа: 482193. Действителен 1 мин."
@pytest.mark.parametrize(
"substitutions",
[
{"code": "482193"},
{"code": "482193", "ttl_min": 1, "extra": "forbidden"},
],
)
def test_template_rejects_placeholder_mismatch(substitutions) -> None:
with pytest.raises(DomainError):
render_template(
"Код: {code}; TTL: {ttl_min}",
["code", "ttl_min"],
substitutions,
1,
)
def test_template_rejects_format_expressions() -> None:
with pytest.raises(DomainError):
render_template("{code!r}", ["code"], {"code": "123456"}, 1)
def test_sms_parts_supports_gsm_and_unicode() -> None:
assert sms_parts("A" * 160) == 1
assert sms_parts("A" * 161) == 2
assert sms_parts("Я" * 70) == 1
assert sms_parts("Я" * 71) == 2
def test_fingerprint_is_canonical() -> None:
first = request_fingerprint({"b": 2, "a": {"y": 2, "x": 1}})
second = request_fingerprint({"a": {"x": 1, "y": 2}, "b": 2})
assert first == second
@pytest.mark.parametrize(
("current", "incoming", "expected"),
[
(DeliveryStatus.UNKNOWN, "sent", DeliveryStatus.SENT),
(DeliveryStatus.SENT, "delivered", DeliveryStatus.DELIVERED),
(DeliveryStatus.DELIVERED, "sent", DeliveryStatus.DELIVERED),
(DeliveryStatus.UNDELIVERED, "sent", DeliveryStatus.UNDELIVERED),
(DeliveryStatus.DELIVERED, "unsent", DeliveryStatus.DELIVERED),
(DeliveryStatus.UNKNOWN, "bogus", None),
],
)
def test_delivery_status_is_monotonic(current, incoming, expected) -> None:
assert delivery_transition(current, incoming) == expected
@@ -0,0 +1,90 @@
import uuid
import httpx
import pytest
from app.db import SendStatus
from app.provider import IdgtlConfig, callback_url_with_credentials, classify_response
def response(status: int, payload=None) -> httpx.Response:
request = httpx.Request("POST", "https://direct.example/api/v1/message")
if payload is None:
return httpx.Response(status, request=request)
return httpx.Response(status, json=payload, request=request)
@pytest.mark.parametrize("status", [401, 402, 403, 422])
def test_explicit_business_rejections_are_not_retried(status: int) -> None:
result = classify_response(response(status), "message-id")
assert result.send_status == SendStatus.REJECTED
assert result.retry_safe is False
@pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_ambiguous_http_results_are_uncertain(status: int) -> None:
result = classify_response(response(status), "message-id")
assert result.send_status == SendStatus.UNCERTAIN
assert result.retry_safe is False
def test_exact_success_contract() -> None:
message_uuid = str(uuid.uuid4())
result = classify_response(
response(
200,
{
"errors": False,
"response": [
{
"code": 201,
"messageUuid": message_uuid,
"externalMessageId": "message-id",
}
],
},
),
"message-id",
)
assert result.send_status == SendStatus.ACCEPTED
assert result.message_uuid == message_uuid
@pytest.mark.parametrize(
"payload",
[
{"errors": True, "response": []},
{"errors": False, "response": []},
{"errors": False, "response": [{"code": 200}]},
{
"errors": False,
"response": [
{
"code": 201,
"messageUuid": str(uuid.uuid4()),
"externalMessageId": "wrong",
}
],
},
],
)
def test_malformed_200_is_rejected_contract_violation(payload) -> None:
result = classify_response(response(200, payload), "message-id")
assert result.send_status == SendStatus.REJECTED
assert result.contract_violation is True
def test_callback_credentials_are_url_encoded() -> None:
config = IdgtlConfig(
base_url="https://direct.example",
api_key="api-key",
callback_url="https://tohin.ru/callbacks/idgtl/sms",
callback_username="user@example",
callback_password="p:a/ss", # noqa: S106 - synthetic URL-encoding fixture
connect_timeout_ms=3000,
request_timeout_ms=70000,
callback_enabled=True,
)
assert callback_url_with_credentials(config) == (
"https://user%40example:p%3Aa%2Fss@tohin.ru/callbacks/idgtl/sms"
)
@@ -0,0 +1,53 @@
import pytest
from pydantic import ValidationError
from app.domain import DomainError
from app.schemas import CallbackItem, SendRequest
from app.service import validate_otp_request
def valid_send(**overrides) -> SendRequest:
payload = {
"idempotency_key": "keycloak:challenge:01JABCDEF",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": "+79001234567",
"substitutions": {"code": "482193", "ttl_min": "1"},
"customer_ref": "01JABCDEF",
"message_ttl_sec": 60,
}
payload.update(overrides)
return SendRequest.model_validate(payload)
def test_send_request_is_strict() -> None:
with pytest.raises(ValidationError):
valid_send(extra="forbidden")
@pytest.mark.parametrize(
("substitutions", "ttl"),
[
({"code": "12ab", "ttl_min": "1"}, 60),
({"code": "123456", "ttl_min": "2"}, 60),
({"code": "123456", "ttl_min": "1"}, 61),
],
)
def test_otp_substitutions_match_ttl(substitutions, ttl) -> None:
with pytest.raises(DomainError):
validate_otp_request(valid_send(substitutions=substitutions, message_ttl_sec=ttl))
def test_callback_accepts_provider_camel_case() -> None:
item = CallbackItem.model_validate(
{
"channelType": "SMS",
"messageUuid": "provider-id",
"externalMessageId": "internal-id",
"callbackEvent": "delivered",
"status": "delivered",
"statusTime": "2026-07-22T12:00:00Z",
}
)
assert item.channel_type == "SMS"
assert item.status_time.tzinfo is not None
+20 -3
View File
@@ -44,9 +44,15 @@ class InfrastructureConfigTests(unittest.TestCase):
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
self.assertIn("networks: [backend, observability, egress]", application)
self.assertIn("networks: [public, backend, observability]", application)
self.assertEqual(
application.count(
"IDGTL_SMS_API_KEY: ${IDGTL_SMS_API_KEY:?IDGTL_SMS_API_KEY is required}"
),
1,
)
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
self.assertEqual(jobs.count("networks: [backend, egress]"), 4)
self.assertEqual(jobs.count("networks: [backend, egress]"), 5)
observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("networks: [observability, backend, egress]", observability)
@@ -93,6 +99,10 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("location = /auth/callback", site)
self.assertIn("location ^~ /auth/resources/", site)
self.assertIn("location ^~ /auth/realms/", site)
self.assertIn("location = /callbacks/idgtl/sms", site)
self.assertIn("allow 185.203.96.7;", site)
self.assertIn("proxy_pass http://sms_service_upstream;", site)
self.assertIn("upstream sms_service_upstream", config)
self.assertNotIn("security-headers.conf", proxy_keycloak)
self.assertNotIn("X-Frame-Options", proxy_keycloak)
@@ -134,6 +144,7 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("frontend-test-site", application)
self.assertIn("frontend-static:/output", application)
for service, command in (
("sms-worker:", "han-sms-worker"),
("delivery-worker:", "han-delivery-worker"),
("safety-recovery-worker:", "han-safety-worker"),
("cleanup-worker:", "han-cleanup-worker"),
@@ -169,10 +180,13 @@ class InfrastructureConfigTests(unittest.TestCase):
"api-backend/alembic/env.py",
"bitrix-local-app/alembic/env.py",
"bitrix-sync/alembic/env.py",
"sms-service/migrations/env.py",
):
env_script = (ROOT / relative_path).read_text(encoding="utf-8")
self.assertIn('.replace("%", "%%")', env_script, relative_path)
self.assertIn("create_postgres_engine", env_script, relative_path)
sms_db = (ROOT / "sms-service/app/db.py").read_text(encoding="utf-8")
self.assertNotIn("server_settings", sms_db)
def test_contact_sync_qualifies_pgcrypto_digest(self) -> None:
initial = (
@@ -185,7 +199,7 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("public.digest(", initial)
self.assertIn("public.digest(", fix)
self.assertIn('down_revision: str | None = "0001_initial"', fix)
self.assertIn('revision != "0003_consent_audit"', main)
self.assertIn('revision != "0005_otp_settings"', main)
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
migration = (
@@ -212,10 +226,11 @@ class InfrastructureConfigTests(unittest.TestCase):
"KEYCLOAK_OTP_MOCK_ENABLED",
"KEYCLOAK_OTP_MOCK_CODE",
"KEYCLOAK_OTP_HMAC_KEY",
"KEYCLOAK_OTP_TTL_SEC",
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
"KEYCLOAK_SETTINGS_BRIDGE_URL",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
"KEYCLOAK_SMS_SERVICE_URL",
"KEYCLOAK_SMS_SERVICE_TOKEN",
):
self.assertIn(f" {variable}:", application)
@@ -229,6 +244,8 @@ class InfrastructureConfigTests(unittest.TestCase):
"CURSOR_HMAC_SECRET=",
"BITRIX_TOKEN_ENCRYPTION_KEY=",
"KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth",
"KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080",
"IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms",
):
self.assertIn(required, example)
materialized = example.replace("change-me", "0123456789abcdef0123456789abcdef")