Files
intaleq_v3_pure_php/core/Auth/JwtService.php
2026-05-02 16:19:07 +03:00

318 lines
13 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') ?: '';
// نشتق مفتاح الـ HMAC الخاص بهذا المستخدم (نفس المعادلة في login.php)
$derivedSecret = hash_hmac('sha256', (string)$userId, $this->hmacSecret);
// التوقيع يضم الـ Body + Timestamp + Nonce لمنع التكرار والتلاعب
$payloadToSign = $body . $timestamp . $nonce;
$expectedHmac = hash_hmac('sha256', $payloadToSign, $derivedSecret);
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;
}
}