限制你的使用者登錄設備數量:一個簡單的 PHP 實作教學
在現代網路應用中,管理使用者會話是至關重要的一環。有時候,我們希望限制一個使用者同時登錄的設備數量,例如為了帳號安全、防止帳號共用,或是為了訂閱服務的規範。今天,我們將透過一個簡單的 PHP 專案,來學習如何實現一個限制使用者同時只能在兩個設備上登錄的功能。
專案功能概覽
這個教學專案將包含以下核心功能:
- 使用者登錄系統:基本的用戶名和密碼驗證。
- 設備登錄數量限制:限制每個使用者最多只能有兩個活躍會話。當第三個設備嘗試登錄時,將自動終止最舊的會話。
- 設備識別:透過 User-Agent 和 IP 地址的組合來識別不同的設備。
- 會話管理:對活躍會話進行管理,包括會話創建、驗證和登出。
- 安全實踐:密碼雜湊、基本的 XSS 防護。
技術棧
- 後端:PHP 7.4+
- 資料庫:MySQL 5.7+
- 依賴管理:Composer
- 前端框架:Tailwind CSS (用於簡單的介面呈現)
專案結構
首先,我們來看看專案的檔案結構:
device-login-restriction/
├── public/
│ ├── index.php # 入口文件,重定向到登錄頁或儀表板
│ ├── login.php # 登錄頁面
│ ├── dashboard.php # 登錄成功後的儀表板
│ └── logout.php # 登出處理
├── src/
│ └── SessionManager.php # 會話管理核心邏輯
├── config/
│ └── db_connect.php # 資料庫連接配置
├── sql/
│ └── init.sql # 資料庫 schema 及初始數據
├── composer.json # Composer 配置
└── README.md # 專案說明文件
步驟一:設定資料庫
我們需要一個資料庫來儲存使用者資訊和他們的會話。
sql/init.sql
CREATE DATABASE IF NOT EXISTS device_login_db;
USE device_login_db;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
session_token VARCHAR(255) NOT NULL,
device_info VARCHAR(255) NOT NULL,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
UNIQUE KEY unique_session (user_id, session_token),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
);
-- Insert a sample user (password: 'password123' hashed with password_hash)
INSERT INTO users (username, password) VALUES (
'testuser',
'$2y$10$z3Q8j9k2Y5p7v8m0n4x2ue8j9k2Y5p7v8m0n4x2u3Q8j9k2Y5p7v'
);
這個 SQL 腳本做了幾件事:
- 創建了一個名為
device_login_db
的資料庫。 users
表儲存用戶名和雜湊後的密碼。user_sessions
表儲存每個活躍會話的詳細資訊,包括使用者 ID、唯一的會話令牌、設備指紋、IP 地址和會話過期時間。FOREIGN KEY
確保會話與使用者關聯,UNIQUE KEY
和INDEX
則用於提高查詢效率和資料完整性。- 插入了一個預設使用者
testuser
,密碼為password123
(已雜湊)。
步驟二:配置資料庫連接
在 config/db_connect.php
中,我們設定資料庫連接。
config/db_connect.php
<?php
try {
$pdo = new PDO("mysql:host=localhost;dbname=device_login_db", "root", "");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("SET NAMES utf8mb4");
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
?>
注意:在生產環境中,請務必將 root
和空密碼替換為安全性更高的資料庫憑證,並考慮使用環境變數來管理這些敏感資訊。
步驟三:實作會話管理器
這是整個專案的核心。SessionManager.php
類負責處理所有與使用者會話相關的邏輯。
src/SessionManager.php
<?php
class SessionManager {
private $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
$this->cleanExpiredSessions(); // 在實例化時清理過期會話
}
private function generateSessionToken() {
return bin2hex(random_bytes(32)); // 生成安全的隨機會話令牌
}
private function getDeviceInfo() {
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return hash('sha256', $userAgent . $ipAddress); // 結合 User-Agent 和 IP 生成設備指紋
}
public function loginUser($userId) {
$deviceInfo = $this->getDeviceInfo();
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$sessionToken = $this->generateSessionToken();
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour')); // 會話有效期設定為 1 小時
// 檢查當前用戶的活躍會話數量
$stmt = $this->pdo->prepare("SELECT COUNT(*) as session_count FROM user_sessions WHERE user_id = ? AND expires_at > NOW()");
$stmt->execute([$userId]);
$sessionCount = $stmt->fetchColumn();
// 如果會話數量達到或超過兩個,則刪除最舊的一個
if ($sessionCount >= 2) {
$stmt = $this->pdo->prepare("DELETE FROM user_sessions WHERE user_id = ? ORDER BY created_at ASC LIMIT 1");
$stmt->execute([$userId]);
}
// 插入新的會話
$stmt = $this->pdo->prepare("INSERT INTO user_sessions (user_id, session_token, device_info, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)");
if ($stmt->execute([$userId, $sessionToken, $deviceInfo, $ipAddress, $expiresAt])) {
$_SESSION['user_id'] = $userId;
$_SESSION['session_token'] = $sessionToken;
return true;
}
return false;
}
public function verifySession() {
if (!isset($_SESSION['user_id'], $_SESSION['session_token'])) {
return false;
}
// 檢查當前會話是否有效且未過期
$stmt = $this->pdo->prepare("SELECT * FROM user_sessions WHERE user_id = ? AND session_token = ? AND expires_at > NOW()");
$stmt->execute([$_SESSION['user_id'], $_SESSION['session_token']]);
if ($stmt->fetch()) {
return true;
}
// 會話無效或過期,銷毀 PHP 會話
session_unset();
session_destroy();
return false;
}
public function logoutUser($userId, $sessionToken) {
// 從資料庫中刪除指定會話
$stmt = $this->pdo->prepare("DELETE FROM user_sessions WHERE user_id = ? AND session_token = ?");
$stmt->execute([$userId, $sessionToken]);
// 銷毀 PHP 會話
session_unset();
session_destroy();
}
private function cleanExpiredSessions() {
// 清理所有已過期的會話
$stmt = $this->pdo->prepare("DELETE FROM user_sessions WHERE expires_at < NOW()");
$stmt->execute();
}
}
?>
步驟四:建立使用者介面檔案
我們需要幾個 PHP 文件來處理頁面導航、登錄表單和儀表板。
public/index.php
這是應用程式的入口點,會根據使用者是否已登錄進行重定向。
<?php
session_start();
if (isset($_SESSION['user_id'])) {
header("Location: dashboard.php"); // 已登錄,重定向到儀表板
exit;
}
header("Location: login.php"); // 未登錄,重定向到登錄頁
exit;
?>
public/login.php
登錄頁面包含一個簡單的表單,用於提交用戶名和密碼。
<?php
session_start();
require_once '../config/db_connect.php';
require_once '../src/SessionManager.php';
$sessionManager = new SessionManager($pdo);
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$stmt = $pdo->prepare("SELECT id, password FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) { // 驗證密碼
if ($sessionManager->loginUser($user['id'])) { // 嘗試登錄並管理會話
header("Location: dashboard.php");
exit;
} else {
$error = "無法登錄,請稍後重試。";
}
} else {
$error = "用戶名或密碼錯誤。";
}
}
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>登錄</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 flex items-center justify-center h-screen">
<div class="bg-white p-8 rounded shadow-md w-full max-w-md">
<h2 class="text-2xl font-bold mb-6 text-center">登錄</h2>
<?php if ($error): ?>
<p class="text-red-500 mb-4"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="">
<div class="mb-4">
<label class="block text-gray-700">用戶名</label>
<input type="text" name="username" class="w-full p-2 border rounded" required>
</div>
<div class="mb-6">
<label class="block text-gray-700">密碼</label>
<input type="password" name="password" class="w-full p-2 border rounded" required>
</div>
<button type="submit" class="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600">登錄</button>
</form>
</div>
</body>
</html>
public/dashboard.php
登錄成功後,使用者會被導向到這個儀表板頁面。
<?php
session_start();
require_once '../config/db_connect.php';
require_once '../src/SessionManager.php';
$sessionManager = new SessionManager($pdo);
if (!$sessionManager->verifySession()) { // 驗證會話是否有效
header("Location: login.php");
exit;
}
$stmt = $pdo->prepare("SELECT username FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>儀表板</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div class="container mx-auto p-4">
<h1 class="text-3xl font-bold mb-4">歡迎,<?php echo htmlspecialchars($user['username']); ?>!</h1>
<p>這是你的儀表板。</p>
<a href="logout.php" class="inline-block bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600">登出</a>
</div>
</body>
</html>
public/logout.php
處理使用者登出邏輯。
<?php
session_start();
require_once '../config/db_connect.php';
require_once '../src/SessionManager.php';
$sessionManager = new SessionManager($pdo);
if (isset($_SESSION['user_id'], $_SESSION['session_token'])) {
$sessionManager->logoutUser($_SESSION['user_id'], $_SESSION['session_token']); // 執行登出操作
}
header("Location: login.php"); // 重定向回登錄頁
exit;
?>
步驟五:設定 Composer
Composer 用於管理專案依賴和自動載入。
composer.json
{
"name": "yourusername/device-login-restriction",
"description": "A PHP application to restrict user logins to two devices",
"type": "project",
"require": {
"php": ">=7.4"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"license": "MIT"
}
安裝與運行
- 克隆專案:
Bash
git clone https://github.com/yourusername/device-login-restriction.git cd device-login-restriction
- 安裝 Composer 依賴:
Bash
composer install
- 建立資料庫並匯入 schema:
替換Bashmysql -u your_username -p < sql/init.sql
your_username
為你的 MySQL 用戶名。 - 配置資料庫連接:
編輯
config/db_connect.php
,更新資料庫的用戶名和密碼。 - 設定 Web 伺服器:
將你的 Web 伺服器 (Apache, Nginx 等) 的文檔根目錄指向
device-login-restriction/public/
目錄。 - 在瀏覽器中訪問應用程式:
例如:
http://localhost
使用與測試
- 預設使用者:
testuser
/password123
- 嘗試使用
testuser
帳號在兩個不同的瀏覽器或設備上登錄,你會發現它們都能正常登錄。 - 然後,在第三個瀏覽器或設備上再次使用
testuser
登錄。此時,你會發現最舊的那個會話會被自動登出,而最新的兩個會話將保持活躍。
優化與注意事項
- 設備指紋魯棒性:目前僅使用 User-Agent 和 IP 地址來識別設備,這並非百分之百可靠。在生產環境中,可以考慮結合更多複雜的設備指紋技術來提高識別的準確性。
- 會話過期時間:預設會話過期時間為 1 小時,你可以根據應用程式的需求調整
SessionManager.php
中的過期時間。 - 安全性:
- HTTPS:在任何生產環境中,務必啟用 HTTPS 來加密所有網路流量,防止會話劫持。
- CSRF 保護:為了防止跨站請求偽造攻擊,建議在所有表單中實現 CSRF 令牌驗證。
- 速率限制:為登錄頁面添加速率限制,以防止暴力破解攻擊。
- 錯誤處理與日誌:在實際應用中,應實現更完善的錯誤處理機制,並將應用程式錯誤和安全事件記錄到日誌檔中,以便於監控和調試。
這個專案提供了一個堅實的基礎,讓你了解如何在 PHP 中實現使用者設備登錄限制。你可以根據自己的需求進一步擴展和完善它!
專案 GitHub 連結: https://github.com/BpsEason/device-login-restriction.git
沒有留言:
張貼留言