Skip to content

Feat: Force password reset #601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Feb 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6c76cc4
added resettable trait
sammyskills Jan 16, 2023
9a07e9d
updated user entity, use resettable trait
sammyskills Jan 16, 2023
4ba4ce9
updated user identity model, created new methods
sammyskills Jan 16, 2023
320e4d4
updated auth config, added force_reset redirect
sammyskills Jan 16, 2023
415edf8
added ForcePasswordResetFilter
sammyskills Jan 16, 2023
f36a46b
updated registrar config, added the force-reset filter
sammyskills Jan 16, 2023
8812d40
created Tests: ForcePasswordResetTest
sammyskills Jan 16, 2023
947705d
updated Tests: UserIdentityModelTest
sammyskills Jan 16, 2023
0d2f89a
updated docs:install.md, added force password reset explanation
sammyskills Jan 16, 2023
066126e
apply suggestions from code review: removed testForceGlobalPasswordRe…
sammyskills Jan 16, 2023
268018f
fixed Postgres test failure
sammyskills Jan 19, 2023
7f68d02
fixed Rector Analysis failure
sammyskills Jan 19, 2023
8159ea6
rebased docs:install.md
sammyskills Jan 19, 2023
8b45094
fixed checks
sammyskills Jan 20, 2023
7c044fa
fixed postgres test failure - b2
sammyskills Jan 20, 2023
7c94a67
updated docs:install.md
sammyskills Jan 21, 2023
7e77fd4
forceGlobalPasswordReset() no longer excludes the logged in user
sammyskills Jan 24, 2023
0621000
reduced test users to 50
sammyskills Jan 24, 2023
2a4d1c3
added the unique() faker modifier to prevent duplicates
sammyskills Jan 24, 2023
482c715
applied code review suggestions on code files
sammyskills Jan 31, 2023
03351e0
applied code review suggestions on docs
sammyskills Jan 31, 2023
491b0d2
fixed phpstan error
sammyskills Feb 1, 2023
f61a5b3
fixed phpstan error - 2
sammyskills Feb 1, 2023
1b406e4
fixed phpstan error -3
sammyskills Feb 1, 2023
83baaa8
applied code review suggestion on tests and auth config file
sammyskills Feb 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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