2025年6月14日 星期六

如何簡單的限制使用者登錄設備數量?一個簡單的 PHP 實作教學

限制你的使用者登錄設備數量:一個簡單的 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

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 KEYINDEX 則用於提高查詢效率和資料完整性。
  • 插入了一個預設使用者 testuser,密碼為 password123(已雜湊)。

步驟二:配置資料庫連接

config/db_connect.php 中,我們設定資料庫連接。

config/db_connect.php

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
<?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
<?php
session_start();
if (isset($_SESSION['user_id'])) {
    header("Location: dashboard.php"); // 已登錄,重定向到儀表板
    exit;
}
header("Location: login.php"); // 未登錄,重定向到登錄頁
exit;
?>

public/login.php

登錄頁面包含一個簡單的表單,用於提交用戶名和密碼。

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
<?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
<?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

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"
}

安裝與運行

  1. 克隆專案
    Bash
    git clone https://github.com/yourusername/device-login-restriction.git
    cd device-login-restriction
    
  2. 安裝 Composer 依賴
    Bash
    composer install
    
  3. 建立資料庫並匯入 schema
    Bash
    mysql -u your_username -p < sql/init.sql
    
    替換 your_username 為你的 MySQL 用戶名。
  4. 配置資料庫連接: 編輯 config/db_connect.php,更新資料庫的用戶名和密碼。
  5. 設定 Web 伺服器: 將你的 Web 伺服器 (Apache, Nginx 等) 的文檔根目錄指向 device-login-restriction/public/ 目錄。
  6. 在瀏覽器中訪問應用程式: 例如: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

沒有留言:

張貼留言

網誌存檔