返回

Laravel Session 状态检查导致持续刷新的解决方案

vue.js

Laravel 应用中 Session 状态检查导致 Session 持续刷新的问题及解决方案

在开发 Laravel/Inertia/Vue 应用时,咱们有时需要检查用户的 Session 是否过期,过期了就弹个提示框。 通常,这会通过前端定时向后端发送请求来完成。但在使用数据库作为 Session 驱动时,可能会遇到一个问题:仅仅是检查 Session 状态,却导致 Session 不断被刷新,无法真正过期。

问题出在哪儿了呢?

根本原因在于,Laravel 默认会在每次请求时更新 Session 的 last_activity 字段。即使我们只是想单纯地“读取” Session 状态,这个更新操作也会被触发,导致 Session “永不过期”。

原本的代码片段,如下:

后端 (Laravel):

Route::get('/session-status', function (\Illuminate\Http\Request $request) {
    $sessionId = cookie(config('session.cookie'));

    if (!\Illuminate\Support\Facades\Auth::check()) {
        return response()->json(['session_expired' => true], 401);
    }

    // Fetch session directly from the database (without updating last_activity)
    $sessionId = $request->session()->getId();
    $session = \Illuminate\Support\Facades\DB::table('sessions')
        ->select('last_activity')
        ->where('id', $sessionId)
        ->first();

    if ($session) {
        $sessionLifetime = config('session.lifetime') * 60;
        $sessionAge = now()->timestamp - $session->last_activity;

        if ($sessionAge > $sessionLifetime) {
            return response()->json(['session_expired' => true], 401);
        }
    }

    return response()->json(['session_expired' => false]);
})->name('session.status');

前端 (Vue/Axios):

const showPopup = ref(false);

const checkSession = async () => {
    try {
        await axios.get('/api/session-status');
    } catch (error) {
        if (error.response && error.response.status === 401) {
            showPopup.value = true;
        }
    }
};

// 每分钟检查一次 Session 状态
onMounted(() => {
    setInterval(checkSession, 6000);
});

代码看起来没毛病,问题在于,$request->session()->getId() 这行代码在获取 Session ID 的同时,已经触发了 Session 的更新。

怎么解决这个问题?

下面提供几种解决方案,可以根据自己的实际情况选择。

解决方案一:使用只读的数据库连接

这种方法的核心思想是,创建一个专门用于读取 Session 数据的数据库连接,这个连接不参与任何写操作。

  1. 配置数据库连接(config/database.php):

    'connections' => [
        'mysql' => [
            // ... 你的主数据库连接配置 ...
        ],
    
        'mysql_read' => [
            'driver'    => 'mysql',
            'host'      => env('DB_HOST', '127.0.0.1'),
            'port'      => env('DB_PORT', '3306'),
            'database'  => env('DB_DATABASE', 'forge'),
            'username'  => env('DB_USERNAME', 'forge'),
            'password'  => env('DB_PASSWORD', ''),
            // ... 其他配置 ...
             //只读
            'read' => [
                'host' => [env('DB_HOST', '127.0.0.1')],
             ],
            'sticky'    => true, //必须为 true
    
        ],
    
        // ... 其他数据库连接配置 ...
    ],
    

    要点是 sticky 设置为 true,和 read。 这确保了对这个数据库连接的读取操作都指向同一个数据库实例(避免主从延迟)。

  2. 修改后端代码:

    Route::get('/session-status', function (\Illuminate\Http\Request $request) {
      //  $sessionId = cookie(config('session.cookie')); //不再需要
    
        if (!\Illuminate\Support\Facades\Auth::check()) {
            return response()->json(['session_expired' => true], 401);
        }
    
        // 从cookie 获取 Session ID.
        $sessionId = $request->cookie(config('session.cookie'));
        if(!$sessionId)
        {
              return response()->json(['session_expired' => true], 401);
        }
        // 使用只读连接查询 Session 数据
        $session = \Illuminate\Support\Facades\DB::connection('mysql_read')
            ->table('sessions')
            ->select('last_activity')
            ->where('id', $sessionId)
            ->first();
    
        if ($session) {
            $sessionLifetime = config('session.lifetime') * 60;
            $sessionAge = now()->timestamp - $session->last_activity;
    
            if ($sessionAge > $sessionLifetime) {
                return response()->json(['session_expired' => true], 401);
            }
        }
    
        return response()->json(['session_expired' => false]);
    })->name('session.status');
    

    这里使用了 DB::connection('mysql_read') 来获取只读的数据库连接。并且,使用$request->cookie() 来直接获得session id.

原理: 通过只读的数据库连接查询 Session 数据,避免了 Laravel 框架自动更新 Session 的 last_activity 字段。

解决方案二:自定义 Session 中间件

