Skip to content

Commit

Permalink
Merge pull request #601 from sammyskills/force-password-reset
Browse files Browse the repository at this point in the history
Feat: Force password reset
  • Loading branch information
kenjis authored Feb 3, 2023
2 parents 019e940 + 83baaa8 commit 2263f82
Show file tree
Hide file tree
Showing 12 changed files with 401 additions and 12 deletions.
67 changes: 67 additions & 0 deletions docs/forcing_password_reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Forcing Password Reset

Depending on the scope of your application, there may be times when you'll decide that it is absolutely necessary to force user(s) to reset their password. This practice is common when you find out that users of your application do not use strong passwords OR there is a reasonable suspicion that their passwords have been compromised. This guide provides you with ways to achieve this.

- [Forcing Password Reset](#forcing-password-reset)
- [Available Methods](#available-methods)
- [Check if a User Requires Password Reset](#check-if-a-user-requires-password-reset)
- [Force Password Reset On a User](#force-password-reset-on-a-user)
- [Removing Password Reset Flag On a User](#removing-password-reset-flag-on-a-user)
- [Force Password Reset On Multiple Users](#force-password-reset-on-multiple-users)
- [Force Password Reset On All Users](#force-password-reset-on-all-users)

## Available Methods

Shield provides a way to enforce password resets throughout your application. The `Resettable` trait on the `User` entity and the `UserIdentityModel` provides the following methods to do so.

### Check if a User Requires Password Reset

When you need to check if a user requires password reset, you can do so using the `requiresPasswordReset()` method on the `User` entity. Returns boolean `true`/`false`.

```php
if ($user->requiresPasswordReset()) {
//...
}
```

### Force Password Reset On a User

To force password reset on a user, you can do so using the `forcePasswordReset()` method on the `User` entity.

```php
$user->forcePasswordReset();
```

### Remove Force Password Reset Flag On a User

Undoing or removing the force password reset flag on a user can be done using the `undoForcePasswordReset()` method on the `User` entity.

```php
$user->undoForcePasswordReset();
```

### Force Password Reset On Multiple Users

If you see the need to force password reset for more than one user, the `forceMultiplePasswordReset()` method of the `UserIdentityModel` allows you to do this easily. It accepts an `Array` of user IDs.

```php
use CodeIgniter\Shield\Models\UserIdentityModel;
...
...
...
$identities = new UserIdentityModel();
$identities->forceMultiplePasswordReset([1,2,3,4]);
```

### Force Password Reset On All Users

If you suspect a security breach or compromise in the passwords of your users, you can easily force password reset on all the users of your application using the `forceGlobalPasswordReset()` method of the `UserIdentityModel`.

```php
use CodeIgniter\Shield\Models\UserIdentityModel;
...
...
...
$identities = new UserIdentityModel();
$identities->forceGlobalPasswordReset();
```
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* [Authentication](authentication.md)
* [Authorization](authorization.md)
* [Auth Actions](auth_actions.md)
* [Forcing Password Reset](forcing_password_reset.md)
* [Events](events.md)
* [Testing](testing.md)
* [Customization](customization.md)
Expand Down
20 changes: 19 additions & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [Controller Filters](#controller-filters)
- [Protect All Pages](#protect-all-pages)
- [Rate Limiting](#rate-limiting)
- [Forcing Password Reset](#forcing-password-reset)

These instructions assume that you have already [installed the CodeIgniter 4 app starter](https://codeigniter.com/user_guide/installation/installing_composer.html) as the basis for your new project, set up your **.env** file, and created a database that you can access via the Spark CLI script.

Expand Down Expand Up @@ -196,6 +197,7 @@ public $aliases = [
'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class,
'group' => \CodeIgniter\Shield\Filters\GroupFilter::class,
'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class,
'force-reset' => \CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class,
];
```

Expand All @@ -206,6 +208,7 @@ chained | The filter will check both authenticators in sequence to see if the us
auth-rates | Provides a good basis for rate limiting of auth-related routes.
group | Checks if the user is in one of the groups passed in.
permission | Checks if the user has the passed permissions.
force-reset | Checks if the user requires a password reset.

These can be used in any of the [normal filter config settings](https://codeigniter.com/user_guide/incoming/filters.html#globals), or [within the routes file](https://codeigniter.com/user_guide/incoming/routing.html#applying-filters).

Expand Down Expand Up @@ -241,6 +244,21 @@ public $filters = [
];
```

### Forcing Password Reset

If your application requires a force password reset functionality, ensure that you exclude the auth pages and the actual password reset page from the `before` global. This will ensure that your users do not run into a *too many redirects* error. See:

```php
public $globals = [
'before' => [
//...
//...
'force-reset' => ['except' => ['login*', 'register', 'auth*', 'change-password']]
]
];
```
In the example above, it is assumed that the page you have created for users to change their password after successful login is **change-password**.

> **Note** If you have grouped or changed the default format of the routes, ensure that your code matches the new format(s) in the **app/Config/Filter.php** file.
For example, if you configured your routes like so:
Expand All @@ -260,4 +278,4 @@ public $globals = [
]
]
```
The same should apply for the Rate Limiting.
The same should apply for the Rate Limiting and Forcing Password Reset.
18 changes: 15 additions & 3 deletions src/Config/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ class Auth extends BaseConfig
* to apply any logic you may need.
*/
public array $redirects = [
'register' => '/',
'login' => '/',
'logout' => 'login',
'register' => '/',
'login' => '/',
'logout' => 'login',
'force_reset' => '/',
];

/**
Expand Down Expand Up @@ -378,6 +379,17 @@ public function registerRedirect(): string
return $this->getUrl($url);
}

/**
* Returns the URL the user should be redirected to
* if force_reset identity is set to true.
*/
public function forcePasswordResetRedirect(): string
{
$url = setting('Auth.redirects')['force_reset'];

return $this->getUrl($url);
}

/**
* Accepts a string which can be an absolute URL or
* a named route or just a URI path, and returns the
Expand Down
14 changes: 8 additions & 6 deletions src/Config/Registrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use CodeIgniter\Shield\Collectors\Auth;
use CodeIgniter\Shield\Filters\AuthRates;
use CodeIgniter\Shield\Filters\ChainAuth;
use CodeIgniter\Shield\Filters\ForcePasswordResetFilter;
use CodeIgniter\Shield\Filters\GroupFilter;
use CodeIgniter\Shield\Filters\PermissionFilter;
use CodeIgniter\Shield\Filters\SessionAuth;
Expand All @@ -22,12 +23,13 @@ public static function Filters(): array
{
return [
'aliases' => [
'session' => SessionAuth::class,
'tokens' => TokenAuth::class,
'chain' => ChainAuth::class,
'auth-rates' => AuthRates::class,
'group' => GroupFilter::class,
'permission' => PermissionFilter::class,
'session' => SessionAuth::class,
'tokens' => TokenAuth::class,
'chain' => ChainAuth::class,
'auth-rates' => AuthRates::class,
'group' => GroupFilter::class,
'permission' => PermissionFilter::class,
'force-reset' => ForcePasswordResetFilter::class,
],
];
}
Expand Down
2 changes: 2 additions & 0 deletions src/Entities/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use CodeIgniter\Shield\Authorization\Traits\Authorizable;
use CodeIgniter\Shield\Models\LoginModel;
use CodeIgniter\Shield\Models\UserIdentityModel;
use CodeIgniter\Shield\Traits\Resettable;

/**
* @property string|null $email
Expand All @@ -25,6 +26,7 @@ class User extends Entity
{
use Authorizable;
use HasAccessTokens;
use Resettable;

/**
* @var UserIdentity[]|null
Expand Down
54 changes: 54 additions & 0 deletions src/Filters/ForcePasswordResetFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace CodeIgniter\Shield\Filters;

use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Shield\Authentication\Authenticators\Session;

/**
* Force Password Reset Filter.
*/
class ForcePasswordResetFilter implements FilterInterface
{
/**
* Checks if a logged in user should reset their
* password, and then redirect to the appropriate
* page.
*
* @param array|null $arguments
*
* @return RedirectResponse|void
*/
public function before(RequestInterface $request, $arguments = null)
{
if (! $request instanceof IncomingRequest) {
return;
}

helper('setting');

/** @var Session $authenticator */
$authenticator = auth('session')->getAuthenticator();

if ($authenticator->loggedIn() && $authenticator->getUser()->requiresPasswordReset()) {
return redirect()->to(config('Auth')->forcePasswordResetRedirect());
}
}

/**
* We don't have anything to do here.
*
* @param Response|ResponseInterface $response
* @param array|null $arguments
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
}
}
35 changes: 34 additions & 1 deletion src/Models/UserIdentityModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,46 @@ public function revokeAllAccessTokens(User $user): void
$this->checkQueryReturn($return);
}

/**
* Force password reset for multiple users.
*
* @param int[]|string[] $userIds
*/
public function forceMultiplePasswordReset(array $userIds): void
{
$this->where(['type' => Session::ID_TYPE_EMAIL_PASSWORD, 'force_reset' => 0]);
$this->whereIn('user_id', $userIds);
$this->set('force_reset', 1);
$return = $this->update();

$this->checkQueryReturn($return);
}

/**
* Force global password reset.
* This is useful for enforcing a password reset
* for ALL users incase of a security breach.
*/
public function forceGlobalPasswordReset(): void
{
$whereFilter = [
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
'force_reset' => 0,
];
$this->where($whereFilter);
$this->set('force_reset', 1);
$return = $this->update();

$this->checkQueryReturn($return);
}

public function fake(Generator &$faker): UserIdentity
{
return new UserIdentity([
'user_id' => fake(UserModel::class)->id,
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
'name' => null,
'secret' => 'info@example.com',
'secret' => $faker->email,
'secret2' => password_hash('secret', PASSWORD_DEFAULT),
'expires' => null,
'extra' => null,
Expand Down
2 changes: 1 addition & 1 deletion src/Models/UserModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public function addToDefaultGroup(User $user): void
public function fake(Generator &$faker): User
{
return new User([
'username' => $faker->userName,
'username' => $faker->unique()->userName,
'active' => true,
]);
}
Expand Down
61 changes: 61 additions & 0 deletions src/Traits/Resettable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace CodeIgniter\Shield\Traits;

use CodeIgniter\Shield\Authentication\Authenticators\Session;
use CodeIgniter\Shield\Models\UserIdentityModel;

/**
* Reusable methods to help the
* enforcing of password resets
*/
trait Resettable
{
/**
* Returns true|false based on the value of the
* force reset column of the user's identity.
*/
public function requiresPasswordReset(): bool
{
$identity_model = model(UserIdentityModel::class);
$identity = $identity_model->getIdentityByType($this, Session::ID_TYPE_EMAIL_PASSWORD);

return $identity->force_reset;
}

/**
* Force password reset
*/
public function forcePasswordReset(): void
{
// Do nothing if user already requires reset
if ($this->requiresPasswordReset()) {
return;
}

// Set force_reset to true
$identity_model = model(UserIdentityModel::class);
$identity_model->set('force_reset', 1);
$identity_model->where(['user_id' => $this->id, 'type' => Session::ID_TYPE_EMAIL_PASSWORD]);
$identity_model->update();
}

/**
* Undo Force password reset
*/
public function undoForcePasswordReset(): void
{
// If user doesn't require password reset, do nothing
if ($this->requiresPasswordReset() === false) {
return;
}

// Set force_reset to false
$identity_model = model(UserIdentityModel::class);
$identity_model->set('force_reset', 0);
$identity_model->where(['user_id' => $this->id, 'type' => Session::ID_TYPE_EMAIL_PASSWORD]);
$identity_model->update();
}
}
Loading

0 comments on commit 2263f82

Please sign in to comment.