Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user