Initial commit with updated Auth and media ignored
This commit is contained in:
239
core/Auth/JwtService.php
Normal file
239
core/Auth/JwtService.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Auth/JwtService.php
|
||||
// JWT آمن: JTI + Blacklist في Redis + Refresh Token
|
||||
// ============================================================
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use Firebase\JWT\BeforeValidException;
|
||||
|
||||
class JwtService
|
||||
{
|
||||
private string $secretKey;
|
||||
private string $hmacSecret;
|
||||
private string $fpPepper;
|
||||
private ?Redis $redis;
|
||||
|
||||
private string $issuer;
|
||||
private const ACCESS_TTL = 3600; // 1 ساعة
|
||||
private const REFRESH_TTL = 2592000; // 30 يوم
|
||||
private const ALGO = 'HS256';
|
||||
|
||||
// Endpoints مسموح لها بتوكن نوع registration
|
||||
private const REGISTRATION_ENDPOINTS = [
|
||||
'loginFirstTime', 'loginFirstTimeDriver',
|
||||
'checkPhoneNumberISVerfiedDriver', 'checkPhoneNumberISVerfiedPassenger',
|
||||
'otpmessage', 'signup', 'verifyEmail', 'verifyOtpMessage',
|
||||
'sendVerifyEmail', 'sendWhatsAppDriver', 'register_passenger',
|
||||
'sendWhatsOpt', 'verifyOtp', 'auth_proxy', 'addToken',
|
||||
'loginFromGoogle', 'loginUsingCredentialsWithoutGoogle',
|
||||
'register', 'sendOtpMessageDriver', 'getTokensPassenger',
|
||||
'send_otp', 'verify_otp', 'errorApp', 'register_driver',
|
||||
];
|
||||
|
||||
public function __construct(?Redis $redis = null)
|
||||
{
|
||||
$this->secretKey = trim(file_get_contents('/home/intaleq-api/.secret_key'));
|
||||
$this->hmacSecret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
$this->fpPepper = getenv('FP_PEPPER') ?: '';
|
||||
$this->issuer = getenv('APP_ISSUER') ;
|
||||
$this->redis = $redis;
|
||||
}
|
||||
|
||||
// ── توليد Access Token ──────────────────────────────────
|
||||
public function generateAccessToken(
|
||||
int|string $userId,
|
||||
string $role,
|
||||
string $audience,
|
||||
?string $fingerprint = null
|
||||
): string {
|
||||
$jti = bin2hex(random_bytes(16));
|
||||
|
||||
$ttl = 3600;
|
||||
if ($role === 'driver') {
|
||||
$ttl = 14400;
|
||||
} elseif ($role === 'passenger') {
|
||||
$ttl = 3600;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'iss' => $this->issuer,
|
||||
'aud' => $audience,
|
||||
'user_id' => $userId,
|
||||
'role' => $role,
|
||||
'token_type' => 'access',
|
||||
'jti' => $jti,
|
||||
'iat' => time(),
|
||||
'exp' => time() + $ttl,
|
||||
];
|
||||
|
||||
if ($fingerprint && $this->fpPepper) {
|
||||
$payload['fingerPrint'] = hash('sha256', $fingerprint . $this->fpPepper);
|
||||
}
|
||||
|
||||
return JWT::encode($payload, $this->secretKey, self::ALGO);
|
||||
}
|
||||
|
||||
// ── توليد Refresh Token ─────────────────────────────────
|
||||
public function generateRefreshToken(int|string $userId): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$exp = time() + self::REFRESH_TTL;
|
||||
|
||||
// تخزين في Redis
|
||||
if ($this->redis) {
|
||||
$this->redis->setex(
|
||||
"refresh:{$userId}:{$token}",
|
||||
self::REFRESH_TTL,
|
||||
json_encode(['user_id' => $userId, 'created_at' => time()])
|
||||
);
|
||||
}
|
||||
|
||||
return ['token' => $token, 'expires_at' => $exp];
|
||||
}
|
||||
|
||||
// ── التحقق الكامل من التوكن ────────────────────────────
|
||||
public function authenticate(): object
|
||||
{
|
||||
// 1. استخراج التوكن
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
$token = null;
|
||||
if (preg_match('/Bearer\s(\S+)/', $authHeader, $m)) {
|
||||
$token = $m[1];
|
||||
}
|
||||
|
||||
if (!$token) {
|
||||
self::abort(401, 'Authorization token required');
|
||||
}
|
||||
|
||||
// 2. Decode
|
||||
try {
|
||||
$decoded = JWT::decode($token, new Key($this->secretKey, self::ALGO));
|
||||
} catch (ExpiredException $e) {
|
||||
self::abort(401, 'Token expired');
|
||||
} catch (SignatureInvalidException $e) {
|
||||
// محاولة فك التشفير بمفتاح المحفظة (Wallet secret fallback)
|
||||
$payKeyPath = '/home/intaleq-api/.secret_key_pay';
|
||||
$payKey = file_exists($payKeyPath) ? trim(file_get_contents($payKeyPath)) : '';
|
||||
|
||||
if ($payKey) {
|
||||
try {
|
||||
$decoded = JWT::decode($token, new Key($payKey, self::ALGO));
|
||||
} catch (Exception $e2) {
|
||||
self::abort(401, 'Invalid token signature');
|
||||
}
|
||||
} else {
|
||||
self::abort(401, 'Invalid token signature');
|
||||
}
|
||||
} catch (BeforeValidException $e) {
|
||||
self::abort(401, 'Token not yet valid');
|
||||
} catch (Exception $e) {
|
||||
self::abort(401, 'Invalid token');
|
||||
}
|
||||
|
||||
// 3. Issuer
|
||||
if (($decoded->iss ?? '') !== $this->issuer) {
|
||||
self::abort(401, 'Invalid token issuer');
|
||||
}
|
||||
|
||||
// 4. User ID
|
||||
$userId = $decoded->user_id ?? $decoded->sub ?? null;
|
||||
if (!$userId) {
|
||||
self::abort(401, 'Invalid JWT payload');
|
||||
}
|
||||
|
||||
// 5. JTI Blacklist (تحقق من توكنات ملغاة)
|
||||
$jti = $decoded->jti ?? null;
|
||||
if ($jti && $this->redis) {
|
||||
if ($this->redis->exists("jwt:blacklist:$jti")) {
|
||||
self::abort(401, 'Token has been revoked');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. token_type — قيّد registration endpoints
|
||||
$tokenType = $decoded->token_type ?? 'access';
|
||||
if ($tokenType === 'registration' || $tokenType === 'new') {
|
||||
$currentFile = basename($_SERVER['PHP_SELF'], '.php');
|
||||
$allowed = false;
|
||||
foreach (self::REGISTRATION_ENDPOINTS as $ep) {
|
||||
if (strcasecmp($currentFile, $ep) === 0) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$allowed) {
|
||||
error_log("[SECURITY] Registration token blocked on: $currentFile | user: $userId");
|
||||
self::abort(403, 'Token not authorized for this action');
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Device Fingerprint (إلزامي للـ Access Tokens)
|
||||
if ($this->fpPepper && $tokenType === 'access') {
|
||||
$fpInToken = $decoded->fingerPrint ?? null;
|
||||
$fpHeader = $_SERVER['HTTP_X_DEVICE_FP'] ?? null;
|
||||
|
||||
if ($fpInToken === null || $fpHeader === null) {
|
||||
error_log("[SECURITY] Fingerprint missing | user: $userId");
|
||||
self::abort(403, 'Device verification required');
|
||||
}
|
||||
|
||||
$expected = hash('sha256', $fpHeader . $this->fpPepper);
|
||||
if (!hash_equals($expected, $fpInToken)) {
|
||||
error_log("[SECURITY] Device mismatch | user: $userId | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||
self::abort(403, 'Device mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
// 8. HMAC — مطلوب للعمليات الحساسة (Wallet/Logout)
|
||||
$hmacHeader = $_SERVER['HTTP_X_HMAC_AUTH'] ?? null;
|
||||
if ($hmacHeader !== null) {
|
||||
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
|
||||
$nonce = $_SERVER['HTTP_X_NONCE'] ?? '';
|
||||
$body = file_get_contents('php://input') ?: '';
|
||||
|
||||
// التوقيع يضم الـ Body + Timestamp + Nonce لمنع التكرار والتلاعب
|
||||
$payloadToSign = $body . $timestamp . $nonce;
|
||||
$expectedHmac = hash_hmac('sha256', $payloadToSign, $this->hmacSecret);
|
||||
|
||||
if (!hash_equals($expectedHmac, $hmacHeader)) {
|
||||
error_log("[SECURITY] HMAC mismatch | user: $userId | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||
self::abort(403, 'Invalid HMAC signature');
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
// ── إلغاء توكن (Logout / Password Change) ──────────────
|
||||
public function revokeToken(string $jti, int $remainingTTL = 900): void
|
||||
{
|
||||
if ($this->redis && $jti) {
|
||||
$this->redis->setex("jwt:blacklist:$jti", $remainingTTL + 60, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal API Key — للـ get_connect.php ─────────────
|
||||
public static function validateInternalKey(): void
|
||||
{
|
||||
$keyPath = '/home/intaleq-api/.internal_socket_key';
|
||||
$sent = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? '';
|
||||
$expected = (file_exists($keyPath) ? trim(file_get_contents($keyPath)) : '') ?: 'Intaleq_Secure_Bridge_Key_2026_@!socket';
|
||||
|
||||
if (!$expected || !hash_equals($expected, $sent)) {
|
||||
error_log('[SECURITY] Invalid internal key from: ' . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized internal request']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static function abort(int $code, string $message): never
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode(['error' => $message]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
82
core/Auth/RateLimiter.php
Normal file
82
core/Auth/RateLimiter.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Auth/RateLimiter.php
|
||||
// Sliding Window Rate Limiting باستخدام Redis
|
||||
// ============================================================
|
||||
|
||||
class RateLimiter
|
||||
{
|
||||
private ?Redis $redis;
|
||||
|
||||
// حدود مختلفة لكل نوع endpoint
|
||||
private const LIMITS = [
|
||||
'login' => ['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة
|
||||
'otp' => ['requests' => 3, 'window' => 300], // 3 محاولات / 5 دقائق
|
||||
'register' => ['requests' => 3, 'window' => 3600], // 3 محاولات / ساعة
|
||||
'api' => ['requests' => 120, 'window' => 60], // 120 طلب / دقيقة
|
||||
'ride' => ['requests' => 30, 'window' => 60], // 30 طلب / دقيقة
|
||||
'upload' => ['requests' => 10, 'window' => 300], // 10 رفع / 5 دقائق
|
||||
];
|
||||
|
||||
public function __construct(?Redis $redis)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
}
|
||||
|
||||
// ── فحص الحد ─────────────────────────────────────────────
|
||||
// $identifier: IP:userId أو IP فقط
|
||||
// $type: login | otp | api | ride | upload
|
||||
public function check(string $identifier, string $type = 'api'): bool
|
||||
{
|
||||
if (!$this->redis) {
|
||||
return true; // بدون Redis نمرر (fallback)
|
||||
}
|
||||
|
||||
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
|
||||
$window = $limit['window'];
|
||||
$max = $limit['requests'];
|
||||
|
||||
$key = "rate:{$type}:{$identifier}";
|
||||
$current = $this->redis->incr($key);
|
||||
|
||||
if ($current === 1) {
|
||||
$this->redis->expire($key, $window);
|
||||
}
|
||||
|
||||
return $current <= $max;
|
||||
}
|
||||
|
||||
// ── تطبيق الحد وإيقاف الطلب إن تجاوز ─────────────────────
|
||||
public function enforce(string $identifier, string $type = 'api'): void
|
||||
{
|
||||
if (!$this->check($identifier, $type)) {
|
||||
$limit = self::LIMITS[$type] ?? self::LIMITS['api'];
|
||||
$window = $limit['window'];
|
||||
|
||||
error_log("[RATE_LIMIT] Blocked: $identifier | type: $type");
|
||||
|
||||
http_response_code(429);
|
||||
header("Retry-After: $window");
|
||||
echo json_encode([
|
||||
'error' => 'Too many requests. Please slow down.',
|
||||
'retry_after' => $window,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ── بناء معرّف المستخدم ────────────────────────────────────
|
||||
public static function identifier(?string $userId = null): string
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
return $userId ? "{$ip}:{$userId}" : $ip;
|
||||
}
|
||||
|
||||
// ── إعادة تعيين عداد (مثلاً بعد تسجيل دخول ناجح) ───────────
|
||||
public function reset(string $identifier, string $type = 'login'): void
|
||||
{
|
||||
if ($this->redis) {
|
||||
$this->redis->del("rate:{$type}:{$identifier}");
|
||||
}
|
||||
}
|
||||
}
|
||||
79
core/Database/Database.php
Normal file
79
core/Database/Database.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Database/Database.php — Lazy PDO Singleton (Refactored)
|
||||
// يدعم قواعد بيانات متعددة لكل منها Host/User/Pass مختلف
|
||||
// ============================================================
|
||||
|
||||
class Database
|
||||
{
|
||||
private static array $instances = [];
|
||||
|
||||
// خريطة الربط مع متغيرات البيئة (ENV)
|
||||
private static array $map = [
|
||||
'main' => [
|
||||
'name' => 'DB_PRIMARY_NAME_V2',
|
||||
'host' => 'DB_PRIMARY_HOST_V2',
|
||||
'user' => 'DB_PRIMARY_USER_V2',
|
||||
'pass' => 'DB_PRIMARY_PASS_V2',
|
||||
],
|
||||
'tracking' => [
|
||||
'name' => 'dbname_track',
|
||||
'host' => 'DB_TRACKING_HOST',
|
||||
'user' => 'DB_TRACKING_USER',
|
||||
'pass' => 'DB_TRACKING_PASS',
|
||||
],
|
||||
'ride' => [
|
||||
'name' => 'dbname_ride',
|
||||
'host' => 'DB_RIDE_HOST',
|
||||
'user' => 'DB_RIDE_USER',
|
||||
'pass' => 'DB_RIDE_PASS',
|
||||
],
|
||||
];
|
||||
|
||||
public static function get(string $name = 'main'): PDO
|
||||
{
|
||||
if (!isset(self::$instances[$name])) {
|
||||
self::$instances[$name] = self::connect($name);
|
||||
}
|
||||
return self::$instances[$name];
|
||||
}
|
||||
|
||||
private static function connect(string $name): PDO
|
||||
{
|
||||
if (!isset(self::$map[$name])) {
|
||||
throw new InvalidArgumentException("Unknown database: $name");
|
||||
}
|
||||
|
||||
$cfg = self::$map[$name];
|
||||
|
||||
$dbname = getenv($cfg['name']);
|
||||
$host = getenv($cfg['host']) ?: 'localhost';
|
||||
$user = getenv($cfg['user']);
|
||||
$pass = getenv($cfg['pass']);
|
||||
|
||||
if (!$dbname || !$user) {
|
||||
error_log("[FATAL] Database config missing for: $name (Check ENV keys: {$cfg['name']}, {$cfg['user']})");
|
||||
throw new RuntimeException("Database configuration error.");
|
||||
}
|
||||
|
||||
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
|
||||
$options = [
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_PERSISTENT => true,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
];
|
||||
|
||||
try {
|
||||
return new PDO($dsn, $user, $pass, $options);
|
||||
} catch (PDOException $e) {
|
||||
error_log("[DB] Connection failed ($name) at $host: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
}
|
||||
88
core/Security/EncryptionHelper.php
Normal file
88
core/Security/EncryptionHelper.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Security/EncryptionHelper.php
|
||||
// يدعم AES-256-GCM الجديد + AES-256-CBC القديم (توافقية)
|
||||
// ============================================================
|
||||
|
||||
class EncryptionHelper
|
||||
{
|
||||
private string $key;
|
||||
private string $cbcIv;
|
||||
private const ALGO_GCM = 'aes-256-gcm';
|
||||
private const ALGO_CBC = 'AES-256-CBC'; // للتوافقية
|
||||
private const IV_LEN_GCM = 12;
|
||||
private const TAG_LEN = 16;
|
||||
private const PREFIX_GCM = 'GCM:'; // للتمييز بين الجديد والقديم
|
||||
|
||||
public function __construct(string $key, ?string $cbcIv = null)
|
||||
{
|
||||
if (strlen($key) !== 32) {
|
||||
throw new InvalidArgumentException('Encryption key must be exactly 32 bytes.');
|
||||
}
|
||||
$this->key = $key;
|
||||
// IV القديم للتوافقية أثناء مرحلة المايغريشن
|
||||
$this->cbcIv = $cbcIv ?: getenv('initializationVector') ?: str_repeat('0', 16);
|
||||
}
|
||||
|
||||
// ─── تشفير نص (CBC مؤقتاً للتوافق التام كما طلب المستخدم) ──
|
||||
// سيتم تغييره لاحقاً لـ GCM بعد تفريغ قاعدة البيانات القديمة
|
||||
public function encryptData(string $plainText): string
|
||||
{
|
||||
// بناءً على طلب المستخدم: إبقاء التشفير الحالي CBC حتى نقوم بالترحيل لاحقاً
|
||||
$plainText = mb_convert_encoding($plainText, 'UTF-8');
|
||||
$paddedText = $this->addPadding($plainText);
|
||||
$encrypted = openssl_encrypt($paddedText, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
|
||||
return base64_encode($encrypted);
|
||||
}
|
||||
|
||||
// ─── فك تشفير نص (يدعم CBC والـ GCM المستقبلي) ───────────
|
||||
public function decryptData(string $cipherText): string|false
|
||||
{
|
||||
// تحقق إن كان مشفر بالنظام الجديد
|
||||
if (str_starts_with($cipherText, self::PREFIX_GCM)) {
|
||||
$raw = base64_decode(substr($cipherText, strlen(self::PREFIX_GCM)), true);
|
||||
if ($raw === false || strlen($raw) < self::IV_LEN_GCM + self::TAG_LEN) return false;
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LEN_GCM);
|
||||
$tag = substr($raw, self::IV_LEN_GCM, self::TAG_LEN);
|
||||
$cipher = substr($raw, self::IV_LEN_GCM + self::TAG_LEN);
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::ALGO_GCM, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
|
||||
return $plain !== false ? $plain : false;
|
||||
}
|
||||
|
||||
// وإلا استخدم CBC القديم
|
||||
$decoded = base64_decode($cipherText, true);
|
||||
if ($decoded === false) return false;
|
||||
|
||||
$decrypted = openssl_decrypt($decoded, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
|
||||
if ($decrypted === false) return false;
|
||||
|
||||
$pad = ord($decrypted[strlen($decrypted) - 1]);
|
||||
if ($pad < 1 || $pad > 16) return false;
|
||||
|
||||
return substr($decrypted, 0, -$pad);
|
||||
}
|
||||
|
||||
// ─── تشفير/فك تشفير Binary (صور، ملفات) ───────────────
|
||||
public function encryptBinary(string $data): string
|
||||
{
|
||||
return openssl_encrypt($data, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
|
||||
}
|
||||
|
||||
public function decryptBinary(string $data): string|false
|
||||
{
|
||||
return openssl_decrypt($data, self::ALGO_CBC, $this->key, OPENSSL_RAW_DATA, $this->cbcIv);
|
||||
}
|
||||
|
||||
// --------- دوال الـ Padding للـ CBC ----------
|
||||
private function addPadding($data, $blockSize = 16) {
|
||||
$pad = $blockSize - (strlen($data) % $blockSize);
|
||||
return $data . str_repeat(chr($pad), $pad);
|
||||
}
|
||||
|
||||
private function removePadding($data) {
|
||||
$pad = ord($data[strlen($data) - 1]);
|
||||
return substr($data, 0, -$pad);
|
||||
}
|
||||
}
|
||||
156
core/Services/FcmService.php
Normal file
156
core/Services/FcmService.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/FcmService.php
|
||||
// إرسال FCM مع كاش توكن في Redis (بدل ملف)
|
||||
// ============================================================
|
||||
|
||||
class FcmService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private string $serviceAccountFile;
|
||||
|
||||
public function __construct(?Redis $redis = null)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
// المسار بناء على بنية المشروع
|
||||
$this->serviceAccountFile = __DIR__ . '/../../service-account.json';
|
||||
}
|
||||
|
||||
// ── إرسال إشعار ────────────────────────────────────────
|
||||
public function send(
|
||||
string $token,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = [],
|
||||
string $category = 'Order',
|
||||
string $tone = 'ding'
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return ['status' => 'error', 'message' => 'No access token'];
|
||||
}
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
|
||||
|
||||
$finalData = array_merge($data, [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'tone' => $tone,
|
||||
'category' => $category,
|
||||
'type' => $category,
|
||||
]);
|
||||
|
||||
// FCM يشترط أن تكون كل القيم strings
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v)
|
||||
? json_encode($v, JSON_UNESCAPED_UNICODE)
|
||||
: (string)$v,
|
||||
$finalData
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'token' => $token,
|
||||
'data' => $processedData,
|
||||
'android' => ['priority' => 'HIGH'],
|
||||
'apns' => [
|
||||
'headers' => ['apns-priority' => '10', 'apns-push-type' => 'background'],
|
||||
'payload' => ['aps' => ['content-available' => 1]],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer $accessToken",
|
||||
'Content-Type: application/json; charset=UTF-8',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FRESH_CONNECT => false, // إعادة استخدام الاتصال
|
||||
CURLOPT_FORBID_REUSE => false,
|
||||
CURLOPT_TCP_KEEPALIVE => 1,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlErr) {
|
||||
return ['status' => 'error', 'message' => 'CURL error'];
|
||||
}
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── Access Token مع Redis Cache ─────────────────────────
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// 1. من Redis
|
||||
if ($this->redis) {
|
||||
$cached = $this->redis->get('google_fcm_access_token');
|
||||
if ($cached) return $cached;
|
||||
}
|
||||
|
||||
// 2. طلب جديد
|
||||
$token = $this->fetchGoogleToken();
|
||||
|
||||
if ($token && $this->redis) {
|
||||
$this->redis->setex('google_fcm_access_token', 3500, $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function fetchGoogleToken(): ?string
|
||||
{
|
||||
if (!file_exists($this->serviceAccountFile)) return null;
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$clientEmail = $creds['client_email'];
|
||||
$privateKey = $creds['private_key'];
|
||||
$now = time();
|
||||
|
||||
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$claim = rtrim(strtr(base64_encode(json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
])), '+/', '-_'), '=');
|
||||
|
||||
$signature = '';
|
||||
openssl_sign("$header.$claim", $signature, $privateKey, 'SHA256');
|
||||
$jwt = "$header.$claim." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return json_decode($res, true)['access_token'] ?? null;
|
||||
}
|
||||
}
|
||||
77
core/Services/OtpService.php
Normal file
77
core/Services/OtpService.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/OtpService.php
|
||||
// تخزين OTP في Redis بدلاً من MySQL (أسرع وأخف)
|
||||
// ============================================================
|
||||
|
||||
class OtpService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private const OTP_TTL = 300; // 5 دقائق
|
||||
private const MAX_ATTEMPTS = 3;
|
||||
private const LOCKOUT_TTL = 1800; // 30 دقيقة إذا تجاوز المحاولات
|
||||
|
||||
public function __construct(?Redis $redis)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
}
|
||||
|
||||
// ── توليد وحفظ OTP ─────────────────────────────────────
|
||||
public function generate(string $phone): string
|
||||
{
|
||||
// OTP آمن (6 أرقام عشوائية)
|
||||
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($this->redis) {
|
||||
$key = "otp:{$phone}";
|
||||
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
|
||||
// إعادة تعيين عداد المحاولات
|
||||
$this->redis->del("otp:attempts:{$phone}");
|
||||
}
|
||||
|
||||
return $otp;
|
||||
}
|
||||
|
||||
// ── التحقق من OTP ───────────────────────────────────────
|
||||
public function verify(string $phone, string $inputOtp): bool
|
||||
{
|
||||
if (!$this->redis) return false;
|
||||
|
||||
// فحص الـ lockout
|
||||
if ($this->redis->exists("otp:locked:{$phone}")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = "otp:{$phone}";
|
||||
$stored = $this->redis->get($key);
|
||||
|
||||
if (!$stored) {
|
||||
return false; // انتهت صلاحية الـ OTP
|
||||
}
|
||||
|
||||
$attemptsKey = "otp:attempts:{$phone}";
|
||||
|
||||
if (!password_verify($inputOtp, $stored)) {
|
||||
$attempts = $this->redis->incr($attemptsKey);
|
||||
$this->redis->expire($attemptsKey, self::OTP_TTL);
|
||||
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
// قفل لمدة 30 دقيقة
|
||||
$this->redis->setex("otp:locked:{$phone}", self::LOCKOUT_TTL, '1');
|
||||
$this->redis->del($key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// نجح التحقق — احذف الـ OTP
|
||||
$this->redis->del($key);
|
||||
$this->redis->del($attemptsKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── فحص هل الرقم مقفل ──────────────────────────────────
|
||||
public function isLocked(string $phone): bool
|
||||
{
|
||||
return $this->redis && (bool)$this->redis->exists("otp:locked:{$phone}");
|
||||
}
|
||||
}
|
||||
90
core/bootstrap.php
Normal file
90
core/bootstrap.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/bootstrap.php
|
||||
// البوابة الرئيسية الموحدة لكل التطبيق
|
||||
// ============================================================
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 1. إعدادات الأخطاء والـ Headers الأساسية
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// تحديد مسار اللوج بشكل ديناميكي (محلياً أو سيرفر)
|
||||
$logPath = '/home/intaleq-api/logs/php_errors.log';
|
||||
if (!file_exists(dirname($logPath)) || !is_writable(dirname($logPath))) {
|
||||
$logPath = __DIR__ . '/../logs/php_errors.log';
|
||||
}
|
||||
ini_set('error_log', $logPath);
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
|
||||
// CORS (يجب تخصيصه في endpoints مخصصة إن لزم، لكن هذا افتراضي)
|
||||
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-FP, X-HMAC-Auth, X-Internal-Key');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Autoload
|
||||
$vendorPath = realpath(__DIR__ . '/../../vendor/autoload.php');
|
||||
if ($vendorPath) require_once $vendorPath;
|
||||
|
||||
// 3. Helpers & Env
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
|
||||
// تحديد مسار الـ .env بشكل ديناميكي
|
||||
$envFile = '/home/intaleq-api/env/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
$envFile = __DIR__ . '/../.env'; // مسار محلي افتراضي
|
||||
}
|
||||
loadEnvironment($envFile);
|
||||
|
||||
// 4. Redis Connection (Singleton)
|
||||
$redis = null;
|
||||
try {
|
||||
if (extension_loaded('redis')) {
|
||||
$redis = new Redis();
|
||||
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
|
||||
$redisPass = getenv('REDIS_PASSWORD');
|
||||
|
||||
if ($redis->connect($redisHost, $redisPort, 1.5)) {
|
||||
if ($redisPass) $redis->auth($redisPass);
|
||||
$redis->setOption(Redis::OPT_PREFIX, 'intaleq:');
|
||||
} else {
|
||||
$redis = null;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[REDIS] Connection failed: " . $e->getMessage());
|
||||
$redis = null;
|
||||
}
|
||||
|
||||
// 5. تحميل الـ Services الأساسية
|
||||
require_once __DIR__ . '/Security/EncryptionHelper.php';
|
||||
require_once __DIR__ . '/Database/Database.php';
|
||||
require_once __DIR__ . '/Auth/RateLimiter.php';
|
||||
require_once __DIR__ . '/Auth/JwtService.php';
|
||||
// لا نحمّل OtpService و FcmService إلا عند الحاجة (Lazy)
|
||||
|
||||
// 6. تهيئة Encryption Helper العام (للتوافقية)
|
||||
// يتم استخدام .enckey (32 بايت) لتشفير البيانات
|
||||
$encKey = trim(@file_get_contents('/home/intaleq-api/.enckey') ?: '');
|
||||
if (!$encKey) {
|
||||
$encKey = getenv('ENC_KEY') ?: '';
|
||||
}
|
||||
|
||||
if (!$encKey || strlen($encKey) !== 32) {
|
||||
error_log("[FATAL] Encryption key (.enckey) is missing or invalid length (must be 32 bytes).");
|
||||
http_response_code(500);
|
||||
exit(json_encode(['error' => 'Server configuration error: Encryption key issue']));
|
||||
}
|
||||
|
||||
$encryptionHelper = new EncryptionHelper($encKey);
|
||||
178
core/helpers.php
Normal file
178
core/helpers.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/helpers.php — دوال مساعدة موحدة
|
||||
// ============================================================
|
||||
|
||||
// ── فلترة المدخلات (محسّنة) ─────────────────────────────────
|
||||
function filterRequest(string $name, string $type = 'string'): mixed
|
||||
{
|
||||
// قراءة من POST أو JSON body
|
||||
$value = null;
|
||||
|
||||
if (isset($_POST[$name]) && $_POST[$name] !== '') {
|
||||
$value = $_POST[$name];
|
||||
} else {
|
||||
// محاولة قراءة من JSON body
|
||||
static $jsonBody = null;
|
||||
if ($jsonBody === null) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$jsonBody = json_decode($raw, true) ?? [];
|
||||
}
|
||||
$value = $jsonBody[$name] ?? null;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') return null;
|
||||
|
||||
$value = trim((string)$value);
|
||||
|
||||
// إزالة control characters
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
|
||||
|
||||
return match ($type) {
|
||||
'int' => filter_var($value, FILTER_VALIDATE_INT) !== false ? (int)$value : null,
|
||||
'float' => filter_var($value, FILTER_VALIDATE_FLOAT) !== false ? (float)$value : null,
|
||||
'email' => filter_var($value, FILTER_VALIDATE_EMAIL) ?: null,
|
||||
'url' => filter_var($value, FILTER_VALIDATE_URL) ?: null,
|
||||
'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
|
||||
default => $value, // string — بدون htmlspecialchars (نتركه لـ PDO)
|
||||
};
|
||||
}
|
||||
|
||||
// ── ردود JSON موحدة ─────────────────────────────────────────
|
||||
function jsonSuccess(mixed $data = null, string $message = 'success', int $code = 200): never
|
||||
{
|
||||
http_response_code($code);
|
||||
// توحيد الأسلوب ليكون متوافقاً مع الكود القديم (وضع البيانات في message)
|
||||
$payload = ($data !== null && (!empty($data) || is_array($data))) ? $data : $message;
|
||||
echo json_encode(['status' => 'success', 'message' => $payload], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonError(string $message, int $code = 400, mixed $extra = null): never
|
||||
{
|
||||
http_response_code($code);
|
||||
$response = ['status' => 'failure', 'message' => $message];
|
||||
if ($extra !== null) $response['details'] = $extra;
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
// (للتوافق مع الكود القديم)
|
||||
function printSuccess(mixed $message = 'success'): void
|
||||
{
|
||||
echo json_encode(['status' => 'success', 'message' => $message], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
function printFailure(mixed $message = 'failure'): void
|
||||
{
|
||||
echo json_encode(['status' => 'failure', 'message' => $message], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
function result(int $count): void
|
||||
{
|
||||
if ($count > 0) {
|
||||
printSuccess();
|
||||
} else {
|
||||
printFailure();
|
||||
}
|
||||
}
|
||||
function sendEmail(string $from, string $to, string $title, string $body): void
|
||||
{
|
||||
$header = "From: $from\nCC: $from";
|
||||
mail($to, $title, $body, $header);
|
||||
}
|
||||
|
||||
// ── رفع صورة آمن ──────────────────────────────────────────────
|
||||
function uploadImageSecure(
|
||||
string $fileKey,
|
||||
string $targetDir,
|
||||
string $prefix = '',
|
||||
array $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']
|
||||
): array {
|
||||
if (!isset($_FILES[$fileKey]) || $_FILES[$fileKey]['error'] !== UPLOAD_ERR_OK) {
|
||||
return ['success' => false, 'error' => 'File upload error'];
|
||||
}
|
||||
|
||||
$file = $_FILES[$fileKey];
|
||||
$maxSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// حجم الملف
|
||||
if ($file['size'] > $maxSize) {
|
||||
return ['success' => false, 'error' => 'File too large (max 5MB)'];
|
||||
}
|
||||
|
||||
// MIME validation حقيقي (ليس extension فقط)
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
|
||||
if (!in_array($mimeType, $allowedMimes, true)) {
|
||||
return ['success' => false, 'error' => "Invalid file type: $mimeType"];
|
||||
}
|
||||
|
||||
// اسم ملف آمن وعشوائي
|
||||
$ext = match ($mimeType) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'bin',
|
||||
};
|
||||
$filename = ($prefix ? "{$prefix}_" : '') . bin2hex(random_bytes(8)) . ".$ext";
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0750, true);
|
||||
}
|
||||
|
||||
$targetPath = rtrim($targetDir, '/') . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'filename' => $filename, 'path' => $targetPath];
|
||||
}
|
||||
|
||||
// ── تحميل ملف .env ───────────────────────────────────────────
|
||||
function loadEnvironment(string $path): void
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
error_log("[ENV] File not found: $path");
|
||||
return;
|
||||
}
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), '#')) continue;
|
||||
if (!str_contains($line, '=')) continue;
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||
if ($key && !getenv($key)) {
|
||||
putenv("$key=$value");
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging منظم ──────────────────────────────────────────────
|
||||
function securityLog(string $message, array $context = []): void
|
||||
{
|
||||
$logDir = __DIR__ . '/../logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0777, true);
|
||||
}
|
||||
$entry = date('Y-m-d H:i:s') . ' [SECURITY] ' . $message;
|
||||
if ($context) $entry .= ' | ' . json_encode($context, JSON_UNESCAPED_UNICODE);
|
||||
@error_log($entry . PHP_EOL, 3, $logDir . '/security.log');
|
||||
}
|
||||
|
||||
function appLog(string $message, string $level = 'INFO'): void
|
||||
{
|
||||
$logDir = __DIR__ . '/../logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0777, true);
|
||||
}
|
||||
$entry = date('Y-m-d H:i:s') . " [$level] " . $message;
|
||||
@error_log($entry . PHP_EOL, 3, $logDir . '/app.log');
|
||||
}
|
||||
|
||||
function debugLog(string $message): void
|
||||
{
|
||||
appLog($message, 'DEBUG');
|
||||
}
|
||||
Reference in New Issue
Block a user