diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 8b388af1b9e5..0edefc7ea9ee 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -10,6 +10,7 @@ use Illuminate\Auth\Events\Logout; use Illuminate\Auth\Events\OtherDeviceLogout; use Illuminate\Auth\Events\Validated; +use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Contracts\Auth\SupportsBasicAuth; @@ -600,7 +601,7 @@ protected function ensureRememberTokenIsSet(AuthenticatableContract $user) protected function queueRecallerCookie(AuthenticatableContract $user) { $this->getCookieJar()->queue($this->createRecaller( - $user->getAuthIdentifier().'|'.$user->getRememberToken().'|'.$user->getAuthPassword() + $user->getAuthIdentifier().'|'.$user->getRememberToken().'|'.$this->hashPasswordForCookie($user->getAuthPassword()) )); } @@ -615,6 +616,25 @@ protected function createRecaller($value) return $this->getCookieJar()->make($this->getRecallerName(), $value, $this->getRememberDuration()); } + /** + * Create a HMAC of the password hash for storage in cookies. + * + * @param string $passwordHash + * @return string + */ + public function hashPasswordForCookie($passwordHash) + { + $container = Container::getInstance(); + + if ($container && $container->bound('config')) { + $key = $container->make('config')->get('app.key'); + } else { + $key = 'base-key-for-password-hash-mac'; + } + + return hash_hmac('sha256', $passwordHash, $key); + } + /** * Log the user out of the application. * diff --git a/src/Illuminate/Session/Middleware/AuthenticateSession.php b/src/Illuminate/Session/Middleware/AuthenticateSession.php index 89c36e8a1d41..c50ec186245f 100644 --- a/src/Illuminate/Session/Middleware/AuthenticateSession.php +++ b/src/Illuminate/Session/Middleware/AuthenticateSession.php @@ -48,9 +48,9 @@ public function handle($request, Closure $next) } if ($this->guard()->viaRemember()) { - $passwordHash = explode('|', $request->cookies->get($this->guard()->getRecallerName()))[2] ?? null; + $passwordHashFromCookie = explode('|', $request->cookies->get($this->guard()->getRecallerName()))[2] ?? null; - if (! $passwordHash || ! hash_equals($request->user()->getAuthPassword(), $passwordHash)) { + if (! $passwordHashFromCookie || ! $this->validatePasswordHash($request->user()->getAuthPassword(), $passwordHashFromCookie)) { $this->logout($request); } } @@ -59,7 +59,9 @@ public function handle($request, Closure $next) $this->storePasswordHashInSession($request); } - if (! hash_equals($request->session()->get('password_hash_'.$this->auth->getDefaultDriver()), $request->user()->getAuthPassword())) { + $sessionPasswordHash = $request->session()->get('password_hash_'.$this->auth->getDefaultDriver()); + + if (! $this->validatePasswordHash($request->user()->getAuthPassword(), $sessionPasswordHash)) { $this->logout($request); } @@ -83,7 +85,7 @@ protected function storePasswordHashInSession($request) } $request->session()->put([ - 'password_hash_'.$this->auth->getDefaultDriver() => $request->user()->getAuthPassword(), + 'password_hash_'.$this->auth->getDefaultDriver() => $this->guard()->hashPasswordForCookie($request->user()->getAuthPassword()), ]); } @@ -129,6 +131,28 @@ protected function redirectTo(Request $request) } } + /** + * Validate the password hash against the stored value. + * + * This method first tries to validate using HMAC (new format), + * and falls back to raw password hash comparison (old format) + * for backward compatibility. + * + * @param string $passwordHash + * @param string $storedValue + * @return bool + */ + protected function validatePasswordHash($passwordHash, $storedValue) + { + // Try new HMAC format first + if (hash_equals($this->guard()->hashPasswordForCookie($passwordHash), $storedValue)) { + return true; + } + + // Fall back to old raw password hash format for backward compatibility + return hash_equals($passwordHash, $storedValue); + } + /** * Specify the callback that should be used to generate the redirect path. * diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index ed2b8e97d5bf..20035d5495e3 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -498,7 +498,8 @@ public function testLoginMethodQueuesCookieWhenRemembering() $guard = new SessionGuard('default', $provider, $session, $request); $guard->setCookieJar($cookie); $foreverCookie = new Cookie($guard->getRecallerName(), 'foo'); - $cookie->shouldReceive('make')->once()->with($guard->getRecallerName(), 'foo|recaller|bar', 576000)->andReturn($foreverCookie); + $expectedHash = hash_hmac('sha256', 'bar', 'base-key-for-password-hash-mac'); + $cookie->shouldReceive('make')->once()->with($guard->getRecallerName(), 'foo|recaller|'.$expectedHash, 576000)->andReturn($foreverCookie); $cookie->shouldReceive('queue')->once()->with($foreverCookie); $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); $session->shouldReceive('regenerate')->once(); @@ -518,7 +519,8 @@ public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride() $guard->setRememberDuration(5000); $guard->setCookieJar($cookie); $foreverCookie = new Cookie($guard->getRecallerName(), 'foo'); - $cookie->shouldReceive('make')->once()->with($guard->getRecallerName(), 'foo|recaller|bar', 5000)->andReturn($foreverCookie); + $expectedHash = hash_hmac('sha256', 'bar', 'base-key-for-password-hash-mac'); + $cookie->shouldReceive('make')->once()->with($guard->getRecallerName(), 'foo|recaller|'.$expectedHash, 5000)->andReturn($foreverCookie); $cookie->shouldReceive('queue')->once()->with($foreverCookie); $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); $session->shouldReceive('regenerate')->once(); diff --git a/tests/Session/Middleware/AuthenticateSessionTest.php b/tests/Session/Middleware/AuthenticateSessionTest.php index 4cc04e98b751..c3832ad63ec1 100644 --- a/tests/Session/Middleware/AuthenticateSessionTest.php +++ b/tests/Session/Middleware/AuthenticateSessionTest.php @@ -94,11 +94,13 @@ public function getAuthPassword() $authFactory->shouldReceive('viaRemember')->andReturn(false); $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); $authFactory->shouldReceive('user')->andReturn(null); + // expected MAC for current password when storing in session: + $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); $middleware = new AuthenticateSession($authFactory); $response = $middleware->handle($request, fn () => 'next-4'); - $this->assertEquals('my-pass-(*&^%$#!@', $session->get('password_hash_web')); + $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); $this->assertEquals('next-4', $response); } @@ -112,7 +114,7 @@ public function getAuthPassword() } }; - $request = new Request(cookies: ['recaller-name' => 'a|b|my-pass-dont-match']); + $request = new Request(cookies: ['recaller-name' => 'a|b|invalid-mac']); $request->setUserResolver(fn () => $user); $session = new Store('name', new ArraySessionHandler(1)); @@ -127,6 +129,8 @@ public function getAuthPassword() $authFactory->shouldReceive('logoutCurrentDevice')->once()->andReturn(null); $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); $authFactory->shouldReceive('user')->andReturn(null); + // expected MAC for current password (won't match cookie): + $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); $this->assertNotNull($session->get('a')); $this->assertNotNull($session->get('b')); @@ -159,7 +163,7 @@ public function getAuthPassword() } }; - $request = new Request(cookies: ['recaller-name' => 'a|b|my-pass-dont-match']); + $request = new Request(cookies: ['recaller-name' => 'a|b|invalid-mac']); $request->setUserResolver(fn () => $user); $session = new Store('name', new ArraySessionHandler(1)); @@ -174,6 +178,8 @@ public function getAuthPassword() $authFactory->shouldReceive('logoutCurrentDevice')->once(); $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); $authFactory->shouldReceive('user')->andReturn(null); + // expected MAC for current password (won't match cookie): + $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); $middleware = new AuthenticateSession($authFactory); // act: @@ -201,7 +207,7 @@ public function getAuthPassword() } }; - $request = new Request(cookies: ['recaller-name' => 'a|b|my-pass-(*&^%$#!@']); + $request = new Request(cookies: ['recaller-name' => 'a|b|mac:my-pass-(*&^%$#!@']); $request->setUserResolver(fn () => $user); $session = new Store('name', new ArraySessionHandler(1)); @@ -217,6 +223,8 @@ public function getAuthPassword() $authFactory->shouldReceive('logoutCurrentDevice')->once()->andReturn(null); $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); $authFactory->shouldReceive('user')->andReturn(null); + // expected MAC for current password (matches cookie but not session): + $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); // act: $middleware = new AuthenticateSession($authFactory); @@ -250,7 +258,7 @@ public function getAuthPassword() $session = new Store('name', new ArraySessionHandler(1)); $session->put('a', '1'); $session->put('b', '2'); - $session->put('password_hash_web', 'my-pass-(*&^%$#!@'); + $session->put('password_hash_web', 'mac:my-pass-(*&^%$#!@'); // set session on the request: $request->setLaravelSession($session); @@ -260,6 +268,8 @@ public function getAuthPassword() $authFactory->shouldReceive('logoutCurrentDevice')->never(); $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); $authFactory->shouldReceive('user')->andReturn($user); + // expected MAC for current password: + $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); // act: $middleware = new AuthenticateSession($authFactory); @@ -267,7 +277,47 @@ public function getAuthPassword() $this->assertEquals('next-8', $response); // ensure session is flushed: - $this->assertEquals('my-pass-(*&^%$#!@', $session->get('password_hash_web')); + $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); + $this->assertEquals('1', $session->get('a')); + $this->assertEquals('2', $session->get('b')); + } + + public function test_handle_with_old_format_cookie_for_backward_compatibility() + { + $user = new class + { + public function getAuthPassword() + { + return 'my-pass-(*&^%$#!@'; + } + }; + + // Cookie contains OLD format (raw password hash, not HMAC) + $request = new Request(cookies: ['recaller-name' => 'a|b|my-pass-(*&^%$#!@']); + $request->setUserResolver(fn () => $user); + + $session = new Store('name', new ArraySessionHandler(1)); + $session->put('a', '1'); + $session->put('b', '2'); + // Session also contains old format for this test + $session->put('password_hash_web', 'my-pass-(*&^%$#!@'); + $request->setLaravelSession($session); + + $authFactory = Mockery::mock(AuthFactory::class); + $authFactory->shouldReceive('viaRemember')->andReturn(true); + $authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name'); + $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); + $authFactory->shouldReceive('user')->andReturn($user); + // The HMAC won't match the old format, but fallback to raw hash should work + $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); + + $middleware = new AuthenticateSession($authFactory); + $response = $middleware->handle($request, fn () => 'next-9'); + + // Should succeed because of backward compatibility fallback + $this->assertEquals('next-9', $response); + // Session should be updated to new format (HMAC) + $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); $this->assertEquals('1', $session->get('a')); $this->assertEquals('2', $session->get('b')); }