Added Authentica OTP
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user