From 487b914c7d45ea9ae97c8b23b152eb540b146041 Mon Sep 17 00:00:00 2001 From: TUNG-YEN Date: Fri, 6 Nov 2026 11:02:40 +0800 Subject: [PATCH] Let abandoned anonymous sessions stop blocking re-login A voter who closed the browser before finishing kept the password locked as "logged in on another device" until the session file was garbage collected, which can take hours. Treat the other session as active only while it has been used within the anonymous login idle timeout. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + models/Logins.php | 20 ++++++- .../models/FormAnonConcurrentLoginTest.php | 60 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad022b..1177231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ ### Fixed * 開票結果、投票結果、候選名單與選票編輯表格消除重複 HTML 編碼;自訂標題及欄 A 保留經淨化的換行與排版(管理端候選人管理列表的欄 A 一併適用),欄 B–F 維持純文字,既有 entity 不作資料轉換 +* 匿名投票未完成即關閉瀏覽器後,殘留的 session 檔會讓同一密碼持續顯示「已在其他裝置登入中」而無法重新登入;改為原裝置超過匿名登入閒置逾時(預設 30 分鐘)即可重新登入,仍在使用中的登入照常阻擋 * 英文介面的紙本選票自訂文字欄位誤讀多一個 E 的欄名而發生錯誤;保留中英並列與原有數字欄位的語言選擇 * Yii log 檢視頁的 UrlModal 資產指向未隨 Git 提供的目錄而發生 500;改為追蹤前端腳本,內容載入沿用目前頁面的協定與主機 * 殘留的 Bootstrap 4 樣式在 Bootstrap 5 下失效:輪次與投票狀態徽章看不見、對話框關閉鈕錯位,核取方塊、輸入群組、間距與邊框也一併改為 Bootstrap 5 寫法 diff --git a/models/Logins.php b/models/Logins.php index 63665d5..72fbcef 100644 --- a/models/Logins.php +++ b/models/Logins.php @@ -69,7 +69,25 @@ public static function hasActiveSession(int $creator, ?string $currentSessionId } $sessionFile = Yii::getAlias('@runtime/sessions/sess_' . $login->session); + clearstatcache(true, $sessionFile); + $lastActivity = is_file($sessionFile) ? @filemtime($sessionFile) : false; + if ($lastActivity === false) { + return false; + } + + // 已登入的投票者每次請求都會更新 anon 逾時並寫回 session 檔。 + // 超過 anon authTimeout 未活動時,原裝置的登入已失效;session 檔可能要等 GC 才刪除, + // 不可因此持續擋住同一密碼重新登入(例如未完成投票就關閉瀏覽器)。 + return (time() - $lastActivity) < static::anonAuthTimeout(); + } + + /** + * 匿名投票登入的閒置逾時秒數,與 anon 元件的 authTimeout 一致 + */ + protected static function anonAuthTimeout(): int + { + $timeout = Yii::$app->has('anon') ? (int) (Yii::$app->anon->authTimeout ?? 0) : 0; - return file_exists($sessionFile); + return $timeout > 0 ? $timeout : 1800; } } diff --git a/tests/unit/models/FormAnonConcurrentLoginTest.php b/tests/unit/models/FormAnonConcurrentLoginTest.php index 83a314c..d749d60 100644 --- a/tests/unit/models/FormAnonConcurrentLoginTest.php +++ b/tests/unit/models/FormAnonConcurrentLoginTest.php @@ -66,6 +66,66 @@ public function testRejectsLoginWhenPasswordHasActiveSessionElsewhere() $this->assertTrue($model->hasErrors('password')); } + public function testAllowsLoginWhenOtherSessionIdleBeyondAuthTimeout() + { + $passwordId = 1; + $existingSessionId = 'abandoned_session_def'; + + $login = new Logins(); + $login->voteID = 'AnonPartyTest'; + $login->party = 'N'; + $login->creator = $passwordId; + $login->session = $existingSessionId; + $this->assertTrue($login->save(false)); + + $sessionDir = Yii::getAlias('@runtime/sessions'); + if (!is_dir($sessionDir)) { + mkdir($sessionDir, 0777, true); + } + $sessionFile = $sessionDir . '/sess_' . $existingSessionId; + // 未完成投票即關閉瀏覽器:session 檔仍在,但已超過 anon 閒置逾時 + touch($sessionFile, time() - Yii::$app->anon->authTimeout - 60); + + try { + $this->assertFalse(Logins::hasActiveSession($passwordId, 'another_device_session')); + + $model = new FormAnon(); + $model->voteID = 'AnonPartyTest'; + $model->password = 'testN1'; + + $this->assertTrue($model->login()); + $this->assertFalse($model->hasErrors('password')); + } finally { + @unlink($sessionFile); + } + } + + public function testRecentlyActiveSessionElsewhereStillBlocks() + { + $passwordId = 1; + $existingSessionId = 'active_session_ghi'; + + $login = new Logins(); + $login->voteID = 'AnonPartyTest'; + $login->party = 'N'; + $login->creator = $passwordId; + $login->session = $existingSessionId; + $this->assertTrue($login->save(false)); + + $sessionDir = Yii::getAlias('@runtime/sessions'); + if (!is_dir($sessionDir)) { + mkdir($sessionDir, 0777, true); + } + $sessionFile = $sessionDir . '/sess_' . $existingSessionId; + touch($sessionFile, time() - Yii::$app->anon->authTimeout + 120); + + try { + $this->assertTrue(Logins::hasActiveSession($passwordId, 'another_device_session')); + } finally { + @unlink($sessionFile); + } + } + public function testAllowsLoginWhenStaleSessionRecord() { $passwordId = 2;