diff --git a/src/Illuminate/Auth/AuthManager.php b/src/Illuminate/Auth/AuthManager.php index e95da5ec4ae4..4bba02800ad7 100755 --- a/src/Illuminate/Auth/AuthManager.php +++ b/src/Illuminate/Auth/AuthManager.php @@ -128,6 +128,7 @@ public function createSessionDriver($name, $config) $name, $provider, $this->app['session.store'], + rehashOnLogin: $this->app['config']->get('hashing.rehash_on_login', true), ); // When using the remember me functionality of the authentication services we diff --git a/src/Illuminate/Auth/Authenticatable.php b/src/Illuminate/Auth/Authenticatable.php index f1c0115916c8..0145c1cc8901 100644 --- a/src/Illuminate/Auth/Authenticatable.php +++ b/src/Illuminate/Auth/Authenticatable.php @@ -4,6 +4,13 @@ trait Authenticatable { + /** + * The column name of the password field using during authentication. + * + * @var string + */ + protected $authPasswordName = 'password'; + /** * The column name of the "remember me" token. * @@ -41,6 +48,16 @@ public function getAuthIdentifierForBroadcasting() return $this->getAuthIdentifier(); } + /** + * Get the name of the password attribute for the user. + * + * @return string + */ + public function getAuthPasswordName() + { + return $this->authPasswordName; + } + /** * Get the password for the user. * @@ -48,7 +65,7 @@ public function getAuthIdentifierForBroadcasting() */ public function getAuthPassword() { - return $this->password; + return $this->{$this->getAuthPasswordName()}; } /** diff --git a/src/Illuminate/Auth/DatabaseUserProvider.php b/src/Illuminate/Auth/DatabaseUserProvider.php index 16b70ee9c76a..1be060cb831a 100755 --- a/src/Illuminate/Auth/DatabaseUserProvider.php +++ b/src/Illuminate/Auth/DatabaseUserProvider.php @@ -158,4 +158,23 @@ public function validateCredentials(UserContract $user, array $credentials) $credentials['password'], $user->getAuthPassword() ); } + + /** + * Rehash the user's password if required and supported. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @param array $credentials + * @param bool $force + * @return void + */ + public function rehashPasswordIfRequired(UserContract $user, array $credentials, bool $force = false) + { + if (! $this->hasher->needsRehash($user->getAuthPassword()) && ! $force) { + return; + } + + $this->connection->table($this->table) + ->where($user->getAuthIdentifierName(), $user->getAuthIdentifier()) + ->update([$user->getAuthPasswordName() => $this->hasher->make($credentials['password'])]); + } } diff --git a/src/Illuminate/Auth/EloquentUserProvider.php b/src/Illuminate/Auth/EloquentUserProvider.php index 39a744e0c098..646c2187f595 100755 --- a/src/Illuminate/Auth/EloquentUserProvider.php +++ b/src/Illuminate/Auth/EloquentUserProvider.php @@ -155,6 +155,25 @@ public function validateCredentials(UserContract $user, array $credentials) return $this->hasher->check($plain, $user->getAuthPassword()); } + /** + * Rehash the user's password if required and supported. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @param array $credentials + * @param bool $force + * @return void + */ + public function rehashPasswordIfRequired(UserContract $user, array $credentials, bool $force = false) + { + if (! $this->hasher->needsRehash($user->getAuthPassword()) && ! $force) { + return; + } + + $user->forceFill([ + $user->getAuthPasswordName() => $this->hasher->make($credentials['password']), + ])->save(); + } + /** * Get a new query builder for the model instance. * diff --git a/src/Illuminate/Auth/GenericUser.php b/src/Illuminate/Auth/GenericUser.php index c87bc2382cb2..57bab7c8435d 100755 --- a/src/Illuminate/Auth/GenericUser.php +++ b/src/Illuminate/Auth/GenericUser.php @@ -44,6 +44,16 @@ public function getAuthIdentifier() return $this->attributes[$this->getAuthIdentifierName()]; } + /** + * Get the name of the password attribute for the user. + * + * @return string + */ + public function getAuthPasswordName() + { + return 'password'; + } + /** * Get the password for the user. * diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index b475dbc6ca2e..7966eaa7da12 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -96,6 +96,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth */ protected $timebox; + /** + * Indicates if passwords should be rehashed on login if needed. + * + * @var bool + */ + protected $rehashOnLogin; + /** * Indicates if the logout method has been called. * @@ -118,19 +125,22 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth * @param \Illuminate\Contracts\Session\Session $session * @param \Symfony\Component\HttpFoundation\Request|null $request * @param \Illuminate\Support\Timebox|null $timebox + * @param bool $rehashOnLogin * @return void */ public function __construct($name, UserProvider $provider, Session $session, Request $request = null, - Timebox $timebox = null) + Timebox $timebox = null, + bool $rehashOnLogin = true) { $this->name = $name; $this->session = $session; $this->request = $request; $this->provider = $provider; $this->timebox = $timebox ?: new Timebox; + $this->rehashOnLogin = $rehashOnLogin; } /** @@ -384,6 +394,8 @@ public function attempt(array $credentials = [], $remember = false) // to validate the user against the given credentials, and if they are in // fact valid we'll log the users into the application and return true. if ($this->hasValidCredentials($user, $credentials)) { + $this->rehashPasswordIfRequired($user, $credentials); + $this->login($user, $remember); return true; @@ -415,6 +427,8 @@ public function attemptWhen(array $credentials = [], $callbacks = null, $remembe // the user is retrieved and validated. If one of the callbacks returns falsy we do // not login the user. Instead, we will fail the specific authentication attempt. if ($this->hasValidCredentials($user, $credentials) && $this->shouldLogin($callbacks, $user)) { + $this->rehashPasswordIfRequired($user, $credentials); + $this->login($user, $remember); return true; @@ -465,6 +479,20 @@ protected function shouldLogin($callbacks, AuthenticatableContract $user) return true; } + /** + * Rehash the user's password if enabled and required. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @param array $credentials + * @return void + */ + protected function rehashPasswordIfRequired(AuthenticatableContract $user, array $credentials) + { + if ($this->rehashOnLogin) { + $this->provider->rehashPasswordIfRequired($user, $credentials); + } + } + /** * Log the given user ID into the application. * @@ -656,18 +684,17 @@ protected function cycleRememberToken(AuthenticatableContract $user) * The application must be using the AuthenticateSession middleware. * * @param string $password - * @param string $attribute * @return \Illuminate\Contracts\Auth\Authenticatable|null * * @throws \Illuminate\Auth\AuthenticationException */ - public function logoutOtherDevices($password, $attribute = 'password') + public function logoutOtherDevices($password) { if (! $this->user()) { return; } - $result = $this->rehashUserPassword($password, $attribute); + $result = $this->rehashUserPasswordForDeviceLogout($password); if ($this->recaller() || $this->getCookieJar()->hasQueued($this->getRecallerName())) { @@ -680,23 +707,24 @@ public function logoutOtherDevices($password, $attribute = 'password') } /** - * Rehash the current user's password. + * Rehash the current user's password for logging out other devices via AuthenticateSession. * * @param string $password - * @param string $attribute * @return \Illuminate\Contracts\Auth\Authenticatable|null * * @throws \InvalidArgumentException */ - protected function rehashUserPassword($password, $attribute) + protected function rehashUserPasswordForDeviceLogout($password) { - if (! Hash::check($password, $this->user()->{$attribute})) { + $user = $this->user(); + + if (! Hash::check($password, $user->getAuthPassword())) { throw new InvalidArgumentException('The given password does not match the current password.'); } - return tap($this->user()->forceFill([ - $attribute => Hash::make($password), - ]))->save(); + $this->provider->rehashPasswordIfRequired( + $user, ['password' => $password], force: true + ); } /** diff --git a/src/Illuminate/Contracts/Auth/Authenticatable.php b/src/Illuminate/Contracts/Auth/Authenticatable.php index ac4ed886d1b7..f5ed4987b44b 100644 --- a/src/Illuminate/Contracts/Auth/Authenticatable.php +++ b/src/Illuminate/Contracts/Auth/Authenticatable.php @@ -18,6 +18,13 @@ public function getAuthIdentifierName(); */ public function getAuthIdentifier(); + /** + * Get the name of the password attribute for the user. + * + * @return string + */ + public function getAuthPasswordName(); + /** * Get the password for the user. * diff --git a/src/Illuminate/Contracts/Auth/UserProvider.php b/src/Illuminate/Contracts/Auth/UserProvider.php index a2ab122718c6..4ed51bf00e9c 100644 --- a/src/Illuminate/Contracts/Auth/UserProvider.php +++ b/src/Illuminate/Contracts/Auth/UserProvider.php @@ -46,4 +46,14 @@ public function retrieveByCredentials(array $credentials); * @return bool */ public function validateCredentials(Authenticatable $user, array $credentials); + + /** + * Rehash the user's password if required and supported. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @param array $credentials + * @param bool $force + * @return void + */ + public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false); } diff --git a/src/Illuminate/Session/Middleware/AuthenticateSession.php b/src/Illuminate/Session/Middleware/AuthenticateSession.php index 43982374b456..b6122cb11eb2 100644 --- a/src/Illuminate/Session/Middleware/AuthenticateSession.php +++ b/src/Illuminate/Session/Middleware/AuthenticateSession.php @@ -44,7 +44,7 @@ public function handle($request, Closure $next) if ($this->guard()->viaRemember()) { $passwordHash = explode('|', $request->cookies->get($this->guard()->getRecallerName()))[2] ?? null; - if (! $passwordHash || $passwordHash != $request->user()->getAuthPassword()) { + if (! $passwordHash || ! hash_equals($request->user()->getAuthPassword(), $passwordHash)) { $this->logout($request); } } @@ -53,7 +53,7 @@ public function handle($request, Closure $next) $this->storePasswordHashInSession($request); } - if ($request->session()->get('password_hash_'.$this->auth->getDefaultDriver()) !== $request->user()->getAuthPassword()) { + if (! hash_equals($request->session()->get('password_hash_'.$this->auth->getDefaultDriver()), $request->user()->getAuthPassword())) { $this->logout($request); } diff --git a/tests/Auth/AuthDatabaseUserProviderTest.php b/tests/Auth/AuthDatabaseUserProviderTest.php index 382d3532a976..ad317be84253 100755 --- a/tests/Auth/AuthDatabaseUserProviderTest.php +++ b/tests/Auth/AuthDatabaseUserProviderTest.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Database\Connection; +use Illuminate\Database\ConnectionInterface; use Mockery as m; use PHPUnit\Framework\TestCase; use stdClass; @@ -160,4 +161,61 @@ public function testCredentialValidation() $this->assertTrue($result); } + + public function testCredentialValidationFailed() + { + $conn = m::mock(Connection::class); + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('check')->once()->with('plain', 'hash')->andReturn(false); + $provider = new DatabaseUserProvider($conn, $hasher, 'foo'); + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword')->once()->andReturn('hash'); + $result = $provider->validateCredentials($user, ['password' => 'plain']); + + $this->assertFalse($result); + } + + public function testRehashPasswordIfRequired() + { + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('needsRehash')->once()->with('hash')->andReturn(true); + $hasher->shouldReceive('make')->once()->with('plain')->andReturn('rehashed'); + + $conn = m::mock(Connection::class); + $table = m::mock(ConnectionInterface::class); + $conn->shouldReceive('table')->once()->with('foo')->andReturn($table); + $table->shouldReceive('where')->once()->with('id', 1)->andReturnSelf(); + $table->shouldReceive('update')->once()->with(['password_attribute' => 'rehashed']); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifierName')->once()->andReturn('id'); + $user->shouldReceive('getAuthIdentifier')->once()->andReturn(1); + $user->shouldReceive('getAuthPassword')->once()->andReturn('hash'); + $user->shouldReceive('getAuthPasswordName')->once()->andReturn('password_attribute'); + + $provider = new DatabaseUserProvider($conn, $hasher, 'foo'); + $provider->rehashPasswordIfRequired($user, ['password' => 'plain']); + } + + public function testDontRehashPasswordIfNotRequired() + { + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('needsRehash')->once()->with('hash')->andReturn(false); + $hasher->shouldNotReceive('make'); + + $conn = m::mock(Connection::class); + $table = m::mock(ConnectionInterface::class); + $conn->shouldNotReceive('table'); + $table->shouldNotReceive('where'); + $table->shouldNotReceive('update'); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword')->once()->andReturn('hash'); + $user->shouldNotReceive('getAuthIdentifierName'); + $user->shouldNotReceive('getAuthIdentifier'); + $user->shouldNotReceive('getAuthPasswordName'); + + $provider = new DatabaseUserProvider($conn, $hasher, 'foo'); + $provider->rehashPasswordIfRequired($user, ['password' => 'plain']); + } } diff --git a/tests/Auth/AuthEloquentUserProviderTest.php b/tests/Auth/AuthEloquentUserProviderTest.php index 9ef0e29636b0..21d181b97ad0 100755 --- a/tests/Auth/AuthEloquentUserProviderTest.php +++ b/tests/Auth/AuthEloquentUserProviderTest.php @@ -140,6 +140,50 @@ public function testCredentialValidation() $this->assertTrue($result); } + public function testCredentialValidationFailed() + { + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('check')->once()->with('plain', 'hash')->andReturn(false); + $provider = new EloquentUserProvider($hasher, 'foo'); + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword')->once()->andReturn('hash'); + $result = $provider->validateCredentials($user, ['password' => 'plain']); + + $this->assertFalse($result); + } + + public function testRehashPasswordIfRequired() + { + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('needsRehash')->once()->with('hash')->andReturn(true); + $hasher->shouldReceive('make')->once()->with('plain')->andReturn('rehashed'); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword')->once()->andReturn('hash'); + $user->shouldReceive('getAuthPasswordName')->once()->andReturn('password_attribute'); + $user->shouldReceive('forceFill')->once()->with(['password_attribute' => 'rehashed'])->andReturnSelf(); + $user->shouldReceive('save')->once(); + + $provider = new EloquentUserProvider($hasher, 'foo'); + $provider->rehashPasswordIfRequired($user, ['password' => 'plain']); + } + + public function testDontRehashPasswordIfNotRequired() + { + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('needsRehash')->once()->with('hash')->andReturn(false); + $hasher->shouldNotReceive('make'); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword')->once()->andReturn('hash'); + $user->shouldNotReceive('getAuthPasswordName'); + $user->shouldNotReceive('forceFill'); + $user->shouldNotReceive('save'); + + $provider = new EloquentUserProvider($hasher, 'foo'); + $provider->rehashPasswordIfRequired($user, ['password' => 'plain']); + } + public function testModelsCanBeCreated() { $hasher = m::mock(Hasher::class); diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index 52c4cfe7d1c8..e75efd642c37 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -103,6 +103,7 @@ public function testAttemptCallsRetrieveByCredentials() $events->shouldReceive('dispatch')->once()->with(m::type(Failed::class)); $events->shouldNotReceive('dispatch')->with(m::type(Validated::class)); $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->with(['foo']); + $guard->getProvider()->shouldNotReceive('rehashPasswordIfRequired'); $guard->attempt(['foo']); } @@ -119,6 +120,7 @@ public function testAttemptReturnsUserInterface() $user = $this->createMock(Authenticatable::class); $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->andReturn($user); $guard->getProvider()->shouldReceive('validateCredentials')->with($user, ['foo'])->andReturn(true); + $guard->getProvider()->shouldReceive('rehashPasswordIfRequired')->with($user, ['foo'])->once(); $guard->expects($this->once())->method('login')->with($this->equalTo($user)); $this->assertTrue($guard->attempt(['foo'])); } @@ -135,6 +137,7 @@ public function testAttemptReturnsFalseIfUserNotGiven() $events->shouldReceive('dispatch')->once()->with(m::type(Failed::class)); $events->shouldNotReceive('dispatch')->with(m::type(Validated::class)); $mock->getProvider()->shouldReceive('retrieveByCredentials')->once()->andReturn(null); + $mock->getProvider()->shouldNotReceive('rehashPasswordIfRequired'); $this->assertFalse($mock->attempt(['foo'])); } @@ -159,6 +162,7 @@ public function testAttemptAndWithCallbacks() $mock->getProvider()->shouldReceive('retrieveByCredentials')->times(3)->with(['foo'])->andReturn($user); $mock->getProvider()->shouldReceive('validateCredentials')->twice()->andReturnTrue(); $mock->getProvider()->shouldReceive('validateCredentials')->once()->andReturnFalse(); + $mock->getProvider()->shouldReceive('rehashPasswordIfRequired')->with($user, ['foo'])->once(); $this->assertTrue($mock->attemptWhen(['foo'], function ($user, $guard) { static::assertInstanceOf(Authenticatable::class, $user); @@ -183,6 +187,44 @@ public function testAttemptAndWithCallbacks() $this->assertFalse($executed); } + public function testAttemptRehashesPasswordWhenRequired() + { + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); + $guard = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['login'])->setConstructorArgs(['default', $provider, $session, $request, $timebox])->getMock(); + $guard->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) { + return $callback($timebox->shouldReceive('returnEarly')->once()->getMock()); + }); + $events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(Validated::class)); + $user = $this->createMock(Authenticatable::class); + $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $guard->getProvider()->shouldReceive('validateCredentials')->with($user, ['foo'])->andReturn(true); + $guard->getProvider()->shouldReceive('rehashPasswordIfRequired')->with($user, ['foo'])->once(); + $guard->expects($this->once())->method('login')->with($this->equalTo($user)); + $this->assertTrue($guard->attempt(['foo'])); + } + + public function testAttemptDoesntRehashPasswordWhenDisabled() + { + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); + $guard = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['login']) + ->setConstructorArgs(['default', $provider, $session, $request, $timebox, $rehashOnLogin = false]) + ->getMock(); + $guard->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) { + return $callback($timebox->shouldReceive('returnEarly')->once()->getMock()); + }); + $events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(Validated::class)); + $user = $this->createMock(Authenticatable::class); + $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $guard->getProvider()->shouldReceive('validateCredentials')->with($user, ['foo'])->andReturn(true); + $guard->getProvider()->shouldNotReceive('rehashPasswordIfRequired'); + $guard->expects($this->once())->method('login')->with($this->equalTo($user)); + $this->assertTrue($guard->attempt(['foo'])); + } + public function testLoginStoresIdentifierInSession() { [$session, $provider, $request, $cookie] = $this->getMocks(); @@ -235,6 +277,7 @@ public function testFailedAttemptFiresFailedEvent() $events->shouldReceive('dispatch')->once()->with(m::type(Failed::class)); $events->shouldNotReceive('dispatch')->with(m::type(Validated::class)); $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn(null); + $guard->getProvider()->shouldNotReceive('rehashPasswordIfRequired'); $guard->attempt(['foo']); }