Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 寫法
Expand Down
20 changes: 19 additions & 1 deletion models/Logins.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
60 changes: 60 additions & 0 deletions tests/unit/models/FormAnonConcurrentLoginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down