Added Authentica OTP

This commit is contained in:
2026-02-28 16:58:50 +03:00
parent a1da918f95
commit 4253f6f650
4 changed files with 230 additions and 20 deletions
+2 -2
View File
@@ -1,13 +1,13 @@
# Backend Notes (MVP Readiness) # Backend Notes (MVP Readiness)
## High-Level Takeaways ## 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. - 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. - 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. - Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
## Near-Term Focus ## 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). - 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. - 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. - Keep booking, payment, and notification orchestration in service layers, not views.
+114 -7
View File
@@ -1,8 +1,8 @@
import logging import logging
import os import os
import secrets import secrets
from datetime import timedelta
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.hashers import check_password, make_password
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
class BaseOtpProvider: class BaseOtpProvider:
uses_provider_otp = False
def send_sms(self, to_number: str, message: str) -> None: def send_sms(self, to_number: str, message: str) -> None:
raise NotImplementedError raise NotImplementedError
def send_whatsapp(self, to_number: str, message: str) -> None: def send_whatsapp(self, to_number: str, message: str) -> None:
raise NotImplementedError 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): class ConsoleOtpProvider(BaseOtpProvider):
def send_sms(self, to_number: str, message: str) -> None: 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")) 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 = { PROVIDERS = {
"console": ConsoleOtpProvider, "console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider, "twilio": TwilioOtpProvider,
"unifonic": UnifonicOtpProvider, "unifonic": UnifonicOtpProvider,
"authentica": AuthenticaOtpProvider,
} }
def get_provider() -> BaseOtpProvider: def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
provider_key = settings.OTP_PROVIDER
provider_cls = PROVIDERS.get(provider_key) provider_cls = PROVIDERS.get(provider_key)
if not provider_cls: if not provider_cls:
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key}) raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
return provider_cls() return provider_cls()
def get_provider() -> BaseOtpProvider:
return _get_provider_for_key(settings.OTP_PROVIDER)
def generate_code(length: int = 6) -> str: def generate_code(length: int = 6) -> str:
digits = "0123456789" digits = "0123456789"
return "".join(secrets.choice(digits) for _ in range(length)) return "".join(secrets.choice(digits) for _ in range(length))
@@ -149,19 +235,28 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
if elapsed < cooldown_seconds: if elapsed < cooldown_seconds:
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed)) raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
if provider.uses_provider_otp:
code_hash = make_password(secrets.token_urlsafe(16))
message = None
else:
code = generate_code() 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( otp = PhoneOTP.objects.create(
phone_number=phone_number, phone_number=phone_number,
channel=channel, channel=channel,
purpose=purpose, purpose=purpose,
provider=settings.OTP_PROVIDER, provider=settings.OTP_PROVIDER,
code_hash=make_password(code), code_hash=code_hash,
expires_at=PhoneOTP.expiry_at(), expires_at=PhoneOTP.expiry_at(),
) )
message = _( if provider.uses_provider_otp:
"Your verification code is %(code)s. It expires in %(minutes)s minutes." provider.send_otp(phone_number, channel)
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES} else:
if channel == OtpChannel.SMS: if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message) provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP: elif channel == OtpChannel.WHATSAPP:
@@ -179,6 +274,18 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
if otp.attempt_count > otp.max_attempts: if otp.attempt_count > otp.max_attempts:
otp.save(update_fields=["attempt_count"]) otp.save(update_fields=["attempt_count"])
return False 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): if not check_password(code, otp.code_hash):
otp.save(update_fields=["attempt_count"]) otp.save(update_fields=["attempt_count"])
return False return False
@@ -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
View File
@@ -5,7 +5,7 @@ This file tracks known gaps and risks to address in future iterations.
## Security And Auth ## Security And Auth
- Phone normalization is KSA-focused and minimal; broaden for multi-country use. - Phone normalization is KSA-focused and minimal; broaden for multi-country use.
- OTP protections are basic; add device fingerprinting and IP throttling if needed. - 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. - Social login is a placeholder.
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows. - 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 ## Data And UX
- Ratings are not recalculated from reviews. - Ratings are not recalculated from reviews.
- No image upload or storage strategy for photos. - 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. - Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance ## Ops And Compliance