本文目录导读:

- 方案一:Token 替换法(最简单,推荐)
- 方案二:Token 列表 + 旧 Token 过期
- 方案三:兼容老设备的 Token 淘汰
- 方案四:JWT + Redis 黑名单(生产环境推荐)
- 方案五:使用 Session 固定 + 数据库记录
- 综合推荐方案
- 前端配合实现
在 PHP 中实现单设备登录,核心思路是让同一账号的新登录使旧登录失效,主要有以下几种实现方案,按推荐程度排序:
Token 替换法(最简单,推荐)
用户登录时生成新 Token,旧 Token 自动失效。
<?php
// 1. 登录时生成唯一 Token
function login($userId) {
$token = md5(uniqid($userId, true) . time());
// 存入数据库(只有一个字段,天然覆盖)
$pdo = new PDO("mysql:host=localhost;dbname=test", "root", "");
$stmt = $pdo->prepare("INSERT INTO user_tokens (user_id, token, expire_time)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE token = ?, expire_time = ?");
$expire = time() + 3600 * 24; // 24小时
$stmt->execute([$userId, $token, $expire, $token, $expire]);
return $token;
}
// 2. 验证 Token 是否有效
function verifyToken($userId, $token) {
$pdo = new PDO("mysql:host=localhost;dbname=test", "root", "");
$stmt = $pdo->prepare("SELECT token FROM user_tokens WHERE user_id = ?");
$stmt->execute([$userId]);
$dbToken = $stmt->fetchColumn();
return $token === $dbToken; // 只有最新 Token 有效
}
// 使用示例
session_start();
$token = login(123);
$_SESSION['token'] = $token; // 或存到 Cookie
?>
数据库表设计:
CREATE TABLE user_tokens (
user_id INT PRIMARY KEY,
token VARCHAR(64) NOT NULL,
expire_time INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Token 列表 + 旧 Token 过期
允许一个用户有多个 Token,但只保留最新的 N 个。
<?php
function generateToken() {
return bin2hex(random_bytes(16));
}
function validateToken($userId, $token) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = "user_token:{$userId}";
$tokens = $redis->lRange($key, 0, -1);
// Token 存在则有效
return in_array($token, $tokens);
}
// 登录时
$token = generateToken();
$redis->lPush("user_token:{$userId}", $token);
$redis->lTrim("user_token:{$userId}", 0, 9); // 允许保留10个 Token
// 验证时只需检查 Token 是否在列表中
?>
兼容老设备的 Token 淘汰
当检测到新设备登录时,通知旧设备。
<?php
class SingleLoginManager {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
// 登录,返回新 Token
public function login($userId, $deviceId) {
// 1. 将旧 Token 设为无效
$stmt = $this->pdo->prepare("UPDATE user_tokens
SET status = 'expired'
WHERE user_id = ? AND status = 'active'");
$stmt->execute([$userId]);
// 2. 生成新 Token
$token = bin2hex(random_bytes(32));
$expire = time() + 3600 * 24;
// 3. 保存新 Token
$stmt = $this->pdo->prepare("INSERT INTO user_tokens
(user_id, token, device_id, expire_time, status)
VALUES (?, ?, ?, ?, 'active')");
$stmt->execute([$userId, $token, $deviceId, $expire]);
return $token;
}
// 验证 Token
public function validate($userId, $token) {
$stmt = $this->pdo->prepare("SELECT status FROM user_tokens
WHERE user_id = ? AND token = ?
LIMIT 1");
$stmt->execute([$userId, $token]);
$status = $stmt->fetchColumn();
return $status === 'active';
}
// 统计在线设备数
public function getOnlineDevices($userId) {
$stmt = $this->pdo->prepare("SELECT device_id, login_time
FROM user_tokens
WHERE user_id = ? AND status = 'active'");
$stmt->execute([$userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
?>
JWT + Redis 黑名单(生产环境推荐)
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class JWTLoginManager {
private $secretKey;
private $redis;
public function __construct($secretKey, $redis) {
$this->secretKey = $secretKey;
$this->redis = $redis;
}
// 登录生成 JWT
public function login($userId) {
$payload = [
'user_id' => $userId,
'iat' => time(),
'exp' => time() + 3600 * 24, // 24小时有效
'jti' => bin2hex(random_bytes(16)) // 唯一标识
];
return JWT::encode($payload, $this->secretKey, 'HS256');
}
// 验证 Token,同时检查是否是最新的
public function validate($token) {
try {
$decoded = JWT::decode($token, new Key($this->secretKey, 'HS256'));
// 检查当前 Token 是否为最新
$latestTokenId = $this->redis->get("latest_token:{$decoded->user_id}");
if ($decoded->jti !== $latestTokenId) {
return false; // 已被新登录替代
}
// 检查是否在黑名单(可以用于登出)
if ($this->redis->sIsMember('token_blacklist', $decoded->jti)) {
return false;
}
return $decoded;
} catch (Exception $e) {
return false;
}
}
// 登出(可以取消单设备限制)
public function logout($userId) {
$this->redis->del("latest_token:{$userId}");
}
}
?>
使用 Session 固定 + 数据库记录
<?php
// MySQL 实现,适合中小型项目
$pdo = new PDO("mysql:host=localhost;dbname=test", "root", "");
// 登录时
session_start();
session_regenerate_id(true); // 防 Session 固定
$sessionId = session_id();
// 更新该用户的 Session
$stmt = $pdo->prepare("UPDATE users SET session_id = ? WHERE id = ?");
$stmt->execute([$sessionId, $userId]);
// 每次请求验证
function checkSession($userId) {
global $pdo;
$stmt = $pdo->prepare("SELECT session_id FROM users WHERE id = ?");
$stmt->execute([$userId]);
$dbSession = $stmt->fetchColumn();
return $dbSession === session_id();
}
?>
综合推荐方案
对于大多数 Web 应用,推荐方案一(Token 替换法),原因是:
- 实现简单 - 只需要一个数据库表
- 性能好 - 一次查询即可验证
- 安全性高 - 无过期 Token 堆积
- 易于维护 - 逻辑清晰
数据库表结构优化版:
CREATE TABLE user_sessions (
user_id INT PRIMARY KEY,
token VARCHAR(64) NOT NULL,
device_info VARCHAR(255),
ip_address VARCHAR(45),
login_time INT,
expire_time INT,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token)
) ENGINE=InnoDB;
前端配合实现
// Axios 拦截器 - 每次请求携带 Token
axios.interceptors.request.use(config => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 后端返回 401 时,提示重新登录
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('您的账号在其他设备登录');
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
这样就实现了完整的单设备登录功能,可以根据项目规模和需求选择合适的方案。