From fda211b1a8d6f273488a4b605641edd2cb9993d4 Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Thu, 28 May 2026 16:21:55 +0700 Subject: [PATCH] Block concurrent login: reject new session if account already active If session_token is set and session_last_seen is within the last hour, the incoming login is rejected with a clear message. Stale sessions (idle > 1 h) and explicit logouts (token = NULL via back.php) still allow re-login normally. Co-Authored-By: Claude Sonnet 4.6 --- app/login/api/engine/login_confirm.php | 38 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index 7fb5a07..20443ec 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -23,9 +23,10 @@ * b. The elapsed time since otpTime is ≤ 5 minutes. * Fail either → return "Wrong OTP! Please try again." * 5. On success: - * a. Force-replace session_token in DB — writing a new token invalidates - * any prior session (old device gets kicked out by db_auth.php on its - * next request). No block: password + OTP is full 2FA proof of identity. + * a. Concurrent-session check — if the account already has a session_token + * set and session_last_seen is within the last hour, the login is blocked + * with "already signed in on another device." A stale or NULL token allows + * the login (user closed browser without logging out, or used back.php). * b. session_regenerate_id(true) — prevents session fixation attack by * issuing a new session ID and deleting the old one. * c. Generate a fresh CSRF token and store in session. @@ -106,13 +107,30 @@ if (empty($_SESSION['skip_otp'])) { } } -// ── Step 4b: Claim session — always replace existing token ──────────────────── -// Password + OTP is full 2FA proof of identity, so we always grant the login. -// Writing a new token here also invalidates any prior session: db_auth.php -// compares session_token in the DB to the one stored in the PHP session, so the -// old device is kicked out on its next request. This also fixes the case where -// a PHP session expired without clearing the DB token, which would otherwise -// permanently block re-login. +// ── Step 4b: Concurrent session check ──────────────────────────────────────── +// Block the login if this account already has an active session. +// "Active" = session_token is set AND session_last_seen is within the last hour. +// A stale last_seen (user closed browser without logging out) expires after 1 h, +// matching the PHP session GC maxlifetime configured in session.php. +// An explicit logout clears session_token to NULL, so back.php bypasses this. +$sth_active = $pdo1->prepare( + "SELECT session_token, session_last_seen FROM user WHERE user_id = :uid LIMIT 1" +); +$sth_active->execute([':uid' => $user_id]); +$active_row = $sth_active->fetch(PDO::FETCH_ASSOC); + +if (!empty($active_row['session_token']) && !empty($active_row['session_last_seen'])) { + $idle_seconds = time() - strtotime($active_row['session_last_seen']); + if ($idle_seconds < 3600) { + $answer['message'] = 'This account is currently signed in on another device. Please sign out from that session first.'; + exit(json_encode($answer)); + } +} + +// ── Step 4c: Claim session ──────────────────────────────────────────────────── +// No active session found (or it has gone stale) — write a new token. +// db_auth.php compares session_token in the DB to the one in the PHP session, +// so any tab that still holds the old token is invalidated on its next request. $session_token = bin2hex(random_bytes(32)); $sth_claim = $pdo1->prepare( "UPDATE user