feat(sms): add selectable provider strategy
This commit is contained in:
@@ -11,9 +11,11 @@ OTP_TTL_SECONDS=120
|
|||||||
OTP_MAX_ATTEMPTS=5
|
OTP_MAX_ATTEMPTS=5
|
||||||
OTP_REQUEST_LIMIT=3
|
OTP_REQUEST_LIMIT=3
|
||||||
OTP_REQUEST_WINDOW_SECONDS=300
|
OTP_REQUEST_WINDOW_SECONDS=300
|
||||||
|
SMS_PROVIDER=kavenegar
|
||||||
KAVENEGAR_API_KEY=replace-with-real-key
|
KAVENEGAR_API_KEY=replace-with-real-key
|
||||||
KAVENEGAR_LOGIN_TEMPLATE=login-otp
|
KAVENEGAR_LOGIN_TEMPLATE=login-otp
|
||||||
|
SMS_IR_API_KEY=replace-with-real-key
|
||||||
|
SMS_IR_VERIFY_TEMPLATE_ID=570574
|
||||||
ADMIN_MOBILE=989120000000
|
ADMIN_MOBILE=989120000000
|
||||||
GRPC_HOST=0.0.0.0
|
GRPC_HOST=0.0.0.0
|
||||||
GRPC_PORT=50051
|
GRPC_PORT=50051
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,11 +1,11 @@
|
|||||||
# Gapido Backend Code Challenge
|
# 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
|
## Architecture
|
||||||
|
|
||||||
- `auth-service`: async `grpc.aio` API for OTP login, token refresh, token revocation, and RBAC demo methods.
|
- `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.
|
- MongoDB stores users and refresh-token sessions.
|
||||||
- Redis stores OTP hashes, TTL, verification attempts, and OTP request rate limits.
|
- Redis stores OTP hashes, TTL, verification attempts, and OTP request rate limits.
|
||||||
- RabbitMQ decouples authentication from SMS delivery.
|
- 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`.
|
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
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -50,5 +63,4 @@ authorization: Bearer <access_token>
|
|||||||
- OTP requests are rate-limited per mobile number and client identity.
|
- 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 are opaque random values; only SHA-256 hashes are persisted.
|
||||||
- Refresh tokens rotate on use.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ services:
|
|||||||
OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5}
|
OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5}
|
||||||
OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3}
|
OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3}
|
||||||
OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300}
|
OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300}
|
||||||
|
SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar}
|
||||||
KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp}
|
KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp}
|
||||||
|
SMS_IR_VERIFY_TEMPLATE_ID: ${SMS_IR_VERIFY_TEMPLATE_ID:-570574}
|
||||||
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
|
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
|
||||||
GRPC_HOST: 0.0.0.0
|
GRPC_HOST: 0.0.0.0
|
||||||
GRPC_PORT: 50051
|
GRPC_PORT: 50051
|
||||||
@@ -36,7 +38,9 @@ services:
|
|||||||
MONGO_URI: mongodb://mongo:27017
|
MONGO_URI: mongodb://mongo:27017
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
||||||
|
SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar}
|
||||||
KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key}
|
KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key}
|
||||||
|
SMS_IR_API_KEY: ${SMS_IR_API_KEY:-replace-with-real-key}
|
||||||
depends_on:
|
depends_on:
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -22,9 +23,14 @@ class Settings(BaseSettings):
|
|||||||
otp_request_limit: int = 3
|
otp_request_limit: int = 3
|
||||||
otp_request_window_seconds: int = 300
|
otp_request_window_seconds: int = 300
|
||||||
|
|
||||||
|
sms_provider: Literal["kavenegar", "sms_ir"] = "kavenegar"
|
||||||
|
|
||||||
kavenegar_api_key: str = "replace-with-real-key"
|
kavenegar_api_key: str = "replace-with-real-key"
|
||||||
kavenegar_login_template: str = "login-otp"
|
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"
|
admin_mobile: str = "989120000000"
|
||||||
grpc_host: str = "0.0.0.0"
|
grpc_host: str = "0.0.0.0"
|
||||||
grpc_port: int = 50051
|
grpc_port: int = 50051
|
||||||
@@ -35,4 +41,3 @@ class Settings(BaseSettings):
|
|||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from gapido_auth.infrastructure.mongo_repositories import (
|
|||||||
)
|
)
|
||||||
from gapido_auth.infrastructure.rabbitmq import RabbitMqSmsPublisher
|
from gapido_auth.infrastructure.rabbitmq import RabbitMqSmsPublisher
|
||||||
from gapido_auth.infrastructure.redis_otp_store import RedisOtpStore
|
from gapido_auth.infrastructure.redis_otp_store import RedisOtpStore
|
||||||
|
from gapido_auth.infrastructure.sms_provider import get_sms_template
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@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_limit=settings.otp_request_limit,
|
||||||
otp_request_window_seconds=settings.otp_request_window_seconds,
|
otp_request_window_seconds=settings.otp_request_window_seconds,
|
||||||
refresh_token_ttl_seconds=settings.refresh_token_ttl_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)
|
return AppContainer(auth_service=auth_service, mongo_client=mongo_client, redis=redis)
|
||||||
|
|
||||||
|
|||||||
56
src/gapido_auth/infrastructure/sms_ir_client.py
Normal file
56
src/gapido_auth/infrastructure/sms_ir_client.py
Normal file
@@ -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)
|
||||||
|
|
||||||
26
src/gapido_auth/infrastructure/sms_provider.py
Normal file
26
src/gapido_auth/infrastructure/sms_provider.py
Normal file
@@ -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)
|
||||||
|
|
||||||
@@ -8,12 +8,13 @@ from aio_pika.abc import AbstractIncomingMessage
|
|||||||
|
|
||||||
from gapido_auth.config import get_settings
|
from gapido_auth.config import get_settings
|
||||||
from gapido_auth.domain.entities import SmsJob
|
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 (
|
from gapido_auth.infrastructure.rabbitmq import (
|
||||||
SMS_ROUTING_KEY,
|
SMS_ROUTING_KEY,
|
||||||
connect_robust,
|
connect_robust,
|
||||||
declare_sms_topology,
|
declare_sms_topology,
|
||||||
)
|
)
|
||||||
|
from gapido_auth.infrastructure.sms_provider import create_sms_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
MAX_RETRIES = 3
|
MAX_RETRIES = 3
|
||||||
@@ -21,7 +22,7 @@ MAX_RETRIES = 3
|
|||||||
|
|
||||||
async def handle_message(
|
async def handle_message(
|
||||||
message: AbstractIncomingMessage,
|
message: AbstractIncomingMessage,
|
||||||
client: KavenegarSmsClient,
|
client: SmsClient,
|
||||||
exchange: Any,
|
exchange: Any,
|
||||||
dlx: Any,
|
dlx: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -68,9 +69,9 @@ async def main() -> None:
|
|||||||
channel = await connection.channel()
|
channel = await connection.channel()
|
||||||
await channel.set_qos(prefetch_count=20)
|
await channel.set_qos(prefetch_count=20)
|
||||||
exchange, queue, dlx = await declare_sms_topology(channel)
|
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))
|
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()
|
await asyncio.Future()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pytest
|
|||||||
|
|
||||||
from gapido_auth.domain.errors import ExternalServiceError
|
from gapido_auth.domain.errors import ExternalServiceError
|
||||||
from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient
|
from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient
|
||||||
|
from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -28,3 +29,29 @@ async def test_kavenegar_api_error() -> None:
|
|||||||
with pytest.raises(ExternalServiceError):
|
with pytest.raises(ExternalServiceError):
|
||||||
await client.send_otp("989120000000", "123456", "login-otp")
|
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")
|
||||||
|
|||||||
26
tests/test_sms_provider.py
Normal file
26
tests/test_sms_provider.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user