diff --git a/.env.example b/.env.example index a1757c3..62125ae 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,11 @@ OTP_TTL_SECONDS=120 OTP_MAX_ATTEMPTS=5 OTP_REQUEST_LIMIT=3 OTP_REQUEST_WINDOW_SECONDS=300 +SMS_PROVIDER=kavenegar KAVENEGAR_API_KEY=replace-with-real-key KAVENEGAR_LOGIN_TEMPLATE=login-otp +SMS_IR_API_KEY=replace-with-real-key +SMS_IR_VERIFY_TEMPLATE_ID=570574 ADMIN_MOBILE=989120000000 GRPC_HOST=0.0.0.0 GRPC_PORT=50051 - diff --git a/README.md b/README.md index 61542ae..832c81f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Gapido Backend Code Challenge -Python gRPC OTP authentication service with MongoDB, Redis, RabbitMQ, and a Kavenegar SMS adapter. +Python gRPC OTP authentication service with MongoDB, Redis, RabbitMQ, and selectable SMS providers. ## Architecture - `auth-service`: async `grpc.aio` API for OTP login, token refresh, token revocation, and RBAC demo methods. -- `sms-worker`: RabbitMQ consumer that sends OTP messages through Kavenegar. +- `sms-worker`: RabbitMQ consumer that sends OTP messages through the configured SMS provider. - MongoDB stores users and refresh-token sessions. - Redis stores OTP hashes, TTL, verification attempts, and OTP request rate limits. - RabbitMQ decouples authentication from SMS delivery. @@ -19,6 +19,19 @@ docker compose up --build The gRPC service listens on `localhost:50051`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`. +## SMS Provider + +The SMS integration uses the Strategy pattern behind the `SmsClient` port. Select the provider with: + +```env +SMS_PROVIDER=kavenegar +``` + +Supported values: + +- `kavenegar`: uses `KAVENEGAR_API_KEY` and `KAVENEGAR_LOGIN_TEMPLATE`. +- `sms_ir`: uses `SMS_IR_API_KEY` and `SMS_IR_VERIFY_TEMPLATE_ID`. + ## Local Development ```bash @@ -50,5 +63,4 @@ authorization: Bearer - OTP requests are rate-limited per mobile number and client identity. - Refresh tokens are opaque random values; only SHA-256 hashes are persisted. - Refresh tokens rotate on use. -- Kavenegar is hidden behind an adapter and mocked in tests. - +- SMS providers are hidden behind adapters and mocked in tests. diff --git a/docker-compose.yml b/docker-compose.yml index 36b7839..b1536c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,9 @@ services: OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5} OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3} OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300} + SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar} KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp} + SMS_IR_VERIFY_TEMPLATE_ID: ${SMS_IR_VERIFY_TEMPLATE_ID:-570574} ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000} GRPC_HOST: 0.0.0.0 GRPC_PORT: 50051 @@ -36,7 +38,9 @@ services: MONGO_URI: mongodb://mongo:27017 REDIS_URL: redis://redis:6379/0 RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ + SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar} KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key} + SMS_IR_API_KEY: ${SMS_IR_API_KEY:-replace-with-real-key} depends_on: rabbitmq: condition: service_healthy diff --git a/src/gapido_auth/config.py b/src/gapido_auth/config.py index 9fa8bf9..a8a3c5d 100644 --- a/src/gapido_auth/config.py +++ b/src/gapido_auth/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +from typing import Literal from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -22,9 +23,14 @@ class Settings(BaseSettings): otp_request_limit: int = 3 otp_request_window_seconds: int = 300 + sms_provider: Literal["kavenegar", "sms_ir"] = "kavenegar" + kavenegar_api_key: str = "replace-with-real-key" kavenegar_login_template: str = "login-otp" + sms_ir_api_key: str = "replace-with-real-key" + sms_ir_verify_template_id: int = 570574 + admin_mobile: str = "989120000000" grpc_host: str = "0.0.0.0" grpc_port: int = 50051 @@ -35,4 +41,3 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: return Settings() - diff --git a/src/gapido_auth/infrastructure/container.py b/src/gapido_auth/infrastructure/container.py index ceb4f8d..6c6f97b 100644 --- a/src/gapido_auth/infrastructure/container.py +++ b/src/gapido_auth/infrastructure/container.py @@ -14,6 +14,7 @@ from gapido_auth.infrastructure.mongo_repositories import ( ) from gapido_auth.infrastructure.rabbitmq import RabbitMqSmsPublisher from gapido_auth.infrastructure.redis_otp_store import RedisOtpStore +from gapido_auth.infrastructure.sms_provider import get_sms_template @dataclass(slots=True) @@ -53,8 +54,7 @@ async def build_auth_service(settings: Settings, rabbitmq_channel: AbstractChann otp_request_limit=settings.otp_request_limit, otp_request_window_seconds=settings.otp_request_window_seconds, refresh_token_ttl_seconds=settings.refresh_token_ttl_seconds, - sms_template=settings.kavenegar_login_template, + sms_template=get_sms_template(settings), ), ) return AppContainer(auth_service=auth_service, mongo_client=mongo_client, redis=redis) - diff --git a/src/gapido_auth/infrastructure/sms_ir_client.py b/src/gapido_auth/infrastructure/sms_ir_client.py new file mode 100644 index 0000000..162e3a5 --- /dev/null +++ b/src/gapido_auth/infrastructure/sms_ir_client.py @@ -0,0 +1,56 @@ +import logging +from typing import Any + +import httpx + +from gapido_auth.domain.errors import ExternalServiceError +from gapido_auth.domain.ports import SmsClient + +logger = logging.getLogger(__name__) + + +class SmsIrSmsClient(SmsClient): + _endpoint = "https://api.sms.ir/v1/send/verify" + + def __init__( + self, + api_key: str, + timeout_seconds: float = 10.0, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._api_key = api_key + self._timeout_seconds = timeout_seconds + self._transport = transport + + async def send_otp(self, mobile: str, code: str, template: str) -> None: + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "x-api-key": self._api_key, + } + payload = { + "mobile": mobile, + "templateId": int(template), + "parameters": [{"name": "OTP", "value": code}], + } + + try: + async with httpx.AsyncClient( + timeout=self._timeout_seconds, transport=self._transport + ) as client: + response = await client.post(self._endpoint, json=payload, headers=headers) + except httpx.HTTPError as exc: + logger.warning("SMS.ir network error for mobile=%s", mobile, exc_info=True) + raise ExternalServiceError("SMS.ir network error") from exc + + if response.status_code != 200: + logger.warning("SMS.ir HTTP error status=%s mobile=%s", response.status_code, mobile) + raise ExternalServiceError("SMS.ir HTTP error") + + data: dict[str, Any] = response.json() + if str(data.get("status", "")) != "1": + logger.warning("SMS.ir API error status=%s mobile=%s", data.get("status"), mobile) + raise ExternalServiceError("SMS.ir API error") + + logger.info("OTP SMS sent successfully to mobile=%s via SMS.ir", mobile) + diff --git a/src/gapido_auth/infrastructure/sms_provider.py b/src/gapido_auth/infrastructure/sms_provider.py new file mode 100644 index 0000000..c96e2e3 --- /dev/null +++ b/src/gapido_auth/infrastructure/sms_provider.py @@ -0,0 +1,26 @@ +import httpx + +from gapido_auth.config import Settings +from gapido_auth.domain.ports import SmsClient +from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient +from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient + + +def create_sms_client( + settings: Settings, + transport: httpx.AsyncBaseTransport | None = None, +) -> SmsClient: + match settings.sms_provider: + case "kavenegar": + return KavenegarSmsClient(settings.kavenegar_api_key, transport=transport) + case "sms_ir": + return SmsIrSmsClient(settings.sms_ir_api_key, transport=transport) + + +def get_sms_template(settings: Settings) -> str: + match settings.sms_provider: + case "kavenegar": + return settings.kavenegar_login_template + case "sms_ir": + return str(settings.sms_ir_verify_template_id) + diff --git a/src/gapido_auth/infrastructure/worker.py b/src/gapido_auth/infrastructure/worker.py index 20dfe26..8ede084 100644 --- a/src/gapido_auth/infrastructure/worker.py +++ b/src/gapido_auth/infrastructure/worker.py @@ -8,12 +8,13 @@ from aio_pika.abc import AbstractIncomingMessage from gapido_auth.config import get_settings from gapido_auth.domain.entities import SmsJob -from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient +from gapido_auth.domain.ports import SmsClient from gapido_auth.infrastructure.rabbitmq import ( SMS_ROUTING_KEY, connect_robust, declare_sms_topology, ) +from gapido_auth.infrastructure.sms_provider import create_sms_client logger = logging.getLogger(__name__) MAX_RETRIES = 3 @@ -21,7 +22,7 @@ MAX_RETRIES = 3 async def handle_message( message: AbstractIncomingMessage, - client: KavenegarSmsClient, + client: SmsClient, exchange: Any, dlx: Any, ) -> None: @@ -68,9 +69,9 @@ async def main() -> None: channel = await connection.channel() await channel.set_qos(prefetch_count=20) exchange, queue, dlx = await declare_sms_topology(channel) - client = KavenegarSmsClient(settings.kavenegar_api_key) + client = create_sms_client(settings) await queue.consume(lambda message: handle_message(message, client, exchange, dlx)) - logger.info("SMS worker started") + logger.info("SMS worker started with provider=%s", settings.sms_provider) await asyncio.Future() diff --git a/tests/test_kavenegar_client.py b/tests/test_kavenegar_client.py index 191e2a2..99ab78f 100644 --- a/tests/test_kavenegar_client.py +++ b/tests/test_kavenegar_client.py @@ -3,6 +3,7 @@ import pytest from gapido_auth.domain.errors import ExternalServiceError from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient +from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient @pytest.mark.asyncio @@ -28,3 +29,29 @@ async def test_kavenegar_api_error() -> None: with pytest.raises(ExternalServiceError): await client.send_otp("989120000000", "123456", "login-otp") + +@pytest.mark.asyncio +async def test_sms_ir_success() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url == "https://api.sms.ir/v1/send/verify" + assert request.headers["x-api-key"] == "sms-ir-key" + payload = request.read() + assert b'"mobile":"989120000000"' in payload + assert b'"templateId":570574' in payload + assert b'"name":"OTP"' in payload + assert b'"value":"123456"' in payload + return httpx.Response(200, json={"status": "1"}) + + client = SmsIrSmsClient("sms-ir-key", transport=httpx.MockTransport(handler)) + await client.send_otp("989120000000", "123456", "570574") + + +@pytest.mark.asyncio +async def test_sms_ir_api_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "0"}) + + client = SmsIrSmsClient("sms-ir-key", transport=httpx.MockTransport(handler)) + + with pytest.raises(ExternalServiceError): + await client.send_otp("989120000000", "123456", "570574") diff --git a/tests/test_sms_provider.py b/tests/test_sms_provider.py new file mode 100644 index 0000000..28c8fc4 --- /dev/null +++ b/tests/test_sms_provider.py @@ -0,0 +1,26 @@ +from gapido_auth.config import Settings +from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient +from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient +from gapido_auth.infrastructure.sms_provider import create_sms_client, get_sms_template + + +def test_sms_provider_factory_selects_kavenegar() -> None: + settings = Settings( + sms_provider="kavenegar", + kavenegar_api_key="kavenegar-key", + kavenegar_login_template="login-otp", + ) + + assert isinstance(create_sms_client(settings), KavenegarSmsClient) + assert get_sms_template(settings) == "login-otp" + + +def test_sms_provider_factory_selects_sms_ir() -> None: + settings = Settings( + sms_provider="sms_ir", + sms_ir_api_key="sms-ir-key", + sms_ir_verify_template_id=570574, + ) + + assert isinstance(create_sms_client(settings), SmsIrSmsClient) + assert get_sms_template(settings) == "570574"