Search code examples
phplaravellaravel-authentication

Allow multiple password reset tokens in Laravel


The default behaviour of Laravel (5.7)'s password reset system is to create a new token in the password_resets table after deleting any others for that user. This behaviour is determined in \Illuminate\Auth\Passwords\DatabaseTokenRepository and it doesn't seem configurable.

protected function deleteExisting(CanResetPasswordContract $user)
{
    return $this->getTable()->where('email', $user->getEmailForPasswordReset())->delete();
}

There's so much inheritance going on, I can't figure out what classes to extend so I can insert my own rules.

Is it possible to allow a certain number password resets to exist simultaneously without hacking into the Laravel core files? What classes do I need to extend?


Solution

  • The answer provided did not help me override the correct class, but it did give me some ideas how to approach this. So I ended up creating three classes, all of which extend built-in classes:

    DatabaseTokenRepository

    This is where I did the overrides from the parent class to allow my custom behaviour; keep the two most recent entries when creating a new reset token, and check multiple tokens when performing the reset.

    <?php
    
    namespace App\Services;
    
    use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
    use Illuminate\Auth\Passwords\DatabaseTokenRepository as DatabaseTokenRepositoryBase;
    
    class DatabaseTokenRepository extends DatabaseTokenRepositoryBase
    {
        /**
         * Create a new token record.
         *
         * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
         * @return string
         */
        public function create(CanResetPasswordContract $user)
        {
            $email = $user->getEmailForPasswordReset();
    
            $this->deleteSomeExisting($user);
    
            // We will create a new, random token for the user so that we can e-mail them
            // a safe link to the password reset form. Then we will insert a record in
            // the database so that we can verify the token within the actual reset.
            $token = $this->createNewToken();
    
            $this->getTable()->insert($this->getPayload($email, $token));
    
            return $token;
        }
    
        /**
         * Determine if a token record exists and is valid.
         *
         * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
         * @param  string  $token
         * @return bool
         */
        public function exists(CanResetPasswordContract $user, $token)
        {
            $records = $this->getTable()
                ->where("email", $user->getEmailForPasswordReset())
                ->get();
    
            foreach ($records as $record) {
                if (
                   ! $this->tokenExpired($record->created_at) &&
                     $this->hasher->check($token, $record->token)
                ) {
                    return true;
                }
            }
            return false;
        }
    
        /**
         * Delete SOME existing reset tokens from the database.
         *
         * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
         * @return int
         */
        protected function deleteSomeExisting($user)
        {
            // TODO: make this configurable in app config
            $limit = 3;
            $records = $this->getTable()
                ->where("email", $user->getEmailForPasswordReset())
                ->orderBy("created_at");
            $ct = $records->count() - $limit + 1;
            return ($ct > 0) ? $records->limit($ct)->delete() : 0;
        }
    }
    

    PasswordBrokerManager

    This just ensures that my custom repository class above is used. The function is copied exactly from the parent class, but is, of course, in a different namespace.

    <?php
    
    namespace App\Services;
    
    use Illuminate\Support\Str;
    use Illuminate\Auth\Passwords\PasswordBrokerManager as PasswordBrokerManagerBase;
    
    class PasswordBrokerManager extends PasswordBrokerManagerBase
    {
        /**
         * Create a token repository instance based on the given configuration.
         *
         * @param  array  $config
         * @return \Illuminate\Auth\Passwords\TokenRepositoryInterface
         */
        protected function createTokenRepository(array $config)
        {
            $key = $this->app['config']['app.key'];
    
            if (Str::startsWith($key, 'base64:')) {
                $key = base64_decode(substr($key, 7));
            }
    
            $connection = $config['connection'] ?? null;
    
            return new DatabaseTokenRepository(
                $this->app['db']->connection($connection),
                $this->app['hash'],
                $config['table'],
                $key,
                $config['expire']
            );
        }
    }
    

    PasswordResetServiceProvider

    Again, just ensuring that the custom class is returned. Again, only the namespace changes from the original.

    <?php
    
    namespace App\Providers;
    
    use App\Services\PasswordBrokerManager;
    use Illuminate\Auth\Passwords\PasswordResetServiceProvider as PasswordResetServiceProviderBase;
    
    class PasswordResetServiceProvider extends PasswordResetServiceProviderBase
    {
        /**
         * Register the password broker instance.
         *
         * @return void
         */
        protected function registerPasswordBroker()
        {
            $this->app->singleton("auth.password", function ($app) {
                return new PasswordBrokerManager($app);
            });
    
            $this->app->bind("auth.password.broker", function ($app) {
                return $app->make("auth.password")->broker();
            });
        }
    }
    

    Finally, the application config is updated to use my provider instead of the original:

        // Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
        App\Providers\PasswordResetServiceProvider::class,
    

    And everything works beautifully.