diff --git a/backend/README.md b/backend/README.md index e288621..b6bcc6d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,13 +1,13 @@ # Backend Notes (MVP Readiness) ## High-Level Takeaways -- Provider integrations are the main reliability gap: OTP providers are stubbed and Moyasar capture/refund are TODOs. +- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs. - External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk. - Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries. - Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion. ## Near-Term Focus -- Implement at least one real SMS/WhatsApp provider end-to-end via existing abstractions. +- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices. - Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope). - Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications. - Keep booking, payment, and notification orchestration in service layers, not views. diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py index ec7aa8c..7fa77ee 100644 --- a/backend/apps/accounts/services/otp.py +++ b/backend/apps/accounts/services/otp.py @@ -1,8 +1,8 @@ import logging import os import secrets -from datetime import timedelta from dataclasses import dataclass +from datetime import timedelta from django.conf import settings from django.contrib.auth.hashers import check_password, make_password @@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError): class BaseOtpProvider: + uses_provider_otp = False + def send_sms(self, to_number: str, message: str) -> None: raise NotImplementedError def send_whatsapp(self, to_number: str, message: str) -> None: raise NotImplementedError + def send_otp(self, to_number: str, channel: str) -> None: + raise NotImplementedError + + def verify_otp(self, to_number: str, code: str) -> bool: + raise NotImplementedError + class ConsoleOtpProvider(BaseOtpProvider): def send_sms(self, to_number: str, message: str) -> None: @@ -102,21 +110,99 @@ class UnifonicOtpProvider(BaseOtpProvider): raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet")) +class AuthenticaOtpProvider(BaseOtpProvider): + """Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging.""" + + uses_provider_otp = True + + def __init__(self) -> None: + self.api_key = os.getenv("AUTHENTICA_API_KEY") + self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa") + self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10")) + self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME") + + def _assert_config(self) -> None: + if not self.api_key: + raise ValueError(_("Authentica API key is not configured")) + + def _headers(self) -> dict: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Authorization": self.api_key, + } + + def _post(self, path: str, payload: dict) -> dict: + import requests + + self._assert_config() + base_url = self.base_url.rstrip("/") + url = f"{base_url}{path}" + try: + response = requests.post( + url, + headers=self._headers(), + json=payload, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise RuntimeError(_("Authentica request failed")) from exc + + try: + data = response.json() + except ValueError: + data = {"detail": response.text} + + if not response.ok: + raise RuntimeError(_("Authentica request failed")) + + return data + + def send_otp(self, to_number: str, channel: str) -> None: + if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP): + raise ValueError(_("Unsupported OTP channel")) + method = "sms" if channel == OtpChannel.SMS else "whatsapp" + self._post("/api/v2/send-otp", {"method": method, "phone": to_number}) + + def verify_otp(self, to_number: str, code: str) -> bool: + data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code}) + return bool(data.get("verified")) + + def send_sms(self, to_number: str, message: str) -> None: + if not self.sender_name: + raise ValueError(_("Authentica sender name is not configured")) + self._post( + "/api/v2/send-sms", + { + "phone": to_number, + "message": message, + "sender_name": self.sender_name, + }, + ) + + def send_whatsapp(self, to_number: str, message: str) -> None: + raise ValueError(_("Authentica WhatsApp messaging is not supported")) + + PROVIDERS = { "console": ConsoleOtpProvider, "twilio": TwilioOtpProvider, "unifonic": UnifonicOtpProvider, + "authentica": AuthenticaOtpProvider, } -def get_provider() -> BaseOtpProvider: - provider_key = settings.OTP_PROVIDER +def _get_provider_for_key(provider_key: str) -> BaseOtpProvider: provider_cls = PROVIDERS.get(provider_key) if not provider_cls: raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key}) return provider_cls() +def get_provider() -> BaseOtpProvider: + return _get_provider_for_key(settings.OTP_PROVIDER) + + def generate_code(length: int = 6) -> str: digits = "0123456789" return "".join(secrets.choice(digits) for _ in range(length)) @@ -149,25 +235,34 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo if elapsed < cooldown_seconds: raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed)) - code = generate_code() + if provider.uses_provider_otp: + code_hash = make_password(secrets.token_urlsafe(16)) + message = None + else: + code = generate_code() + code_hash = make_password(code) + message = _( + "Your verification code is %(code)s. It expires in %(minutes)s minutes." + ) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES} + otp = PhoneOTP.objects.create( phone_number=phone_number, channel=channel, purpose=purpose, provider=settings.OTP_PROVIDER, - code_hash=make_password(code), + code_hash=code_hash, expires_at=PhoneOTP.expiry_at(), ) - message = _( - "Your verification code is %(code)s. It expires in %(minutes)s minutes." - ) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES} - if channel == OtpChannel.SMS: - provider.send_sms(phone_number, message) - elif channel == OtpChannel.WHATSAPP: - provider.send_whatsapp(phone_number, message) + if provider.uses_provider_otp: + provider.send_otp(phone_number, channel) else: - raise ValueError(_("Unsupported OTP channel")) + if channel == OtpChannel.SMS: + provider.send_sms(phone_number, message) + elif channel == OtpChannel.WHATSAPP: + provider.send_whatsapp(phone_number, message) + else: + raise ValueError(_("Unsupported OTP channel")) return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat()) @@ -179,9 +274,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool: if otp.attempt_count > otp.max_attempts: otp.save(update_fields=["attempt_count"]) return False - if not check_password(code, otp.code_hash): - otp.save(update_fields=["attempt_count"]) - return False + provider_cls = PROVIDERS.get(otp.provider) + if provider_cls and getattr(provider_cls, "uses_provider_otp", False): + provider = provider_cls() + try: + verified = provider.verify_otp(otp.phone_number, code) + except Exception: + otp.save(update_fields=["attempt_count"]) + raise + if not verified: + otp.save(update_fields=["attempt_count"]) + return False + else: + if not check_password(code, otp.code_hash): + otp.save(update_fields=["attempt_count"]) + return False otp.verified_at = timezone.now() otp.save(update_fields=["verified_at", "attempt_count"]) return True diff --git a/backend/apps/accounts/tests/test_authentica_provider.py b/backend/apps/accounts/tests/test_authentica_provider.py new file mode 100644 index 0000000..7faa186 --- /dev/null +++ b/backend/apps/accounts/tests/test_authentica_provider.py @@ -0,0 +1,103 @@ +"""Tests for Authentica OTP provider implementation.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.auth.hashers import make_password + +from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP +from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp + + +@patch("requests.post") +def test_authentica_send_otp_sms_calls_api(mock_post): + mock_response = MagicMock(ok=True) + mock_response.json.return_value = {"success": True} + mock_post.return_value = mock_response + + with patch.dict( + os.environ, + { + "AUTHENTICA_API_KEY": "api-key", + "AUTHENTICA_BASE_URL": "https://api.authentica.sa", + "AUTHENTICA_TIMEOUT_SECONDS": "7", + }, + ): + provider = AuthenticaOtpProvider() + provider.send_otp("+966512345678", OtpChannel.SMS) + + mock_post.assert_called_once() + url = mock_post.call_args[0][0] + kwargs = mock_post.call_args[1] + + assert url == "https://api.authentica.sa/api/v2/send-otp" + assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"} + assert kwargs["headers"]["X-Authorization"] == "api-key" + assert kwargs["timeout"] == 7.0 + + +@patch("requests.post") +def test_authentica_send_sms_calls_api(mock_post): + mock_response = MagicMock(ok=True) + mock_response.json.return_value = {"success": True} + mock_post.return_value = mock_response + + with patch.dict( + os.environ, + { + "AUTHENTICA_API_KEY": "api-key", + "AUTHENTICA_SENDER_NAME": "Salon", + }, + ): + provider = AuthenticaOtpProvider() + provider.send_sms("+966512345678", "Hello") + + mock_post.assert_called_once() + url = mock_post.call_args[0][0] + kwargs = mock_post.call_args[1] + + assert url == "https://api.authentica.sa/api/v2/send-sms" + assert kwargs["json"] == { + "phone": "+966512345678", + "message": "Hello", + "sender_name": "Salon", + } + + +@patch("requests.post") +def test_authentica_verify_otp_calls_api(mock_post): + mock_response = MagicMock(ok=True) + mock_response.json.return_value = {"verified": True} + mock_post.return_value = mock_response + + with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}): + provider = AuthenticaOtpProvider() + assert provider.verify_otp("+966512345678", "123456") is True + + mock_post.assert_called_once() + url = mock_post.call_args[0][0] + kwargs = mock_post.call_args[1] + + assert url == "https://api.authentica.sa/api/v2/verify-otp" + assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"} + + +@pytest.mark.django_db +def test_verify_otp_uses_provider_for_authentica(): + otp = PhoneOTP.objects.create( + phone_number="+966512345678", + channel=OtpChannel.SMS, + purpose=OtpPurpose.AUTH, + provider="authentica", + code_hash=make_password("unused"), + expires_at=PhoneOTP.expiry_at(), + ) + + with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify: + assert verify_otp(otp, "123456") is True + + mock_verify.assert_called_once_with("+966512345678", "123456") + otp.refresh_from_db() + assert otp.verified_at is not None + assert otp.attempt_count == 1 diff --git a/docs/risks.md b/docs/risks.md index 19abb35..94493c2 100644 --- a/docs/risks.md +++ b/docs/risks.md @@ -5,7 +5,7 @@ This file tracks known gaps and risks to address in future iterations. ## Security And Auth - Phone normalization is KSA-focused and minimal; broaden for multi-country use. - OTP protections are basic; add device fingerprinting and IP throttling if needed. -- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold. +- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP); Unifonic remains a scaffold. - Social login is a placeholder. - USERNAME_FIELD is still `email` while email can be null; verify admin/login flows. @@ -21,7 +21,7 @@ This file tracks known gaps and risks to address in future iterations. ## Data And UX - Ratings are not recalculated from reviews. - No image upload or storage strategy for photos. -- Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio. +- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica. - Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending. ## Ops And Compliance