312 lines
12 KiB
PHP
312 lines
12 KiB
PHP
<?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 = (string)(getenv('APP_ISSUER') ?: '');
|
|
$this->redis = $redis;
|
|
|
|
// Debugging fpPepper
|
|
if (empty($this->fpPepper)) {
|
|
error_log("[JWT_DEBUG] fpPepper is EMPTY in constructor");
|
|
} else {
|
|
error_log("[JWT_DEBUG] fpPepper is SET (length: " . strlen($this->fpPepper) . ")");
|
|
}
|
|
}
|
|
|
|
|
|
// ── توليد 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;
|
|
} elseif ($role === 'service') {
|
|
$ttl = 14400; // 4 hours as requested
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
$token = JWT::encode($payload, $this->secretKey, self::ALGO);
|
|
|
|
// تخزين في Redis لضمان عدم التكرار وإمكانية الإلغاء
|
|
if ($this->redis) {
|
|
$this->redis->setex("active_jti:{$userId}", $ttl, $jti);
|
|
$this->redis->setex("active_token:{$userId}:{$audience}", $ttl, $token);
|
|
}
|
|
|
|
return $token;
|
|
}
|
|
|
|
// ── فك تشفير التوكن للتحقق الداخلي ────────────────────────
|
|
public function decodeToken(string $token): ?object
|
|
{
|
|
try {
|
|
return JWT::decode($token, new Key($this->secretKey, self::ALGO));
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
// ── توليد 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 (Only check if configured)
|
|
if (!empty($this->issuer) && ($decoded->iss ?? '') !== $this->issuer) {
|
|
self::abort(401, 'Invalid token issuer: expected ' . $this->issuer . ' but got ' . ($decoded->iss ?? 'none'));
|
|
}
|
|
|
|
// 3.1 App Signature Verification (Security Layer)
|
|
$appSignature = $_SERVER['HTTP_X_APP_SIGNATURE'] ?? null;
|
|
if ($appSignature === null && function_exists('getallheaders')) {
|
|
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$appSignature = $headers['x-app-signature'] ?? null;
|
|
}
|
|
|
|
// قائمة البصمات المعتمدة لكل تطبيق (يجب تعبئتها من ملف .env)
|
|
// APP_SIGNATURE_SERVICE, APP_SIGNATURE_DRIVER, APP_SIGNATURE_PASSENGER
|
|
$role = $decoded->role ?? 'unknown';
|
|
$envKey = 'APP_SIGNATURE_' . strtoupper($role);
|
|
$expectedSignature = getenv($envKey) ?: getenv('APP_SIGNATURE_HASH');
|
|
|
|
if (!empty($expectedSignature)) {
|
|
if ($appSignature === null || !hash_equals($expectedSignature, $appSignature)) {
|
|
error_log("[SECURITY_ERROR] App Signature Mismatch/Missing! Role: $role | Expected: $expectedSignature | Got: " . ($appSignature ?? 'NONE') . " | User: $userId");
|
|
|
|
// الحظر النهائي: إذا كانت البصمة خاطئة، نرفض الطلب فوراً
|
|
self::abort(403, 'App integrity check failed. Please update your app.');
|
|
}
|
|
} else {
|
|
// في حال لم يتم ضبط البصمة لهذا النوع من المستخدمين بعد، نسجلها فقط لتسهيل الإعداد
|
|
error_log("[SECURITY_INFO] Incoming App Signature for $role: " . ($appSignature ?? 'NONE') . " | User: $userId");
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
// محاولة جلب الهيدر بطرق بديلة إذا لم يوجد في $_SERVER
|
|
if ($fpHeader === null && function_exists('getallheaders')) {
|
|
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$fpHeader = $headers['x-device-fp'] ?? null;
|
|
}
|
|
|
|
if ($fpInToken === null || $fpHeader === null) {
|
|
|
|
$allHeaders = json_encode(getallheaders());
|
|
error_log("[SECURITY] Fingerprint missing | user: $userId | fpInToken: " . ($fpInToken ?? 'NULL') . " | fpHeader: " . ($fpHeader ?? 'NULL') . " | Headers: $allHeaders");
|
|
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 = getenv('INTERNAL_SOCKET_KEY_PATH');
|
|
$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;
|
|
}
|
|
}
|
|
|
|
public function getFpPepper(): string
|
|
{
|
|
return $this->fpPepper;
|
|
}
|
|
|
|
private static function abort(int $code, string $message)
|
|
|
|
{
|
|
error_log("[JWT_AUTH_FAILED] Code: $code | Message: $message | IP: " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " | URI: " . ($_SERVER['REQUEST_URI'] ?? '?'));
|
|
http_response_code($code);
|
|
echo json_encode(['error' => $message]);
|
|
exit;
|
|
}
|
|
}
|