diff --git a/app/login/api/engine/back.php b/app/login/api/engine/back.php index a812d54..9b5daac 100644 --- a/app/login/api/engine/back.php +++ b/app/login/api/engine/back.php @@ -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']]); } diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index 6cc9341..073e43d 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -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