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 <noreply@anthropic.com>
This commit is contained in:
Thanakorn S
2026-05-28 16:21:55 +07:00
co-authored by Claude Sonnet 4.6
parent 9a50238347
commit fda211b1a8
+28 -10
View File
@@ -23,9 +23,10 @@
* b. The elapsed time since otpTime is ≤ 5 minutes. * b. The elapsed time since otpTime is ≤ 5 minutes.
* Fail either → return "Wrong OTP! Please try again." * Fail either → return "Wrong OTP! Please try again."
* 5. On success: * 5. On success:
* a. Force-replace session_token in DB — writing a new token invalidates * a. Concurrent-session check — if the account already has a session_token
* any prior session (old device gets kicked out by db_auth.php on its * set and session_last_seen is within the last hour, the login is blocked
* next request). No block: password + OTP is full 2FA proof of identity. * 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 * b. session_regenerate_id(true) — prevents session fixation attack by
* issuing a new session ID and deleting the old one. * issuing a new session ID and deleting the old one.
* c. Generate a fresh CSRF token and store in session. * 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 ──────────────────── // ── Step 4b: Concurrent session check ────────────────────────────────────────
// Password + OTP is full 2FA proof of identity, so we always grant the login. // Block the login if this account already has an active session.
// Writing a new token here also invalidates any prior session: db_auth.php // "Active" = session_token is set AND session_last_seen is within the last hour.
// compares session_token in the DB to the one stored in the PHP session, so the // A stale last_seen (user closed browser without logging out) expires after 1 h,
// old device is kicked out on its next request. This also fixes the case where // matching the PHP session GC maxlifetime configured in session.php.
// a PHP session expired without clearing the DB token, which would otherwise // An explicit logout clears session_token to NULL, so back.php bypasses this.
// permanently block re-login. $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)); $session_token = bin2hex(random_bytes(32));
$sth_claim = $pdo1->prepare( $sth_claim = $pdo1->prepare(
"UPDATE user "UPDATE user