Laravel - 会话数据在注销/登录后仍然存在,即使对于不同的用户也是如此

Posted

技术标签:

【中文标题】Laravel - 会话数据在注销/登录后仍然存在,即使对于不同的用户也是如此【英文标题】:Laravel - session data survives log-out/log-in, even for different users 【发布时间】:2015-08-24 01:49:47 【问题描述】:

今天我在检查 Laravel 5 创建的 storage/framework/sessions 文件夹中的会话文件时发现了一些令人不安的事情。

这是发生了什么:

    我以用户 A 的身份登录 我导航到在会话中存储变量 X 的页面 我退出了,但没有关闭浏览器。 storage/framework/sessions 中的会话文件仍然存在,并且浏览器 饼干还活着。 我以用户 B 的身份登录。 storage/framework/sessions 中的旧会话文件已被删除,并且有一个新会话文件。 我查看了新的会话文件 - 惊喜!变量 X 在注销后仍然存在,可供用户 B 访问!

这会导致安全问题,因为现在用户 B 可以访问用户 A 的数据。

在通过 Laravel 源代码进行调试时,我发现 Session Store 在注销/登录过程中从未被清除。在Illuminate\Auth\Guard::clearUserDataFromStorage() 方法中只删除了登录凭据,但所有会话存储属性仍然存在,然后在调用$kernel->terminate($request, $response); 时,这又导致Illuminate\Session\Middleware\StartSession::terminate() 调用Store::save(),从而盲目保存@987654329 @ 到新会话,忽略它现在属于另一个用户的事实。

从一方面来看,这似乎是合乎逻辑的 - Laravel 对我的数据以及我是否希望它与身份验证一起过期没有任何假设。但是,最好将它记录在某个地方,并提供一种解决方案,将一些敏感数据附加到身份验证对象并与它一起过期。

这意味着我作为一名程序员有责任在新(或同一)用户登录时彻底清除当前会话中的所有敏感数据。

注销时清除是不可靠的,因为用户可能永远不会点击注销链接,而是等待会话“过期”,这对于 Laravel 仍然不会清除会话。

还有一点要记住:我不应该过早清除会话 - 必须存在 AntiForgery 令牌,否则登录表单将始终失败。

我找到了一个论坛主题,它也试图解决一些类似的问题:

http://laravel.io/forum/04-27-2014-how-to-expire-session-data

我对此感到困惑:

我今天又试了一下,发现问题出在哪里:Session::flush() 不会删除应用程序创建的会话数据,例如购物车详细信息

如果这是真的,那么完全摆脱会话的唯一方法是使用 php 原生 session_unset()session_destroy() 但我不想那样做——我更愿意找一个清洁工, Laravel-ish 解决方案,如果可能的话。

当身份验证过期或用户注销时,我如何告诉 Laravel 我希望我的旧会话数据与用户身份验证数据一起被删除?

【问题讨论】:

【参考方案1】:

在laravel docs 中说你可以:

从会话中删除项目

Session::forget('key');

从会话中删除所有项目

Session::flush();

您可以导航到 AuthenticatesAndRegistersUsers.php 特征并重写

   /**
     * Log the user out of the application.
     *
     * @return \Illuminate\Http\Response
     */
    public function getLogout()
    
        $this->auth->logout();

        return redirect(property_exists($this, 'redirectAfterLogout') ? $this->redirectAfterLogout : '/');
    

   /**
     * Log the user out of the application.
     *
     * @return \Illuminate\Http\Response
     */
    public function getLogout()
    
        Session::flush();

        $this->auth->logout();

        return redirect(property_exists($this, 'redirectAfterLogout') ? $this->redirectAfterLogout : '/');
    

我不知道这是否真的有效,但试一试:)

更新

According to this answer here on Stack Overflow,您可以将会话设置为在浏览器关闭时或 XXX 分钟后过期。配合上面的方案,应该可以解决问题吧?

在 config/session.php 中

   /*
    |--------------------------------------------------------------------------
    | Session Lifetime
    |--------------------------------------------------------------------------
    |
    | Here you may specify the number of minutes that you wish the session
    | to be allowed to remain idle before it expires. If you want them
    | to immediately expire on the browser closing, set that option.
    |
    */

    'lifetime' => 120,

    'expire_on_close' => false

