Update: 2026-08-06 18:32:03
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Chooses which channel delivers an OTP.
|
||||
*
|
||||
* OTP_PROVIDER=nabeh -> Nabeh gateway
|
||||
* OTP_PROVIDER=whatsapp -> legacy self-hosted WhatsApp bots
|
||||
*
|
||||
* Callers pass the code, not a rendered message: Nabeh composes its own message
|
||||
* around the code, while the WhatsApp bots need finished text. Keeping that
|
||||
* difference here means call sites do not care which channel is active.
|
||||
*
|
||||
* With OTP_FALLBACK_ENABLED on, a failure is retried on the other channel
|
||||
* before the login is refused - losing OTP delivery locks out every user.
|
||||
*/
|
||||
class OtpSender
|
||||
{
|
||||
public static function sendOtp(string $phone, string $otp): array
|
||||
{
|
||||
$provider = strtolower(trim((string)env('OTP_PROVIDER', 'whatsapp')));
|
||||
$fallback = strtolower((string)env('OTP_FALLBACK_ENABLED', 'true')) === 'true';
|
||||
|
||||
$result = self::dispatch($provider, $phone, $otp);
|
||||
$result['provider'] = $provider;
|
||||
|
||||
if ($result['success'] || !$fallback) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$alternate = $provider === 'nabeh' ? 'whatsapp' : 'nabeh';
|
||||
error_log(sprintf(
|
||||
'[OTP] primary channel "%s" failed, falling back to "%s" | error=%s',
|
||||
$provider,
|
||||
$alternate,
|
||||
$result['error'] ?? substr((string)($result['raw_response'] ?? ''), 0, 200)
|
||||
));
|
||||
|
||||
$fallbackResult = self::dispatch($alternate, $phone, $otp);
|
||||
$fallbackResult['provider'] = $alternate;
|
||||
$fallbackResult['primary_error'] = $result['error'] ?? null;
|
||||
|
||||
return $fallbackResult;
|
||||
}
|
||||
|
||||
private static function dispatch(string $channel, string $phone, string $otp): array
|
||||
{
|
||||
if ($channel === 'nabeh') {
|
||||
return (new NabehOtpService())->sendOtp($phone, $otp);
|
||||
}
|
||||
|
||||
$appName = (string)env('NABEH_APP_NAME', 'مُصادَق');
|
||||
$message = "رمز التحقق لتطبيق {$appName}:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
|
||||
|
||||
return (new WhatsAppProxyService())->sendMessage($phone, $message);
|
||||
}
|
||||
}
|
||||
@@ -88,12 +88,20 @@ if ($deviceId && !$isReviewer) {
|
||||
fclose($fp);
|
||||
}
|
||||
|
||||
$whatsappService = new \App\Services\WhatsAppProxyService();
|
||||
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
|
||||
$result = $whatsappService->sendMessage($phone, $message);
|
||||
$result = \App\Services\OtpSender::sendOtp($phone, $otp);
|
||||
|
||||
if (!$result['success']) {
|
||||
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
|
||||
// Log why it failed, not just that it did - the proxy returns the
|
||||
// transport error and the bot's own response body, and without them
|
||||
// a failure here is undiagnosable from the logs alone.
|
||||
error_log(sprintf(
|
||||
'ERROR: Failed to send OTP to phone: %s | provider=%s | url=%s | curl_error=%s | response=%s',
|
||||
$phone,
|
||||
$result['provider'] ?? 'n/a',
|
||||
$result['url'] ?? 'n/a',
|
||||
$result['error'] ?? 'none',
|
||||
substr((string)($result['raw_response'] ?? ''), 0, 500)
|
||||
));
|
||||
json_error('عذراً، فشل في إرسال رمز التحقق. يرجى المحاولة مرة أخرى.', 500);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,14 +100,12 @@ try {
|
||||
fclose($fp);
|
||||
}
|
||||
|
||||
// 5. Send OTP via WhatsApp Proxy
|
||||
$whatsappService = new \App\Services\WhatsAppProxyService();
|
||||
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
|
||||
$result = $whatsappService->sendMessage($phone, $message);
|
||||
// 5. Send OTP via the configured channel (Nabeh gateway or WhatsApp bots)
|
||||
$result = \App\Services\OtpSender::sendOtp($phone, $otp);
|
||||
|
||||
if (!$result['success']) {
|
||||
// Internal provider details stay in the log, not in the HTTP response.
|
||||
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone} - " . json_encode($result));
|
||||
error_log("ERROR: Failed to send OTP to phone: {$phone} - " . json_encode($result));
|
||||
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.useLibSizedPageAlignment=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_config.dart';
|
||||
import 'hmac_interceptor.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
/// Request/response fields that must never reach a log sink.
|
||||
const _sensitiveKeys = {
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'old_password',
|
||||
'new_password',
|
||||
'otp',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'device_secret',
|
||||
'push_token',
|
||||
};
|
||||
|
||||
/// Replaces sensitive values with a placeholder, recursing into nested maps.
|
||||
///
|
||||
/// Logging raw payloads printed live credentials into Xcode/logcat, where they
|
||||
/// persist well beyond the debug session.
|
||||
Object? _redact(Object? data) {
|
||||
if (data is Map) {
|
||||
return data.map((key, value) => MapEntry(
|
||||
key,
|
||||
_sensitiveKeys.contains(key.toString().toLowerCase())
|
||||
? '***'
|
||||
: _redact(value),
|
||||
));
|
||||
}
|
||||
if (data is List) {
|
||||
return data.map(_redact).toList();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
class DioClient {
|
||||
static const String baseUrl = ApiConfig.baseUrl;
|
||||
late final Dio dio;
|
||||
@@ -23,29 +56,33 @@ class DioClient {
|
||||
// Add Interceptors
|
||||
dio.interceptors.add(HmacInterceptor(SecureStorage()));
|
||||
|
||||
// Custom Logging Interceptor as requested
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
print('--- API REQUEST ---');
|
||||
print('URL: ${options.method} ${options.uri}');
|
||||
print('Payload: ${options.data}');
|
||||
return handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
print('--- API RESPONSE ---');
|
||||
print('URL: ${response.requestOptions.method} ${response.requestOptions.uri}');
|
||||
print('Status Code: ${response.statusCode}');
|
||||
print('Response: ${response.data}');
|
||||
return handler.next(response);
|
||||
},
|
||||
onError: (DioException e, handler) {
|
||||
print('--- API ERROR ---');
|
||||
print('URL: ${e.requestOptions.method} ${e.requestOptions.uri}');
|
||||
print('Status Code: ${e.response?.statusCode}');
|
||||
print('Response: ${e.response?.data ?? e.message}');
|
||||
return handler.next(e);
|
||||
},
|
||||
));
|
||||
// Logging interceptor. Debug builds only, and credentials are redacted:
|
||||
// these used to be bare print() calls, which also ran in release builds.
|
||||
if (kDebugMode) {
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
debugPrint('--- API REQUEST ---');
|
||||
debugPrint('URL: ${options.method} ${options.uri}');
|
||||
debugPrint('Payload: ${_redact(options.data)}');
|
||||
return handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
debugPrint('--- API RESPONSE ---');
|
||||
debugPrint(
|
||||
'URL: ${response.requestOptions.method} ${response.requestOptions.uri}');
|
||||
debugPrint('Status Code: ${response.statusCode}');
|
||||
debugPrint('Response: ${_redact(response.data)}');
|
||||
return handler.next(response);
|
||||
},
|
||||
onError: (DioException e, handler) {
|
||||
debugPrint('--- API ERROR ---');
|
||||
debugPrint('URL: ${e.requestOptions.method} ${e.requestOptions.uri}');
|
||||
debugPrint('Status Code: ${e.response?.statusCode}');
|
||||
debugPrint('Response: ${_redact(e.response?.data) ?? e.message}');
|
||||
return handler.next(e);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Dio get client => dio;
|
||||
|
||||
@@ -20,6 +20,37 @@ class AuthController extends GetxController {
|
||||
var isLoading = false.obs;
|
||||
var phone = ''.obs;
|
||||
|
||||
/// Turns a DioException into a message that reflects what actually failed.
|
||||
///
|
||||
/// Without this, any transport failure (expired TLS cert, no network, DNS)
|
||||
/// surfaced as "wrong credentials", which sent users hunting for a password
|
||||
/// problem that did not exist.
|
||||
String _describeDioError(DioException e,
|
||||
{String fallback = 'بيانات الدخول غير صحيحة'}) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['message'] != null) {
|
||||
return data['message'].toString();
|
||||
}
|
||||
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'انتهت مهلة الاتصال بالخادم. تحقق من اتصالك بالإنترنت.';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'تعذّر التحقق من شهادة أمان الخادم. يرجى التواصل مع الدعم.';
|
||||
case DioExceptionType.connectionError:
|
||||
case DioExceptionType.unknown:
|
||||
final msg = e.error?.toString() ?? e.message ?? '';
|
||||
if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) {
|
||||
return 'تعذّر التحقق من شهادة أمان الخادم. يرجى التواصل مع الدعم.';
|
||||
}
|
||||
return 'تعذّر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestOtp(String phoneNumber) async {
|
||||
try {
|
||||
if (phoneNumber.trim().isEmpty) {
|
||||
@@ -120,9 +151,9 @@ class AuthController extends GetxController {
|
||||
}
|
||||
}
|
||||
} on DioException catch (e, stackTrace) {
|
||||
AppLogger.error('OTP Verify Failed', e.response?.data, stackTrace);
|
||||
AppLogger.error('OTP Verify Failed', e.response?.data ?? e.message, stackTrace);
|
||||
AppSnackbar.showError(
|
||||
'خطأ', e.response?.data['message'] ?? 'رمز التحقق غير صحيح');
|
||||
'خطأ', _describeDioError(e, fallback: 'رمز التحقق غير صحيح'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -199,12 +230,8 @@ class AuthController extends GetxController {
|
||||
}
|
||||
}
|
||||
} on DioException catch (e, stackTrace) {
|
||||
AppLogger.error('Email Login Failed', e.response?.data, stackTrace);
|
||||
String errorMessage = 'بيانات الدخول غير صحيحة';
|
||||
if (e.response?.data != null && e.response?.data is Map) {
|
||||
errorMessage = e.response?.data['message'] ?? errorMessage;
|
||||
}
|
||||
AppSnackbar.showError('خطأ', errorMessage);
|
||||
AppLogger.error('Email Login Failed', e.response?.data ?? e.message, stackTrace);
|
||||
AppSnackbar.showError('خطأ', _describeDioError(e));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: musadaq_app
|
||||
description: Jordanian E-Invoicing Automation SaaS
|
||||
publish_to: 'none'
|
||||
version: 1.0.6+6
|
||||
version: 1.0.7+7
|
||||
|
||||
environment:
|
||||
sdk: '>=3.2.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user