我们可以创建一个中间件,专门用于处理 Session 状态检查请求,在这个中间件中,阻止 Session 的更新。

  1. 创建中间件:

    php artisan make:middleware CheckSessionStatus
    
  2. 编写中间件逻辑 (app/Http/Middleware/CheckSessionStatus.php):

    <?php
    
    namespace App\Http\Middleware;
    
    use Closure;
    use Illuminate\Http\Request;
    
    class CheckSessionStatus
    {
        public function handle(Request $request, Closure $next)
        {
             // 如果是检查 Session 状态的请求,则不更新 Session
             if ($request->routeIs('session.status')) {
                //不做任何和 session 相关操作.
            }
            else{
              //不是检查session 直接跳过, 进行默认的 session 操作.
               $request->session();
            }
    
            return $next($request);
        }
    }
    
  3. 注册中间件(app/Http/Kernel.php):
    $middlewarePriority数组内,把 StartSession 提到最高优先级

    protected $middlewarePriority = [
          \Illuminate\Session\Middleware\StartSession::class,
          \Illuminate\View\Middleware\ShareErrorsFromSession::class,
          \App\Http\Middleware\Authenticate::class,
        // ...其他
      ];
    
    

    然后把新建的 CheckSessionStatus 插入在\Illuminate\Session\Middleware\StartSession::class之前。

    protected $middlewarePriority = [
         \App\Http\Middleware\CheckSessionStatus::class, //新加的
         \Illuminate\Session\Middleware\StartSession::class,
          // ...其他
     ];
    
    
  4. 后端调整:

       Route::get('/session-status', function (\Illuminate\Http\Request $request) {
    
        if (!\Illuminate\Support\Facades\Auth::check()) {
            return response()->json(['session_expired' => true], 401);
        }
    
         // 从cookie 获取 Session ID.
        $sessionId = $request->cookie(config('session.cookie'));
        if(!$sessionId)
        {
              return response()->json(['session_expired' => true], 401);
        }
    
        $session = \Illuminate\Support\Facades\DB::table('sessions')
            ->select('last_activity')
            ->where('id', $sessionId)
            ->first();
    
        if ($session) {
            $sessionLifetime = config('session.lifetime') * 60;
            $sessionAge = now()->timestamp - $session->last_activity;
    
            if ($sessionAge > $sessionLifetime) {
                return response()->json(['session_expired' => true], 401);
            }
        }
    
        return response()->json(['session_expired' => false]);
    })->name('session.status');
    
    

    要点,是从 Cookie 中获取,不走session()

原理: 中间件会在 Session 启动 之前 执行。如果在 CheckSessionStatus 中间件里针对特定的路由(这里是 session.status)不做和 session 相关的任何操作,那么框架就不会启动或者说接触 session。避免 Session 的更新操作。

解决方案三: 使用 Cache 替代 Session

这种方案是,将“是否需要显示弹窗”这个状态存储在缓存中(比如 Redis),而不是依赖 Session。

  1. 后端代码:

    use Illuminate\Support\Facades\Cache;
    
     Route::get('/session-status', function (\Illuminate\Http\Request $request) {
    
        $userId = \Illuminate\Support\Facades\Auth::id(); //获得用户id
          if (!$userId) {
                return response()->json(['session_expired' => true], 401);
          }
        $cacheKey = 'user:' . $userId . ':session_expired';
    
         //尝试从获得上次的记录
        $sessionExpired= Cache::get($cacheKey);
    
        if($sessionExpired === true)
        {
              return response()->json(['session_expired' => true], 401);
        }
    
        //正常业务逻辑...
        return response()->json(['session_expired' => false]);
    })->name('session.status');
    
     //在登出时候
     Route::post('/logout', function (\Illuminate\Http\Request $request) {
         $userId = \Illuminate\Support\Facades\Auth::id();
         $cacheKey = 'user:' . $userId . ':session_expired';
          //设置过期标志
         Cache::put($cacheKey, true, config('session.lifetime') * 60); //和 session 超时时间一致
    
         \Illuminate\Support\Facades\Auth::logout(); //原有逻辑
         // ...
    
      })->name('logout');
    

    前端的调用不变,依然用401 来判断

  2. 额外说明:

    • user:{user_id}:session_expired 作为缓存键,确保每个用户有独立的标记。
    • 在用户登出时,将这个标记设置为 true,并设置过期时间,与 Session 的过期时间保持一致。
    • /session-status 接口,只需要检查这个缓存值即可。
    • 考虑使用 Redis 等高性能缓存服务。

原理: 使用缓存来管理“是否需要显示弹窗”的状态,与 Session 解耦,完全避免了 Session 更新的问题。

安全建议

无论你选择上述的哪种解决方案,一定保证:

  • 不要/session-status 接口中执行任何可能修改用户状态或数据的操作, 这个接口只是用作只读查询.
  • 如果可以,把 /session-status 的请求频率调低,避免对服务器造成不必要的压力。

总的来说,解决问题的核心思路都是不让session 的读操作产生副作用(写 last_activity). 上述三种方式分别通过技术选型的调整,框架中间件, 缓存服务,避免接触session组件,或者将逻辑处理分离,达到了这个目标。