Close login gap

This commit is contained in:
Thanakorn S
2026-05-25 15:34:12 +07:00
parent 8027ab569e
commit 3c9475f3fa
2 changed files with 28 additions and 27 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ require_once '../../../assets/utils/db_auth.php';
// Clear session token so the account is free to log in elsewhere immediately
if (!empty($_SESSION['login_user_id'])) {
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid")
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL, session_last_seen = NULL WHERE user_id = :uid")
->execute([':uid' => (int)$_SESSION['login_user_id']]);
}
+27 -26
View File
@@ -95,29 +95,7 @@ $otp_diff_minutes = $otp_diff_seconds / 60.0;
$_SESSION["now"] = $now;
$_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Block login if another session is already active ─────────────────
// If session_token is non-NULL AND was set within the last 8 hours, another
// session is active — reject. Tokens older than 8 hours are treated as
// abandoned (browser crash, PHP GC expiry, etc.) and cleared automatically.
$sth_token = $pdo1->prepare("SELECT session_token, session_last_seen FROM user WHERE user_id = :uid LIMIT 1");
$sth_token->execute([':uid' => $user_id]);
$token_row = $sth_token->fetch(PDO::FETCH_ASSOC);
$existing_token = $token_row['session_token'] ?? null;
if (!empty($existing_token)) {
$idle_seconds = PHP_INT_MAX;
if (!empty($token_row['session_last_seen'])) {
$idle_seconds = time() - strtotime($token_row['session_last_seen']);
}
if ($idle_seconds < (int)ini_get('session.gc_maxlifetime')) {
$answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end.";
exit(json_encode($answer));
}
// PHP GC has expired this session — clear token and allow login
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL, session_last_seen = NULL WHERE user_id = :uid")
->execute([':uid' => $user_id]);
}
// ── Step 4b: Validate OTP value and expiry ────────────────────────────────────
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
// so they never receive or enter an OTP. Admin/owner always go through this check.
if (empty($_SESSION['skip_otp'])) {
@@ -127,10 +105,33 @@ if (empty($_SESSION['skip_otp'])) {
}
}
// ── Step 4c: Claim session — write token so no one else can log in ────────────
// ── Step 4b: Claim session atomically ─────────────────────────────────────────
// The WHERE clause is the concurrency gate: only a free or stale token can be
// replaced. session_last_seen is set immediately so a fresh login is live before
// the first authenticated heartbeat in db_auth.php.
$session_token = bin2hex(random_bytes(32));
$pdo1->prepare("UPDATE user SET session_token = :token, session_token_at = NOW() WHERE user_id = :uid")
->execute([':token' => $session_token, ':uid' => $user_id]);
$sth_claim = $pdo1->prepare(
"UPDATE user
SET session_token = :token,
session_token_at = NOW(),
session_last_seen = NOW()
WHERE user_id = :uid
AND (
session_token IS NULL
OR session_last_seen IS NULL
OR TIMESTAMPDIFF(SECOND, session_last_seen, NOW()) >= :gc_maxlifetime
)"
);
$sth_claim->execute([
':token' => $session_token,
':uid' => $user_id,
':gc_maxlifetime' => (int)ini_get('session.gc_maxlifetime'),
]);
if ($sth_claim->rowCount() !== 1) {
$answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end.";
exit(json_encode($answer));
}
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
// session_regenerate_id(true) issues a brand-new session ID and deletes the old