120 lines
3.6 KiB
PHP
120 lines
3.6 KiB
PHP
<?php
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
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);
|
|
}
|
|
|
|
$db = Database::getInstance();
|
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
|
|
|
$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 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'],
|
|
'role' => $user['role'],
|
|
'device_id' => $isMobile ? $deviceId : null,
|
|
'source' => $isMobile ? 'mobile' : 'web',
|
|
'exp' => time() + ($isMobile ? (30 * 24 * 3600) : (15 * 60)),
|
|
];
|
|
|
|
$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,
|
|
// Web keeps receiving it via the HttpOnly cookie only.
|
|
'refresh_token' => $isMobile ? $newRefreshToken : null,
|
|
]);
|