feat(auth): implement token-based tab session isolation using JWT
This commit is contained in:
@@ -41,8 +41,12 @@ if ($action === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
auditLog($db, (int)$user['id'], 'LOGIN', 'users', (int)$user['id']);
|
auditLog($db, (int)$user['id'], 'LOGIN', 'users', (int)$user['id']);
|
||||||
|
|
||||||
|
// Generate JWT token for tab-session isolation
|
||||||
|
$token = jwt_encode($_SESSION['user'], JWT_SECRET);
|
||||||
|
|
||||||
jsonResponse([
|
jsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
'token' => $token,
|
||||||
'user' => $_SESSION['user']
|
'user' => $_SESSION['user']
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ define('DB_CHARSET', 'utf8mb4');
|
|||||||
// ── Upload Config ─────────────────────────────
|
// ── Upload Config ─────────────────────────────
|
||||||
define('UPLOAD_DIR', __DIR__ . '/../uploads/');
|
define('UPLOAD_DIR', __DIR__ . '/../uploads/');
|
||||||
define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); // 5MB
|
define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); // 5MB
|
||||||
|
define('JWT_SECRET', 'a_very_secure_random_key_for_webgis_poverty_mapping_2026');
|
||||||
|
|
||||||
// ── Session ───────────────────────────────────
|
// ── Session ───────────────────────────────────
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -52,11 +53,70 @@ function jsonResponse(array $data, int $code = 200): void {
|
|||||||
|
|
||||||
// ── Auth Helpers ──────────────────────────────
|
// ── Auth Helpers ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Helper functions for tab-session isolation
|
||||||
|
*/
|
||||||
|
function jwt_encode(array $payload, string $secret): string {
|
||||||
|
$header = json_encode(['alg' => 'HS256', 'typ' => 'JWT']);
|
||||||
|
$base64UrlHeader = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($header));
|
||||||
|
|
||||||
|
$payloadJson = json_encode($payload);
|
||||||
|
$base64UrlPayload = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($payloadJson));
|
||||||
|
|
||||||
|
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
|
||||||
|
$base64UrlSignature = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature));
|
||||||
|
|
||||||
|
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jwt_decode(string $jwt, string $secret): ?array {
|
||||||
|
$tokenParts = explode('.', $jwt);
|
||||||
|
if (count($tokenParts) !== 3) return null;
|
||||||
|
|
||||||
|
$header = base64_decode(str_replace(['-', '_'], ['+', '/'], $tokenParts[0]));
|
||||||
|
$payload = base64_decode(str_replace(['-', '_'], ['+', '/'], $tokenParts[1]));
|
||||||
|
$signatureProvided = $tokenParts[2];
|
||||||
|
|
||||||
|
// Check signature
|
||||||
|
$base64UrlHeader = $tokenParts[0];
|
||||||
|
$base64UrlPayload = $tokenParts[1];
|
||||||
|
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
|
||||||
|
$base64UrlSignature = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature));
|
||||||
|
|
||||||
|
if ($base64UrlSignature !== $signatureProvided) return null;
|
||||||
|
|
||||||
|
return json_decode($payload, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorizationHeader(): string {
|
||||||
|
$headers = null;
|
||||||
|
if (isset($_SERVER['Authorization'])) {
|
||||||
|
$headers = trim($_SERVER["Authorization"]);
|
||||||
|
} else if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
|
$headers = trim($_SERVER["HTTP_AUTHORIZATION"]);
|
||||||
|
} else if (function_exists('apache_request_headers')) {
|
||||||
|
$requestHeaders = apache_request_headers();
|
||||||
|
$requestHeaders = array_change_key_case($requestHeaders, CASE_LOWER);
|
||||||
|
if (isset($requestHeaders['authorization'])) {
|
||||||
|
$headers = trim($requestHeaders['authorization']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $headers ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cek apakah user sudah login.
|
* Cek apakah user sudah login.
|
||||||
* Return data user atau null.
|
* Return data user atau null.
|
||||||
*/
|
*/
|
||||||
function getCurrentUser(): ?array {
|
function getCurrentUser(): ?array {
|
||||||
|
$authHeader = getAuthorizationHeader();
|
||||||
|
if (preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
$payload = jwt_decode($token, JWT_SECRET);
|
||||||
|
if ($payload) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
return $_SESSION['user'] ?? null;
|
return $_SESSION['user'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -11,7 +11,7 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="style.css?v=1.6">
|
<link rel="stylesheet" href="style.css?v=1.7">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -259,6 +259,22 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.heat/0.2.0/leaflet-heat.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.heat/0.2.0/leaflet-heat.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// Global fetch interceptor to append JWT token for tab-session isolation
|
||||||
|
(function() {
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = function(url, options) {
|
||||||
|
options = options || {};
|
||||||
|
options.headers = options.headers || {};
|
||||||
|
const token = sessionStorage.getItem('user_token');
|
||||||
|
if (token) {
|
||||||
|
if (!options.headers['Authorization'] && !options.headers['authorization']) {
|
||||||
|
options.headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalFetch(url, options);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
// --
|
// --
|
||||||
// WebGIS Poverty Mapping Main App
|
// WebGIS Poverty Mapping Main App
|
||||||
// --
|
// --
|
||||||
@@ -417,6 +433,9 @@
|
|||||||
await fetch(API.auth + '?action=logout', { method: 'POST', credentials: 'include' });
|
await fetch(API.auth + '?action=logout', { method: 'POST', credentials: 'include' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (res.token) {
|
||||||
|
sessionStorage.setItem('user_token', res.token);
|
||||||
|
}
|
||||||
currentUser = res.user;
|
currentUser = res.user;
|
||||||
await showApp();
|
await showApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -433,6 +452,7 @@
|
|||||||
|
|
||||||
await fetch(API.auth + '?action=logout', { method: 'POST', credentials: 'include' });
|
await fetch(API.auth + '?action=logout', { method: 'POST', credentials: 'include' });
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
|
sessionStorage.removeItem('user_token');
|
||||||
if (location.hash) {
|
if (location.hash) {
|
||||||
history.replaceState(null, null, ' ');
|
history.replaceState(null, null, ' ');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user