Skip to content

Commit 2263f82

Browse files
authored
Merge pull request #601 from sammyskills/force-password-reset
Feat: Force password reset
2 parents 019e940 + 83baaa8 commit 2263f82

12 files changed

Lines changed: 401 additions & 12 deletions

File tree

docs/forcing_password_reset.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Forcing Password Reset
2+
3+
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.
4+
5+
- [Forcing Password Reset](#forcing-password-reset)
6+
- [Available Methods](#available-methods)
7+
- [Check if a User Requires Password Reset](#check-if-a-user-requires-password-reset)
8+
- [Force Password Reset On a User](#force-password-reset-on-a-user)
9+
- [Removing Password Reset Flag On a User](#removing-password-reset-flag-on-a-user)
10+
- [Force Password Reset On Multiple Users](#force-password-reset-on-multiple-users)
11+
- [Force Password Reset On All Users](#force-password-reset-on-all-users)
12+
13+
## Available Methods
14+
15+
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.
16+
17+
### Check if a User Requires Password Reset
18+
19+
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`.
20+
21+
```php
22+
if ($user->requiresPasswordReset()) {
23+
//...
24+
}
25+
```
26+
27+
### Force Password Reset On a User
28+
29+
To force password reset on a user, you can do so using the `forcePasswordReset()` method on the `User` entity.
30+
31+
```php
32+
$user->forcePasswordReset();
33+
```
34+
35+
### Remove Force Password Reset Flag On a User
36+
37+
Undoing or removing the force password reset flag on a user can be done using the `undoForcePasswordReset()` method on the `User` entity.
38+
39+
```php
40+
$user->undoForcePasswordReset();
41+
```
42+
43+
### Force Password Reset On Multiple Users
44+
45+
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.
46+
47+
```php
48+
use CodeIgniter\Shield\Models\UserIdentityModel;
49+
...
50+
...
51+
...
52+
$identities = new UserIdentityModel();
53+
$identities->forceMultiplePasswordReset([1,2,3,4]);
54+
```
55+
56+
### Force Password Reset On All Users
57+
58+
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`.
59+
60+
```php
61+
use CodeIgniter\Shield\Models\UserIdentityModel;
62+
...
63+
...
64+
...
65+
$identities = new UserIdentityModel();
66+
$identities->forceGlobalPasswordReset();
67+
```

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* [Authentication](authentication.md)
77
* [Authorization](authorization.md)
88
* [Auth Actions](auth_actions.md)
9+
* [Forcing Password Reset](forcing_password_reset.md)
910
* [Events](events.md)
1011
* [Testing](testing.md)
1112
* [Customization](customization.md)

docs/install.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [Controller Filters](#controller-filters)
1212
- [Protect All Pages](#protect-all-pages)
1313
- [Rate Limiting](#rate-limiting)
14+
- [Forcing Password Reset](#forcing-password-reset)
1415

1516
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.
1617

@@ -196,6 +197,7 @@ public $aliases = [
196197
'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class,
197198
'group' => \CodeIgniter\Shield\Filters\GroupFilter::class,
198199
'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class,
200+
'force-reset' => \CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class,
199201
];
200202
```
201203

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

210213
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).
211214

@@ -241,6 +244,21 @@ public $filters = [
241244
];
242245
```
243246

247+
### Forcing Password Reset
248+
249+
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:
250+
251+
```php
252+
public $globals = [
253+
'before' => [
254+
//...
255+
//...
256+
'force-reset' => ['except' => ['login*', 'register', 'auth*', 'change-password']]
257+
]
258+
];
259+
```
260+
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**.
261+
244262
> **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.
245263
246264
For example, if you configured your routes like so:
@@ -260,4 +278,4 @@ public $globals = [
260278
]
261279
]
262280
```
263-
The same should apply for the Rate Limiting.
281+
The same should apply for the Rate Limiting and Forcing Password Reset.

src/Config/Auth.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ class Auth extends BaseConfig
5252
* to apply any logic you may need.
5353
*/
5454
public array $redirects = [
55-
'register' => '/',
56-
'login' => '/',
57-
'logout' => 'login',
55+
'register' => '/',
56+
'login' => '/',
57+
'logout' => 'login',
58+
'force_reset' => '/',
5859
];
5960

6061
/**
@@ -378,6 +379,17 @@ public function registerRedirect(): string
378379
return $this->getUrl($url);
379380
}
380381

382+
/**
383+
* Returns the URL the user should be redirected to
384+
* if force_reset identity is set to true.
385+
*/
386+
public function forcePasswordResetRedirect(): string
387+
{
388+
$url = setting('Auth.redirects')['force_reset'];
389+
390+
return $this->getUrl($url);
391+
}
392+
381393
/**
382394
* Accepts a string which can be an absolute URL or
383395
* a named route or just a URI path, and returns the

src/Config/Registrar.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use CodeIgniter\Shield\Collectors\Auth;
99
use CodeIgniter\Shield\Filters\AuthRates;
1010
use CodeIgniter\Shield\Filters\ChainAuth;
11+
use CodeIgniter\Shield\Filters\ForcePasswordResetFilter;
1112
use CodeIgniter\Shield\Filters\GroupFilter;
1213
use CodeIgniter\Shield\Filters\PermissionFilter;
1314
use CodeIgniter\Shield\Filters\SessionAuth;
@@ -22,12 +23,13 @@ public static function Filters(): array
2223
{
2324
return [
2425
'aliases' => [
25-
'session' => SessionAuth::class,
26-
'tokens' => TokenAuth::class,
27-
'chain' => ChainAuth::class,
28-
'auth-rates' => AuthRates::class,
29-
'group' => GroupFilter::class,
30-
'permission' => PermissionFilter::class,
26+
'session' => SessionAuth::class,
27+
'tokens' => TokenAuth::class,
28+
'chain' => ChainAuth::class,
29+
'auth-rates' => AuthRates::class,
30+
'group' => GroupFilter::class,
31+
'permission' => PermissionFilter::class,
32+
'force-reset' => ForcePasswordResetFilter::class,
3133
],
3234
];
3335
}

src/Entities/User.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use CodeIgniter\Shield\Authorization\Traits\Authorizable;
1212
use CodeIgniter\Shield\Models\LoginModel;
1313
use CodeIgniter\Shield\Models\UserIdentityModel;
14+
use CodeIgniter\Shield\Traits\Resettable;
1415

1516
/**
1617
* @property string|null $email
@@ -25,6 +26,7 @@ class User extends Entity
2526
{
2627
use Authorizable;
2728
use HasAccessTokens;
29+
use Resettable;
2830

2931
/**
3032
* @var UserIdentity[]|null
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CodeIgniter\Shield\Filters;
6+
7+
use CodeIgniter\Filters\FilterInterface;
8+
use CodeIgniter\HTTP\IncomingRequest;
9+
use CodeIgniter\HTTP\RedirectResponse;
10+
use CodeIgniter\HTTP\RequestInterface;
11+
use CodeIgniter\HTTP\Response;
12+
use CodeIgniter\HTTP\ResponseInterface;
13+
use CodeIgniter\Shield\Authentication\Authenticators\Session;
14+
15+
/**
16+
* Force Password Reset Filter.
17+
*/
18+
class ForcePasswordResetFilter implements FilterInterface
19+
{
20+
/**
21+
* Checks if a logged in user should reset their
22+
* password, and then redirect to the appropriate
23+
* page.
24+
*
25+
* @param array|null $arguments
26+
*
27+
* @return RedirectResponse|void
28+
*/
29+
public function before(RequestInterface $request, $arguments = null)
30+
{
31+
if (! $request instanceof IncomingRequest) {
32+
return;
33+
}
34+
35+
helper('setting');
36+
37+
/** @var Session $authenticator */
38+
$authenticator = auth('session')->getAuthenticator();
39+
40+
if ($authenticator->loggedIn() && $authenticator->getUser()->requiresPasswordReset()) {
41+
return redirect()->to(config('Auth')->forcePasswordResetRedirect());
42+
}
43+
}
44+
45+
/**
46+
* We don't have anything to do here.
47+
*
48+
* @param Response|ResponseInterface $response
49+
* @param array|null $arguments
50+
*/
51+
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
52+
{
53+
}
54+
}

src/Models/UserIdentityModel.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,46 @@ public function revokeAllAccessTokens(User $user): void
329329
$this->checkQueryReturn($return);
330330
}
331331

332+
/**
333+
* Force password reset for multiple users.
334+
*
335+
* @param int[]|string[] $userIds
336+
*/
337+
public function forceMultiplePasswordReset(array $userIds): void
338+
{
339+
$this->where(['type' => Session::ID_TYPE_EMAIL_PASSWORD, 'force_reset' => 0]);
340+
$this->whereIn('user_id', $userIds);
341+
$this->set('force_reset', 1);
342+
$return = $this->update();
343+
344+
$this->checkQueryReturn($return);
345+
}
346+
347+
/**
348+
* Force global password reset.
349+
* This is useful for enforcing a password reset
350+
* for ALL users incase of a security breach.
351+
*/
352+
public function forceGlobalPasswordReset(): void
353+
{
354+
$whereFilter = [
355+
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
356+
'force_reset' => 0,
357+
];
358+
$this->where($whereFilter);
359+
$this->set('force_reset', 1);
360+
$return = $this->update();
361+
362+
$this->checkQueryReturn($return);
363+
}
364+
332365
public function fake(Generator &$faker): UserIdentity
333366
{
334367
return new UserIdentity([
335368
'user_id' => fake(UserModel::class)->id,
336369
'type' => Session::ID_TYPE_EMAIL_PASSWORD,
337370
'name' => null,
338-
'secret' => 'info@example.com',
371+
'secret' => $faker->email,
339372
'secret2' => password_hash('secret', PASSWORD_DEFAULT),
340373
'expires' => null,
341374
'extra' => null,

src/Models/UserModel.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public function addToDefaultGroup(User $user): void
151151
public function fake(Generator &$faker): User
152152
{
153153
return new User([
154-
'username' => $faker->userName,
154+
'username' => $faker->unique()->userName,
155155
'active' => true,
156156
]);
157157
}

src/Traits/Resettable.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CodeIgniter\Shield\Traits;
6+
7+
use CodeIgniter\Shield\Authentication\Authenticators\Session;
8+
use CodeIgniter\Shield\Models\UserIdentityModel;
9+
10+
/**
11+
* Reusable methods to help the
12+
* enforcing of password resets
13+
*/
14+
trait Resettable
15+
{
16+
/**
17+
* Returns true|false based on the value of the
18+
* force reset column of the user's identity.
19+
*/
20+
public function requiresPasswordReset(): bool
21+
{
22+
$identity_model = model(UserIdentityModel::class);
23+
$identity = $identity_model->getIdentityByType($this, Session::ID_TYPE_EMAIL_PASSWORD);
24+
25+
return $identity->force_reset;
26+
}
27+
28+
/**
29+
* Force password reset
30+
*/
31+
public function forcePasswordReset(): void
32+
{
33+
// Do nothing if user already requires reset
34+
if ($this->requiresPasswordReset()) {
35+
return;
36+
}
37+
38+
// Set force_reset to true
39+
$identity_model = model(UserIdentityModel::class);
40+
$identity_model->set('force_reset', 1);
41+
$identity_model->where(['user_id' => $this->id, 'type' => Session::ID_TYPE_EMAIL_PASSWORD]);
42+
$identity_model->update();
43+
}
44+
45+
/**
46+
* Undo Force password reset
47+
*/
48+
public function undoForcePasswordReset(): void
49+
{
50+
// If user doesn't require password reset, do nothing
51+
if ($this->requiresPasswordReset() === false) {
52+
return;
53+
}
54+
55+
// Set force_reset to false
56+
$identity_model = model(UserIdentityModel::class);
57+
$identity_model->set('force_reset', 0);
58+
$identity_model->where(['user_id' => $this->id, 'type' => Session::ID_TYPE_EMAIL_PASSWORD]);
59+
$identity_model->update();
60+
}
61+
}

0 commit comments

Comments
 (0)