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;