Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-6
@@ -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) {}
|
||||
|
||||
+47
-6
@@ -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) {}
|
||||
|
||||
+11
-4
@@ -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); }
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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;
|
||||
}
|
||||
|
||||
+13
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user