diff --git a/app/Services/NabehOtpService.php b/app/Services/NabehOtpService.php new file mode 100644 index 0000000..146c6db --- /dev/null +++ b/app/Services/NabehOtpService.php @@ -0,0 +1,243 @@ + 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, + ]; + } +} diff --git a/app/Services/OtpSender.php b/app/Services/OtpSender.php new file mode 100644 index 0000000..847a8ab --- /dev/null +++ b/app/Services/OtpSender.php @@ -0,0 +1,58 @@ + 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); + } +} diff --git a/app/modules_app/auth/login.php b/app/modules_app/auth/login.php index 045a1fc..006210e 100644 --- a/app/modules_app/auth/login.php +++ b/app/modules_app/auth/login.php @@ -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); } diff --git a/app/modules_app/auth/mobile_request_otp.php b/app/modules_app/auth/mobile_request_otp.php index 06ed623..d7d320a 100644 --- a/app/modules_app/auth/mobile_request_otp.php +++ b/app/modules_app/auth/mobile_request_otp.php @@ -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); } diff --git a/musadaq-app/android/gradle.properties b/musadaq-app/android/gradle.properties index 33c0839..64415f5 100644 --- a/musadaq-app/android/gradle.properties +++ b/musadaq-app/android/gradle.properties @@ -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 diff --git a/musadaq-app/lib/core/network/dio_client.dart b/musadaq-app/lib/core/network/dio_client.dart index a502e17..0526ac6 100644 --- a/musadaq-app/lib/core/network/dio_client.dart +++ b/musadaq-app/lib/core/network/dio_client.dart @@ -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; diff --git a/musadaq-app/lib/features/auth/controllers/auth_controller.dart b/musadaq-app/lib/features/auth/controllers/auth_controller.dart index 3b01dae..fa17211 100644 --- a/musadaq-app/lib/features/auth/controllers/auth_controller.dart +++ b/musadaq-app/lib/features/auth/controllers/auth_controller.dart @@ -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 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; } diff --git a/musadaq-app/pubspec.yaml b/musadaq-app/pubspec.yaml index 36a28d4..36be9d6 100644 --- a/musadaq-app/pubspec.yaml +++ b/musadaq-app/pubspec.yaml @@ -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'