【讨论】:

是的,当用户故意注销时,这将起作用。但如果他只是保持浏览器打开直到会话到期,我将不得不手动调用那些忘记和刷新函数。看来,发现这个潜在安全问题的不止我一个:github.com/laravel/framework/issues/8661 更新了答案。这能解决你的问题吗? 是的,如果用户关闭浏览器,那么它总是可以正常工作并且完全没有问题。问题仅在于用户刚刚离开,然后很久以后另一个用户进来并登录 - 然后旧会话变量存在。我想,在注销和登录期间,我将不得不忍受这些 flush() 调用,而 Laravel 中没有更好的内置解决方案来指定我不希望某些会话变量在会话更改后保持活动状态. 是的,好的。我希望你能找到更好的解决方案 :) 或者 laravel 创建它 但是如果您担心“很久以后”,那么为什么不将过期时间设置为 1-5 分钟呢?我的银行在 10 分钟左右不活动使用后将我注销。但是在自动注销前 2 分钟,他们问我是否要续订/延长会话。【参考方案2】:

我相信这是这个问题/问题的正确答案:

在一次测试中发出多个请求时,您的 laravel 应用程序的状态不会在请求之间重置。 Auth 管理器是 laravel 容器中的一个单例,它保留已解析的 auth 守卫的本地缓存。已解析的身份验证守卫会保留已身份验证用户的本地缓存。

因此,您对 api/logout 端点的第一个请求会解析身份验证管理器,该管理器会解析 api 防护,该防护存储对您将撤销其令牌的经过身份验证的用户的引用。

现在,当您向 /api/user 发出第二个请求时,已从容器中提取已解析的身份验证管理器,已从其本地缓存中提取已解析的 api 保护,并从守卫的本地缓存。这就是第二个请求通过身份验证而不是失败的原因。

在同一测试中使用多个请求测试与身份验证相关的内容时,您需要在测试之间重置已解析的实例。此外,您不能只取消设置已解析的身份验证管理器实例,因为当它再次解析时,它不会定义扩展护照驱动程序。

因此,我发现最简单的方法是使用反射来取消设置已解析身份验证管理器上的受保护警卫属性。您还需要在已解析的会话保护上调用 logout 方法。

来源:Method Illuminate\Auth\RequestGuard::logout does not exist Laravel Passport

要使用它,请将其添加到:

TestCase.php

protected function resetAuth(array $guards = null) : void

    $guards = $guards ?: array_keys(config('auth.guards'));

    foreach ($guards as $guard) 
        $guard = $this->app['auth']->guard($guard);

        if ($guard instanceof SessionGuard) 
            $guard->logout();
        
    

    $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards');
    $protectedProperty->setAccessible(true);
    $protectedProperty->setValue($this->app['auth'], []);

然后,像这样使用它:

LoginTest.php

