不少应用在默认情况下允许同一个账号在多台设备上同时保持登录状态,这虽然给用户带来了便利,但也埋下了安全隐患。例如某台设备被他人借用或丢失后,只要会话未过期,对方就能继续以合法身份访问账户数据。要解决这个问题,核心思路是在用户登录后生成一个唯一的会话标识并存入数据库,之后每次请求都校验当前会话携带的标识是否与数据库记录一致。如果发现不一致,说明该账号在其他设备触发了新的登录,当前会话应当被强制终止。CodeIgniter 4 提供的会话服务和过滤器机制可以很方便地实现这一逻辑。

实现单设备登录的核心步骤
最直接且易于维护的做法是在用户表中增加一个字段,用来记录当前有效会话的令牌。当用户成功登录时,系统生成一个随机且足够长的字符串作为令牌,并同时写入数据库和当前会话中。之后每次请求,都通过过滤器对比会话中的令牌与数据库中的令牌。一旦二者不匹配,就立即销毁当前会话并跳转到登录页。
首先需要修改用户表结构。以 MySQL 为例,可以在迁移文件中添加一个 session_token 字段。该字段建议使用 VARCHAR(64) 类型,并设置默认值为 NULL,这样可以区分尚未登录的用户。同时为了查询效率,可以给这个字段加上索引,但考虑到它只在登录和校验时使用,高频更新,通常不建索引也可以接受。
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSessionTokenToUsers extends Migration
{
public function up()
{
$this->forge->addColumn('users', [
'session_token' => [
'type' => 'VARCHAR',
'constraint' => 64,
'null' => true,
'default' => null,
],
]);
}
public function down()
{
$this->forge->dropColumn('users', 'session_token');
}
}
在登录控制器中,验证用户凭据通过后,生成一个新的随机令牌,更新数据库,并将用户 ID 和令牌写入会话。这里使用 bin2hex(random_bytes(32)) 生成 64 位十六进制字符串,足够安全且便于存储。同时,旧设备上的会话因为令牌未更新,会在下一次请求时被过滤器拦截并退出。
public function attemptLogin()
{
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$userModel = new \App\Models\UserModel();
$user = $userModel->where('email', $email)->first();
if ($user && password_verify($password, $user['password_hash'])) {
$newToken = bin2hex(random_bytes(32));
$userModel->update($user['id'], ['session_token' => $newToken]);
session()->set('user_id', $user['id']);
session()->set('session_token', $newToken);
return redirect()->to('/dashboard')->with('success', '登录成功');
}
return redirect()->back()->with('error', '邮箱或密码错误');
}
这种方式的优点在于实现简单、逻辑清晰,而且不需要改动 CodeIgniter 的会话存储机制。缺点是每次请求都需要查询一次用户表,在高并发场景下可能会给数据库带来额外压力。不过对于大多数中小型应用来说,这种开销完全在可接受范围内。如果后续性能成为瓶颈,可以切换为从 Redis 或缓存中读取令牌,核心思想不变。
用过滤器校验会话令牌
为了确保令牌校验覆盖所有需要登录的页面,最合适的方式是实现一个自定义过滤器。CodeIgniter 4 的过滤器可以在控制器执行前运行,我们可以在 before 方法中读取会话里的 user_id 和 session_token,然后查询数据库中的最新令牌。如果不一致或者用户已被删除,就销毁会话并重定向。
创建过滤器文件 app/Filters/SingleSessionFilter.php。在 before 方法中,先判断会话中是否存在用户标识和令牌。若不存在,通常说明用户尚未登录,可以不做处理,交给后续的登录检查或者其他过滤器处理。若存在,则从数据库加载用户记录,并比较令牌。一旦发现不匹配,说明当前会话已经在别处被替换,立即销毁会话并跳转。
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class SingleSessionFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (!session()->has('user_id') || !session()->has('session_token')) {
return;
}
$userModel = new \App\Models\UserModel();
$user = $userModel->find(session()->get('user_id'));
if (!$user || $user['session_token'] !== session()->get('session_token')) {
session()->destroy();
return redirect()->to('/login')->with('error', '您的账号已在其他设备登录,请重新登录');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// 无需处理
}
}
接下来需要在 app/Config/Filters.php 中注册这个过滤器,并将其应用到需要保护的路由组。例如,可以创建一个名为 single-session 的别名,然后在路由文件中为所有需要登录的地址添加这个过滤器。也可以直接将其挂载到全局过滤器,但那样会影响到所有请求,包括静态资源,所以建议仅在需要认证的分组中使用。
// app/Config/Filters.php
public $aliases = [
'csrf' => \CodeIgniter\Filters\CSRF::class,
'toolbar' => \CodeIgniter\Filters\DebugToolbar::class,
'honeypot' => \CodeIgniter\Filters\Honeypot::class,
'single-session' => \App\Filters\SingleSessionFilter::class,
];
public $filters = [
'single-session' => ['before' => ['dashboard', 'account/*']],
];
使用过滤器的方式让所有受保护的路由自动执行校验,代码集中且易于维护。如果后续需要调整校验逻辑,只需要修改过滤器即可,不会影响业务控制器。此外,这种方案也支持在过滤器中进行更细粒度的处理,比如记录日志、发送通知等。
基于会话驱动或 Redis 的更灵活方案
除了在用户表中增加字段,还可以利用 CodeIgniter 4 的数据库会话驱动来管理所有活动会话。这种方式需要在 app/Config/App.php 中将 sessionDriver 设置为数据库驱动,并确保会话表结构已经创建。然后通过会话表记录每个会话所属的用户 ID,登录后删除该用户的其他会话记录,从而实现单设备限制。这种方案的好处是不用修改用户表,并且可以统一管理会话状态。
实现时,在登录成功后获取当前会话 ID(session_id()),然后执行一条 SQL 语句,从会话表中删除所有属于该用户但会话 ID 不等于当前的记录。同时,需要把当前会话的用户 ID 写入会话表,以便后续识别。由于 CodeIgniter 默认的会话表结构不包含用户 ID 字段,需要手动添加该列,或者在 data 字段中存储用户 ID 并解析,但前者更直接。
public function login()
{
// 假设已通过密码验证,$userId 为用户 ID
$userId = $user['id'];
$newSessionId = session_id();
$db = \Config\Database::connect();
$builder = $db->table('ci_sessions');
// 删除其他会话
$builder->where('user_id', $userId);
$builder->where('id !=', $newSessionId);
$builder->delete();
// 更新当前会话的用户 ID
$builder = $db->table('ci_sessions');
$builder->where('id', $newSessionId);
$builder->update(['user_id' => $userId]);
}
如果应用部署在分布式环境或者需要更高性能,可以考虑使用 Redis 存储登录令牌。将每个用户当前有效的令牌写入 Redis,键名设计为 user_token:{userId},值就是令牌字符串。登录时覆盖旧值,请求校验时从 Redis 读取并比较。这样避免了频繁查询关系型数据库,响应速度更快。但需要注意的是 Redis 数据可能因重启而丢失,因此必须设置合理的过期时间,并在 Redis 不可用时回退到数据库验证。
无论采用哪种方案,都应当考虑令牌轮换与过期策略。令牌不应无限期有效,可以结合会话过期时间,定期或在敏感操作前要求用户重新验证。另外,强制单设备登录可能会给用户带来不便,比如在不同设备间切换时需要反复登录。更好的做法是提供“记住此设备”的选项,或者在旧会话被踢出时给出友好提示,引导用户重新登录。安全性与用户体验需要平衡,但核心目标仍然是保护账户不被未授权访问。
CodeIgniter 4重复登录会话管理修改时间:2026-08-26 09:46:04