後台管理系統要求:同一帳號同一時間僅允許一台設備/瀏覽器在線。後登入者踢下線前者,或拒絕後登入。常見於付費會員、高權限後台、考試系統。
user_id + token + IP + 登入時間 寫入「在線登記表」(Redis/資料庫/檔案)。# Key: online:user:{userId} Value: JSON {token, ip, login_at, last_active}
# TTL: 30 分鐘(滑動過期)
<?php
class SingleLoginManager {
private \Redis $redis;
private int $ttl = 1800; // 30 分鐘
public function __construct(\Redis $redis) {
$this->redis = $redis;
}
// 登入時調用:返回 [success, message, token]
public function login(int $userId, string $ip): array {
$token = bin2hex(random_bytes(32)); // 高強度隨機 Token
$key = "online:user:{$userId}";
// 檢查是否已有其他設備在線
$existing = $this->redis->get($key);
if ($existing) {
$old = json_decode($existing, true);
// 可選策略:1. 踢下舊設備 2. 拒絕新登入
// 這裡採用「踢下舊設備」
$this->redis->del("session:{$old['token']}"); // 刪除舊 Session(若 Session 存 Redis)
}
// 寫入新登入記錄
$data = [
'token' => $token,
'ip' => $ip,
'login_at' => time(),
'last_active' => time()
];
$this->redis->setex($key, $this->ttl, json_encode($data));
// 同時建立反向映射(用於快速驗證)
$this->redis->setex("session:{$token}", $this->ttl, (string)$userId);
return [true, '登入成功', $token];
}
// 每次請求驗證(中介軟體調用)
public function validate(int $userId, string $token): bool {
$key = "online:user:{$userId}";
$data = $this->redis->get($key);
if (!$data) return false; // 無登入記錄
$info = json_decode($data, true);
if ($info['token'] !== $token) return false; // Token 不匹配 = 別處登入
// 更新最後活躍時間,續期 TTL
$info['last_active'] = time();
$this->redis->setex($key, $this->ttl, json_encode($info));
$this->redis->expire("session:{$token}", $this->ttl);
return true;
}
// 登出調用
public function logout(int $userId, string $token): void {
$this->redis->del("online:user:{$userId}");
$this->redis->del("session:{$token}");
}
// 強制踢下線(管理員操作)
public function forceLogout(int $userId): void {
$data = $this->redis->get("online:user:{$userId}");
if ($data) {
$info = json_decode($data, true);
$this->redis->del("session:{$info['token']}");
}
$this->redis->del("online:user:{$userId}");
}
}
?>
<?php
// 資料表:user_online (user_id PK, token, ip, login_at, last_active)
// 登入
function doLogin(PDO $pdo, int $userId, string $ip): array {
$token = bin2hex(random_bytes(32));
$pdo->beginTransaction();
try {
// 排他鎖防併發
$stmt = $pdo->prepare("SELECT token FROM user_online WHERE user_id = ? FOR UPDATE");
$stmt->execute([$userId]);
if ($row = $stmt->fetch()) {
// 刪除舊 Session(假設 Session 存檔案/資料庫)
deleteSession($row['token']);
}
$stmt = $pdo->prepare("REPLACE INTO user_online (user_id, token, ip, login_at, last_active) VALUES (?, ?, ?, NOW(), NOW())");
$stmt->execute([$userId, $token, $ip]);
$pdo->commit();
return [true, $token];
} catch (Exception $e) {
$pdo->rollBack();
return [false, $e->getMessage()];
}
}
// 驗證(每請求調用)
function validateOnline(PDO $pdo, int $userId, string $token): bool {
$stmt = $pdo->prepare("SELECT token, last_active FROM user_online WHERE user_id = ?");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row || $row['token'] !== $token) return false;
// 更新活躍時間
$pdo->prepare("UPDATE user_online SET last_active = NOW() WHERE user_id = ?")->execute([$userId]);
return true;
}
?>
<script>
// 定期輪詢 /heartbeat 檢查登入狀態
setInterval(async () => {
const res = await fetch('/api/heartbeat');
const data = await res.json();
if (data.code === 401 && data.message.includes('別處登入')) {
alert('您的帳號已在其他設備登入,當前會話將結束。');
window.location.href = '/login?kicked=1';
}
}, 30000); // 每 30 秒檢查一次
</script>
| 議題 | 建議 |
|---|---|
| 同一瀏覽器多標籤 | 共享 Session Cookie,視為同一設備,不互踢 |
| 無痕模式/不同瀏覽器 | 不同 Cookie → 不同 Session → 觸發單機限制 |
| 負載均衡多節點 | **必須**使用共享存儲,不可用本地檔案/記憶體 |
| Session 與 Token 綁定 | 將 Token 存入 Session:$_SESSION['single_token'] = $token,驗證時比對 |
| 記住我(長期登入) | 長期 Token 另存 remember_tokens 表,單機限制僅對當前活躍 Session 生效 |
單機在線限制的核心是 「唯一 Token + 共享存儲 + 每請求驗證」。Redis 方案效能最佳、代碼最簡;資料庫方案通用性強。切記:將驗證邏輯封裝為中介軟體/過濾器,全局攔截,避免在每個控制器重複代碼。