244 lines
7.9 KiB
PHP
244 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Cache;
|
|
|
|
/**
|
|
* Nabeh OTP Gateway
|
|
*
|
|
* Replaces the self-hosted WhatsApp bots for OTP delivery. Those bots were a
|
|
* single point of failure: one dead PM2 process took login down for everyone.
|
|
*
|
|
* Mirrors the integration already proven in the Tripz/Siro backend
|
|
* (backend/auth/otp/providers.php):
|
|
* auth : POST /api/auth/login {email, password} -> JWT bearer token
|
|
* send : POST /api/otp/send {phone, type, code, message}
|
|
*
|
|
* The bearer token is valid for 24h, so it is cached in Redis rather than
|
|
* refetched on every login. Delivery defaults to Nabeh's rendered image card
|
|
* and falls back to plain text, which is the ordering Siro settled on.
|
|
*/
|
|
class NabehOtpService
|
|
{
|
|
private const TOKEN_CACHE_KEY = 'nabeh_bearer_token';
|
|
private const TOKEN_TTL = 86400;
|
|
|
|
private string $baseUrl;
|
|
private int $timeout;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->baseUrl = rtrim((string)env('NABEH_BASE_URL', 'https://nabeh.intaleqapp.com'), '/');
|
|
$this->timeout = (int)env('NABEH_TIMEOUT', 15);
|
|
}
|
|
|
|
/**
|
|
* Deliver an OTP code.
|
|
*
|
|
* Nabeh renders the message itself, so the code is passed as a field and
|
|
* the template carries a literal {code} placeholder for it to substitute.
|
|
*
|
|
* @return array{success: bool, ...}
|
|
*/
|
|
public function sendOtp(string $phone, string $otp): array
|
|
{
|
|
$token = $this->getBearerToken();
|
|
if ($token === null) {
|
|
return ['success' => false, 'error' => 'Failed to obtain Nabeh bearer token.'];
|
|
}
|
|
|
|
// Nabeh expects digits only.
|
|
$phoneRaw = preg_replace('/\D+/', '', $phone);
|
|
|
|
$preferred = strtolower((string)env('NABEH_OTP_TYPE', 'image'));
|
|
if (!in_array($preferred, ['image', 'text', 'voice'], true)) {
|
|
$preferred = 'image';
|
|
}
|
|
|
|
$result = $this->attempt($phoneRaw, $preferred, $otp, $token);
|
|
if ($result['success']) {
|
|
return $result;
|
|
}
|
|
|
|
// The image renderer fails more often than plain text; retry before
|
|
// refusing the login.
|
|
if ($preferred === 'image') {
|
|
error_log('[Nabeh OTP] image type failed, retrying as text.');
|
|
$retry = $this->attempt($phoneRaw, 'text', $otp, $token);
|
|
if ($retry['success']) {
|
|
return $retry;
|
|
}
|
|
$result = $retry;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* A single send attempt for one delivery type.
|
|
*/
|
|
private function attempt(string $phone, string $type, string $otp, string $token): array
|
|
{
|
|
$url = $this->baseUrl . '/api/otp/send';
|
|
$appName = (string)env('NABEH_APP_NAME', 'مُصادَق');
|
|
|
|
$payload = [
|
|
'phone' => $phone,
|
|
'type' => $type,
|
|
'code' => $otp,
|
|
// {code} is substituted by Nabeh, not by us.
|
|
'message' => "رمز التحقق الخاص بك لتطبيق {$appName} هو: *{code}*\nصالح لمدة 5 دقائق. الرجاء عدم مشاركته مع أي شخص.",
|
|
];
|
|
|
|
$response = $this->request($url, $payload, [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $token,
|
|
]);
|
|
|
|
if ($response['error'] !== null) {
|
|
return ['success' => false, 'error' => $response['error'], 'url' => $url, 'type' => $type];
|
|
}
|
|
|
|
$decoded = json_decode($response['body'], true);
|
|
|
|
return [
|
|
'success' => $this->looksSuccessful($decoded, $response['status']),
|
|
'status' => $response['status'],
|
|
'type' => $type,
|
|
'response' => $decoded,
|
|
'raw_response' => $response['body'],
|
|
'url' => $url,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Nabeh is inconsistent about how it signals success, so accept any of the
|
|
* shapes the Siro integration observed in production.
|
|
*/
|
|
private function looksSuccessful(mixed $decoded, int $status): bool
|
|
{
|
|
if (!is_array($decoded)) {
|
|
return false;
|
|
}
|
|
|
|
if (!empty($decoded['success'])) {
|
|
return true;
|
|
}
|
|
|
|
$statusStr = strtolower((string)($decoded['status'] ?? ''));
|
|
if (in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true)) {
|
|
return true;
|
|
}
|
|
|
|
if (($decoded['status'] ?? false) === true || ($decoded['code'] ?? 0) === 200) {
|
|
return true;
|
|
}
|
|
|
|
if (!empty($decoded['message_id']) || !empty($decoded['id'])) {
|
|
return true;
|
|
}
|
|
|
|
$msgStr = strtolower((string)($decoded['message'] ?? ''));
|
|
if (str_contains($msgStr, 'success') || str_contains($msgStr, 'sent') || str_contains($msgStr, 'تم')) {
|
|
return true;
|
|
}
|
|
|
|
// Observed quirk: the gateway reports delivery inside an "error" field.
|
|
$errStr = strtolower((string)($decoded['error'] ?? ''));
|
|
if (str_contains($errStr, 'via gateway')) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Return a valid bearer token, from Redis when one is cached.
|
|
*/
|
|
private function getBearerToken(bool $forceRefresh = false): ?string
|
|
{
|
|
$redis = Cache::getInstance();
|
|
|
|
if (!$forceRefresh && $redis) {
|
|
try {
|
|
$cached = $redis->get(self::TOKEN_CACHE_KEY);
|
|
if (is_string($cached) && $cached !== '') {
|
|
return $cached;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log('[Nabeh Auth] Redis read failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$email = (string)env('NABEH_EMAIL', '');
|
|
$password = (string)env('NABEH_PASSWORD', '');
|
|
|
|
if ($email === '' || $password === '') {
|
|
error_log('[Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD in .env');
|
|
return null;
|
|
}
|
|
|
|
$url = $this->baseUrl . '/api/auth/login';
|
|
$response = $this->request($url, ['email' => $email, 'password' => $password], [
|
|
'Content-Type: application/json',
|
|
]);
|
|
|
|
if ($response['error'] !== null) {
|
|
error_log('[Nabeh Auth] Transport error: ' . $response['error']);
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($response['body'], true);
|
|
$token = $decoded['token']
|
|
?? $decoded['message']['token']
|
|
?? $decoded['jwt']
|
|
?? $decoded['access_token']
|
|
?? null;
|
|
|
|
if (!is_string($token) || $token === '') {
|
|
// Never log the response body here - it is the reply to a request
|
|
// that carried our gateway credentials.
|
|
error_log('[Nabeh Auth] Login failed, no token in response. HTTP ' . $response['status']);
|
|
return null;
|
|
}
|
|
|
|
if ($redis) {
|
|
try {
|
|
$redis->setex(self::TOKEN_CACHE_KEY, self::TOKEN_TTL, $token);
|
|
} catch (\Throwable $e) {
|
|
error_log('[Nabeh Auth] Redis write failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* @return array{status: int, body: string, error: ?string}
|
|
*/
|
|
private function request(string $url, array $payload, array $headers): array
|
|
{
|
|
$curl = curl_init();
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CUSTOMREQUEST => 'POST',
|
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_TIMEOUT => $this->timeout,
|
|
]);
|
|
|
|
$body = curl_exec($curl);
|
|
$err = curl_error($curl);
|
|
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
curl_close($curl);
|
|
|
|
return [
|
|
'status' => $status,
|
|
'body' => is_string($body) ? $body : '',
|
|
'error' => $err !== '' ? $err : null,
|
|
];
|
|
}
|
|
}
|