Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
+29 -5
View File
@@ -40,7 +40,12 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
}
$deviceId = $data['device_id'] ?? null;
$isReviewer = (strtolower($email) === 'reviewer@musadaq.jo');
// App-store reviewer account skips the WhatsApp OTP step (reviewers have no
// access to the registered phone). Configured via .env so the exception is not
// baked into the source, and disabled entirely when the var is unset.
$reviewerEmail = strtolower(trim((string)env('REVIEWER_EMAIL', '')));
$isReviewer = $reviewerEmail !== '' && strtolower($email) === $reviewerEmail;
if ($deviceId && !$isReviewer) {
// Generate and send WhatsApp OTP
@@ -127,7 +132,9 @@ if ($deviceId) {
$deviceName,
$data['platform'] ?? 'web',
$data['app_version'] ?? '1.0.0',
password_hash($deviceSecret, PASSWORD_DEFAULT),
// Stored encrypted, NOT bcrypt-hashed: the server must be able to
// recompute the client's HMAC signature from this same secret.
\App\Core\Encryption::encrypt($deviceSecret),
]);
}
@@ -152,11 +159,28 @@ $payload = [
$token = JWT::encode($payload, $secret);
// 5. Update Refresh Token (Hashed before storage for security)
// 5. Issue Refresh Token (hashed before storage).
//
// Mobile tokens are stored PER DEVICE. users.refresh_token_hash is a single
// column, so writing there logged the user out of every other device silently.
$refreshToken = bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
$stmt->execute([$refreshTokenHash, $user['id']]);
$refreshTtlDays = $deviceId ? 60 : 7;
$refreshExpiresAt = date('Y-m-d H:i:s', time() + ($refreshTtlDays * 24 * 3600));
if ($deviceId) {
$stmt = $db->prepare("
UPDATE user_devices
SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW()
WHERE user_id = ? AND device_fingerprint = ?
");
$stmt->execute([$refreshTokenHash, $refreshExpiresAt, $user['id'], $deviceId]);
$db->prepare("UPDATE users SET last_login_at = NOW() WHERE id = ?")->execute([$user['id']]);
} else {
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
$stmt->execute([$refreshTokenHash, $user['id']]);
}
// 6. Secure Refresh Token delivery via HttpOnly Cookie (for web)
if (!$deviceId) {
+20 -3
View File
@@ -10,9 +10,26 @@ use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$userId = $decoded['user_id'];
// 2. Invalidate Refresh Token
// 2. Invalidate the refresh token.
// A mobile logout must only sign THIS device out - clearing the shared user
// column would drop every other device the user owns.
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?");
$stmt->execute([$userId]);
$deviceId = $decoded['device_id'] ?? null;
if ($deviceId) {
$stmt = $db->prepare("
UPDATE user_devices
SET refresh_token_hash = NULL,
refresh_expires_at = NULL,
push_token = NULL,
live_activity_token = NULL,
is_trusted = 0
WHERE user_id = ? AND device_fingerprint = ?
");
$stmt->execute([$userId, $deviceId]);
} else {
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?");
$stmt->execute([$userId]);
}
json_success(null, 'تم تسجيل الخروج بنجاح');
+10 -5
View File
@@ -64,8 +64,12 @@ try {
exit;
}
// A disabled account must produce the SAME answer as an unknown number,
// otherwise this endpoint tells an attacker which phones are registered.
if (!$user['is_active']) {
json_error('الحساب معطّل. تواصل مع المسؤول.', 403);
error_log("OTP request for disabled account: user {$user['id']}");
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق');
exit;
}
// 3. Generate OTP (6 digits)
@@ -102,16 +106,17 @@ try {
$result = $whatsappService->sendMessage($phone, $message);
if (!$result['success']) {
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500, ['whatsapp_debug' => $result]);
// 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));
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500);
}
// Log for development (REMOVE IN PRODUCTION!)
// Development only - never reached when APP_DEBUG is false.
if (env('APP_DEBUG', 'false') === 'true') {
error_log("DEV OTP for {$phone}: {$otp}");
}
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
} catch (\Exception $e) {
safe_error($e, 'auth/mobile_request_otp');
+15 -4
View File
@@ -132,7 +132,9 @@ $stmt->execute([
$platform,
$appVersion,
$pushToken,
password_hash($deviceSecret, PASSWORD_DEFAULT), // Store hashed
// Stored encrypted (reversible), not bcrypt-hashed: HmacMiddleware has to
// recompute the same signature the client produced from this secret.
\App\Core\Encryption::encrypt($deviceSecret),
]);
// 6. Generate JWT (30 days for mobile)
@@ -153,11 +155,20 @@ $payload = [
$token = JWT::encode($payload, $secret);
// 7. Generate refresh token
// 7. Generate refresh token, stored against THIS device so signing in on a
// second phone does not silently invalidate the first one.
$refreshToken = bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
$stmt->execute([$refreshTokenHash, $userId]);
$refreshExpiresAt = date('Y-m-d H:i:s', time() + (60 * 24 * 3600)); // 60 days
$stmt = $db->prepare("
UPDATE user_devices
SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW()
WHERE user_id = ? AND device_fingerprint = ?
");
$stmt->execute([$refreshTokenHash, $refreshExpiresAt, $userId, $deviceId]);
$db->prepare("UPDATE users SET last_login_at = NOW() WHERE id = ?")->execute([$userId]);
// 8. Decrypt name for response
$userName = $user['name'];
+90 -16
View File
@@ -1,13 +1,30 @@
<?php
/**
* Refresh Token Endpoint (Secure Cookie Based)
* Refresh Token Endpoint
*
* Two callers, two transports:
* - Web: HttpOnly cookie, token stored on users.refresh_token_hash
* - Mobile: JSON body {refresh_token, device_id}, token stored per device on
* user_devices.refresh_token_hash
*
* The mobile path used to be missing entirely (cookies only), so the app had no
* way to refresh and simply started failing with 401s once the JWT aged out.
*/
use App\Core\Database;
use Firebase\JWT\JWT;
declare(strict_types=1);
// 1. Get Refresh Token from HttpOnly Cookie
$refreshToken = $_COOKIE['refresh_token'] ?? null;
use App\Core\Database;
use App\Core\JWT;
use App\Core\Security;
use App\Middleware\RateLimitMiddleware;
// Refresh is unauthenticated by design, so rate limit it.
RateLimitMiddleware::check(20, 60);
$data = Security::sanitize(input());
$refreshToken = $_COOKIE['refresh_token'] ?? ($data['refresh_token'] ?? null);
$deviceId = $data['device_id'] ?? null;
if (!$refreshToken) {
json_error('Refresh token is required', 401);
@@ -16,30 +33,87 @@ if (!$refreshToken) {
$db = Database::getInstance();
$refreshTokenHash = hash('sha256', $refreshToken);
// 2. Verify in DB
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$refreshTokenHash]);
$user = $stmt->fetch();
$user = null;
$isMobile = false;
// 1. Mobile: per-device lookup.
if ($deviceId) {
$stmt = $db->prepare("
SELECT u.*, d.device_fingerprint, d.refresh_expires_at
FROM user_devices d
JOIN users u ON u.id = d.user_id
WHERE d.refresh_token_hash = ?
AND d.device_fingerprint = ?
AND d.is_trusted = 1
AND u.is_active = 1
LIMIT 1
");
$stmt->execute([$refreshTokenHash, $deviceId]);
$user = $stmt->fetch();
if ($user) {
$isMobile = true;
if (!empty($user['refresh_expires_at']) && strtotime($user['refresh_expires_at']) < time()) {
json_error('انتهت صلاحية الجلسة. يرجى تسجيل الدخول من جديد.', 401);
}
}
}
// 2. Web: single-column lookup.
if (!$user) {
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$refreshTokenHash]);
$user = $stmt->fetch();
}
if (!$user) {
json_error('Invalid refresh token', 401);
}
// 3. Generate New Access Token
$secret = $_ENV['JWT_SECRET'] ?? null;
if (!$secret) {
// 3. Generate a new access token.
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500);
}
$payload = [
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'], // Now including tenant_id
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes
'device_id' => $isMobile ? $deviceId : null,
'source' => $isMobile ? 'mobile' : 'web',
'exp' => time() + ($isMobile ? (30 * 24 * 3600) : (15 * 60)),
];
$token = JWT::encode($payload, $secret, 'HS256');
$token = JWT::encode($payload, $secret);
// 4. Rotate the refresh token so a leaked one cannot be replayed indefinitely.
$newRefreshToken = bin2hex(random_bytes(32));
$newHash = hash('sha256', $newRefreshToken);
if ($isMobile) {
$expiresAt = date('Y-m-d H:i:s', time() + (60 * 24 * 3600));
$db->prepare("
UPDATE user_devices
SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW()
WHERE user_id = ? AND device_fingerprint = ?
")->execute([$newHash, $expiresAt, $user['id'], $deviceId]);
} else {
$db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?")
->execute([$newHash, $user['id']]);
setcookie('refresh_token', $newRefreshToken, [
'expires' => time() + (7 * 24 * 60 * 60),
'path' => '/api/v1/auth/refresh',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
}
json_success([
'access_token' => $token
'access_token' => $token,
// Web keeps receiving it via the HttpOnly cookie only.
'refresh_token' => $isMobile ? $newRefreshToken : null,
]);
+8
View File
@@ -32,6 +32,14 @@ if (isset($data['push_token'])) {
$params[] = $data['push_token'];
}
// ActivityKit push token for the currently running Live Activity (iOS).
// This is a DIFFERENT token from the FCM registration token above: APNs
// liveactivity pushes must target this one specifically.
if (isset($data['live_activity_token'])) {
$updateFields[] = 'live_activity_token = ?';
$params[] = $data['live_activity_token'];
}
if (isset($data['app_version'])) {
$updateFields[] = 'app_version = ?';
$params[] = $data['app_version'];