class LoginTest extends TestCase

    use DatabaseTransactions, ThrottlesLogins;

    protected $auth_guard = 'web';

    /** @test */
    public function it_can_login()
    
        $user = $this->user();

        $this->postJson(route('login'), ['email' => $user->email, 'password' => TestCase::AUTH_PASSWORD])
            ->assertStatus(200)
            ->assertJsonStructure([
                'user' => [
                    'id' ,
                    'status',
                    'name',
                    'email',
                    'email_verified_at',
                    'created_at',
                    'updated_at',
                    'photo_url',
                    'roles_list',
                    'roles',
                ],
            ]);

        $this->assertEquals(Auth::check(), true);
        $this->assertEquals(Auth::user()->email, $user->email);
        $this->assertAuthenticated($this->auth_guard);
        $this->assertAuthenticatedAs($user, $this->auth_guard);

        $this->resetAuth();
    

    /** @test */
    public function it_can_logout()
    
        $this->actingAs($this->user())
            ->postJson(route('logout'))
            ->assertStatus(204);

        $this->assertGuest($this->auth_guard);

        $this->resetAuth();
    

    /** @test */
    public function it_should_get_two_cookies_upon_login_without_remember_me()
    
        $user = $this->user();

        $response = $this->postJson(route('login'), [
            'email' => $user->email,
            'password' => TestCase::AUTH_PASSWORD,
        ]);

        $response->assertCookieNotExpired(Str::slug(config('app.name'), '_').'_session');
        $response->assertCookieNotExpired('XSRF-TOKEN');
        $this->assertEquals(config('session.http_only'), true);

        $this->resetAuth();
    

    /** @test */
    public function it_should_get_three_cookies_upon_login_with_remember_me()
    
        $user = $this->user();

        $response = $this->postJson(route('login'), [
            'email' => $user->email,
            'password' => TestCase::AUTH_PASSWORD,
            'remember' => true,
        ]);

        $response->assertCookieNotExpired(Str::slug(config('app.name'), '_').'_session');
        $response->assertCookieNotExpired('XSRF-TOKEN');
        $response->assertCookieNotExpired(Auth::getRecallerName());

        $this->resetAuth();
    

    /** @test */
    public function it_should_throw_error_422_on_login_attempt_without_email()
    
        $this->postJson(route('login'), ['email' => '', 'password' => TestCase::AUTH_PASSWORD])
            ->assertStatus(422)
            ->assertJsonStructure(['message', 'errors' => ['email']]);

        $this->assertGuest($this->auth_guard);

        $this->resetAuth();
    

    /** @test */
    public function it_should_throw_error_422_on_login_attempt_without_password()
    
        $this->postJson(route('login'), ['email' => $this->adminUser()->email, 'password' => ''])
            ->assertStatus(422)
            ->assertJsonStructure(['message', 'errors' => ['password']]);

        $this->assertGuest($this->auth_guard);

        $this->resetAuth();
    

    /** @test */
    public function it_should_throw_error_422_on_login_attempt_with_empty_form()
    
        $this->postJson(route('login'), ['email' => '', 'password' => ''])
            ->assertStatus(422)
            ->assertJsonStructure(['message', 'errors' => ['email', 'password']]);

        $this->assertGuest($this->auth_guard);

        $this->resetAuth();
    

    /** @test */
    public function it_should_throw_error_401_as_guest_on_protected_routes()
    
        $this->assertGuest($this->auth_guard);

        $this->getJson(route('me'))
            ->assertStatus(401)
            ->assertJson(['message' => 'Unauthenticated.']);
    

    /** @test */
    public function it_should_throw_error_429_when_login_attempt_is_throttled()
    
        $this->resetAuth();

        $throttledUser = factory(User::class, 1)->create()->first();

        foreach (range(0, 9) as $attempt) 
            $this->postJson(route('login'), ['email' => $throttledUser->email, 'password' => "TestCase::AUTH_PASSWORD_$attempt"]);
        

        $this->postJson(route('login'), ['email' => $throttledUser->email, 'password' => TestCase::AUTH_PASSWORD . 'k'])
            ->assertStatus(429)
            ->assertJson(['message' => 'Too Many Attempts.']);

        $this->resetAuth();
    


关于油门的说明。我花了几天时间才弄清楚如何确保 429 行为。较早的单元测试会增加导致节流的“尝试”次数,因此您需要在节流测试之前resetAuth,否则将在错误的时间触发节流并搞砸测试。

鉴于上面的单元测试代码,我正在使用这个:

Route::group(['middleware' => ['guest', 'throttle:10,5']], function ()  /**/ );

您可以通过更改任何这些数字来观察它的工作情况,例如将10,5 更改为9,511,5,并观察它如何影响油门单元测试。您还可以取消注释 resetAuth 方法并观察它是如何完成测试的。

对于任何与身份验证相关的单元测试,resetAuth 实用程序方法非常有用,必须具备。此外,AuthManager 中的身份验证缓存知识是理解观察到的行为的必要知识。

【讨论】:

以上是关于Laravel - 会话数据在注销/登录后仍然存在,即使对于不同的用户也是如此的主要内容,如果未能解决你的问题,请参考以下文章

Laravel 5.2:在用户登录/注销和注册后显示会话闪烁消息

会话超时后的 laravel csrf 令牌不匹配异常

登录后没有出现注销链接

使用 Angularjs 和 Laravel 注销会话过期

在 C# 中注销网站后立即拒绝重新登录

快速单击链接会导致注销(会话锁定?)