├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── phpunit.xml.dist ├── src ├── FromAttemptPasswordReHasher.php ├── Providers │ ├── DatabaseUserProviderWithPasswordUpdate.php │ ├── EloquentUserProviderWithPasswordUpdate.php │ └── ProviderWithPasswordUpdate.php ├── RehashServiceProvider.php └── UnexpectedProviderException.php └── tests ├── Admin.php ├── EmptyCustomerUserProvider.php ├── FromAttemptPasswordReHasherTest.php ├── RehashServiceProviderTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | 4 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 5 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 6 | # composer.lock 7 | 8 | .phpunit.result.cache 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.3 5 | - 7.4 6 | - 8.0 7 | - 8.1 8 | - 8.2 9 | 10 | install: travis_retry composer install --no-interaction --prefer-source 11 | 12 | script: vendor/bin/phpunit --coverage-clover coverage.clover 13 | 14 | after_script: 15 | - wget https://scrutinizer-ci.com/ocular.phar 16 | - php ocular.phar code-coverage:upload --format=php-clover coverage.clover 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Samson Endale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | laravel-needs-auto-rehash ![From Ethiopia](https://img.shields.io/badge/From-Ethiopia-brightgreen.svg) 2 | ========================= 3 | 4 | [![Build Status](https://travis-ci.org/SamAsEnd/laravel-needs-auto-rehash.svg?branch=master)](https://travis-ci.org/SamAsEnd/laravel-needs-auto-rehash) 5 | [![StyleCI](https://github.styleci.io/repos/297123581/shield?branch=master)](https://github.styleci.io/repos/297123581?branch=master) 6 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/SamAsEnd/laravel-needs-auto-rehash/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/SamAsEnd/laravel-needs-auto-rehash/?branch=master) 7 | [![Code Coverage](https://scrutinizer-ci.com/g/SamAsEnd/laravel-needs-auto-rehash/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/SamAsEnd/laravel-needs-auto-rehash/?branch=master) 8 | [![License](https://poser.pugx.org/samasend/laravel-needs-auto-rehash/license.svg)](https://packagist.org/packages/samasend/laravel-needs-auto-rehash) 9 | 10 | This package automates the common password [`Hash::needsRehash`](https://laravel.com/docs/8.x/hashing#basic-usage) routine by hooking into the [built-in event system](https://laravel.com/docs/8.x/authentication#events). 11 | 12 | Use case 13 | -------- 14 | When a user register, Laravel uses `bcrypt` algorithm with a cost factor of `10` to hash passwords. 15 | 16 | The ~~problem~~ is when you change [the default hashing algorithm](https://github.com/laravel/laravel/blob/master/config/hashing.php#L18) or 17 | when Laravel eventually changes [the default algorithm](https://github.com/laravel/framework/blob/master/src/Illuminate/Hashing/HashManager.php#L95) to `argon2i` 18 | or PHP recommended [`PASSWORD_DEFAULT` constant](https://www.php.net/manual/en/password.constants.php) changes, and you want to keep up 19 | or simply want to upgrade the `cost` factor of `bcrypt`; your changes will only be reflected on **newly registered users** or when **existing users change their password**. 20 | 21 | You have to implement a common routine task to upgrade users' password hash by checking `Hash::needsRehash` whenever the user provides a valid credential. 22 | 23 | Prerequisites 24 | ------------- 25 | - **PHP** 7.2 or greater and 8.0.2 or greater 26 | - **Laravel** 6.x || 7.x || 8.x || 9.x || 10.x 27 | 28 | Installation 29 | ------------ 30 | ```bash 31 | composer require samasend/laravel-needs-auto-rehash 32 | ``` 33 | 34 | Basic Usage 35 | ----------- 36 | That's it, you just need to install the package. :rocket: 37 | 38 | How does this works? 39 | -------------------- 40 | - This magical package listen for the built-in `Illuminate\Auth\Events\Attempting` [event fired from the framework](https://laravel.com/docs/8.x/authentication#events) and validate the credentials [using the built-in infrastructure](https://laravel.com/docs/8.x/authentication#the-user-provider-contract). 41 | - If the user password needs rehashing, it will rehash the password and update the model. 42 | 43 | Contributing 44 | ------------ 45 | Fork it 46 | Create your feature branch (git checkout -b my-new-feature) 47 | Commit your changes (git commit -am 'Add some feature') 48 | Push to the branch (git push origin my-new-feature) 49 | Create new Pull Request 50 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "samasend/laravel-needs-auto-rehash", 3 | "description": "Automate the common password `Auth::needsRehash` routine using built-in event", 4 | "type": "library", 5 | "keywords": ["laravel", "password", "rehash", "hash", "needsRehash"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "SamAsEnd", 10 | "email": "4sam21@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.2|^8.0.2", 15 | "illuminate/auth": "^6.0|^7.0|^8.0|^9.0|^10.0", 16 | "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0", 17 | "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0" 18 | }, 19 | "require-dev": { 20 | "orchestra/testbench": "~3.6.7 || ~3.7.8 || ~3.8.6 || ^4.8 || ^5.2 || ^6.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "SamAsEnd\\NeedsAutoRehash\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "SamAsEnd\\NeedsAutoRehash\\Tests\\": "tests/" 30 | } 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | }, 35 | "extra": { 36 | "branch-alias": { 37 | "dev-master": "1.0-dev" 38 | }, 39 | "laravel": { 40 | "providers": [ 41 | "SamAsEnd\\NeedsAutoRehash\\RehashServiceProvider" 42 | ] 43 | } 44 | }, 45 | "minimum-stability": "dev", 46 | "prefer-stable": true 47 | } 48 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | src/ 17 | 18 | 19 | 20 | 21 | tests 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/FromAttemptPasswordReHasher.php: -------------------------------------------------------------------------------- 1 | app = $container; 45 | $this->auth = $auth; 46 | $this->hash = $hash; 47 | $this->config = $config; 48 | 49 | $this->provider = $this->getUserProviderWithPasswordUpdate(); 50 | } 51 | 52 | protected function getUserProviderWithPasswordUpdate(): ProviderWithPasswordUpdate 53 | { 54 | $provider = $this->createUserProvider($this->getUserProviderName()); 55 | 56 | if ($provider instanceof DatabaseUserProvider) { 57 | return new DatabaseUserProviderWithPasswordUpdate($provider); 58 | } 59 | 60 | if ($provider instanceof EloquentUserProvider) { 61 | return new EloquentUserProviderWithPasswordUpdate($provider); 62 | } 63 | 64 | try { 65 | return $this->app->make(ProviderWithPasswordUpdate::class); 66 | } catch (BindingResolutionException $e) { 67 | throw new UnexpectedProviderException('ProviderWithPasswordUpdate could not be found.', $e); 68 | } 69 | } 70 | 71 | public function handle(Attempting $event) 72 | { 73 | $user = $this->provider->retrieveByCredentials($event->credentials); 74 | 75 | if (!is_null($user) && $this->validCredentials($event) && $this->passwordNeedsRehash($user)) { 76 | $this->passwordUpdateRehash($user, $event->credentials['password']); 77 | } 78 | } 79 | 80 | protected function validCredentials(Attempting $attempting): bool 81 | { 82 | return $this->auth->guard($attempting->guard)->validate($attempting->credentials); 83 | } 84 | 85 | protected function passwordNeedsRehash(Authenticatable $user) 86 | { 87 | return $this->hash->needsRehash($user->getAuthPassword(), $this->getHashingOptions()); 88 | } 89 | 90 | protected function passwordUpdateRehash(Authenticatable $user, $password) 91 | { 92 | $this->provider->updatePassword($user, $password); 93 | } 94 | 95 | protected function getUserProviderName() 96 | { 97 | $guard = $this->config->get('auth.defaults.guard'); 98 | 99 | return 100 | $this->getDefaultUserProvider() ?? 101 | $this->config->get('auth.guards.'.$guard.'.provider'); 102 | } 103 | 104 | protected function getHashingOptions() 105 | { 106 | $driver = $this->config->get('hashing.driver'); 107 | 108 | return $this->config->get('hashing.'.$driver); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Providers/DatabaseUserProviderWithPasswordUpdate.php: -------------------------------------------------------------------------------- 1 | conn, $provider->hasher, $provider->table); 13 | } 14 | 15 | public function updatePassword(Authenticatable $user, $plainPassword) 16 | { 17 | $this->conn->table($this->table) 18 | ->where($user->getAuthIdentifierName(), $user->getAuthIdentifier()) 19 | ->update(['password' => $this->hasher->make($plainPassword)]); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Providers/EloquentUserProviderWithPasswordUpdate.php: -------------------------------------------------------------------------------- 1 | hasher, $provider->model); 13 | } 14 | 15 | public function updatePassword(Authenticatable $user, $plainPassword) 16 | { 17 | $user->password = $this->hasher->make($plainPassword); 18 | 19 | $timestamps = $user->timestamps; 20 | 21 | $user->timestamps = false; 22 | 23 | $user->save(); 24 | 25 | $user->timestamps = $timestamps; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Providers/ProviderWithPasswordUpdate.php: -------------------------------------------------------------------------------- 1 | loadLaravelMigrations(); 24 | } 25 | 26 | public function testThrowExceptionForUnknownProvider() 27 | { 28 | config(['auth.defaults.provider' => 'lorem ipsum']); 29 | 30 | try { 31 | Auth::guard('web')->attempt([ 32 | 'email' => '4sam21@gmail.com', 33 | 'password' => 'password', 34 | ]); 35 | 36 | $this->fail('UnexpectedProviderException should have been thrown.'); 37 | } catch (Throwable $exception) { 38 | $this->assertInstanceOf(UnexpectedProviderException::class, $exception); 39 | } 40 | } 41 | 42 | public function testEloquentPasswordUpdateRehash() 43 | { 44 | $this->testPasswordUpdateRehash(); 45 | } 46 | 47 | public function testDatabasePasswordUpdateRehash() 48 | { 49 | config([ 50 | 'auth.providers.users' => [ 51 | 'driver' => 'database', 52 | 'table' => 'users', 53 | ], 54 | ]); 55 | 56 | $this->testPasswordUpdateRehash(); 57 | } 58 | 59 | public function testCustomUserProviderPasswordUpdateRehash() 60 | { 61 | Schema::dropIfExists('admins'); 62 | 63 | Schema::create('admins', function (Blueprint $table) { 64 | $table->id(); 65 | $table->string('name'); 66 | $table->string('email')->unique(); 67 | $table->string('password'); 68 | $table->rememberToken(); 69 | $table->timestamps(); 70 | }); 71 | 72 | $empty = new EmptyCustomerUserProvider(DB::connection(), app(Hasher::class), 'admins'); 73 | 74 | $this->app->instance(ProviderWithPasswordUpdate::class, $empty); 75 | 76 | config(['auth.defaults.provider' => 'custom-provider']); 77 | config(['auth.providers.users.model' => Admin::class]); 78 | 79 | Auth::provider('custom-provider', function ($app, array $config) use ($empty) { 80 | return $empty; 81 | }); 82 | 83 | // FIXME: Load the User provider properly 84 | 85 | $this->testPasswordUpdateRehash('admins', 'web'); 86 | } 87 | 88 | public function testPasswordUpdateRehash($table = 'users', $guard = 'web') 89 | { 90 | DB::table($table)->insert([ 91 | 'name' => 'Samson Endale', 92 | 'email' => '4sam21@gmail.com', 93 | 'password' => $hash = password_hash('password', PASSWORD_BCRYPT, ['cost' => 4]), 94 | ]); 95 | 96 | config(['bcrypt.rounds' => 5]); 97 | 98 | $this->assertDatabaseHas($table, [ 99 | 'name' => 'Samson Endale', 100 | 'email' => '4sam21@gmail.com', 101 | 'password' => $hash, 102 | ]); 103 | 104 | Auth::guard($guard)->attempt([ 105 | 'email' => '4sam21@gmail.com', 106 | 'password' => 'password', 107 | ]); 108 | 109 | $this->assertDatabaseCount($table, 1); 110 | 111 | $this->assertDatabaseMissing($table, [ 112 | 'name' => 'Samson Endale', 113 | 'email' => '4sam21@gmail.com', 114 | 'password' => $hash, 115 | ]); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/RehashServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | assertTrue( 14 | $this->app->providerIsLoaded(RehashServiceProvider::class), 15 | 'RehashServiceProvider is not loaded' 16 | ); 17 | } 18 | 19 | public function testFromAttemptPasswordReHasherIsListening() 20 | { 21 | $guard = 'some-guard'; 22 | $credentials = ['email' => 'some', 'password' => 'test']; 23 | $remember = false; 24 | 25 | $this->mock(FromAttemptPasswordReHasher::class, function ($mock) use ($guard, $credentials, $remember) { 26 | $mock->shouldReceive('handle') 27 | ->withArgs(function (Attempting $attempting) use ($guard, $credentials, $remember) { 28 | return 29 | $attempting->guard === $guard && 30 | $attempting->credentials == $credentials && 31 | $attempting->remember === $remember; 32 | }) 33 | ->once(); 34 | }); 35 | 36 | event(new Attempting($guard, $credentials, $remember)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |