Skip to content

Commit 8939569

Browse files
committed
feat(auth): add conditional auth actions
- add ConditionalActionInterface for opt-in per-user action checks - skip conditional login/register actions when they do not apply - keep stored activation identities enforced for inactive users - document conditional action behavior and activation edge cases - add login, registration, magic-link, and filter coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 34be62b commit 8939569

12 files changed

Lines changed: 398 additions & 8 deletions

File tree

‎docs/references/authentication/auth_actions.md‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,52 @@ and provides feedback. In the `Email2FA` class, it verifies the code against wha
8686
database and either sends them back to the previous form to try again or redirects the user to the
8787
page that a `login` task would have redirected them to anyway.
8888

89-
All methods should return either a `Response` or a view string (e.g. using the `view()` function).
89+
All methods should return either a `Response` or a view string (e.g. using the `view()` function).
90+
91+
## Conditional Actions
92+
93+
Some applications only need an action for certain users. For example, you may
94+
want email-based 2FA for administrators, but not for every user.
95+
96+
To make an action conditional, implement `ConditionalActionInterface`:
97+
98+
```php
99+
<?php
100+
101+
namespace App\Authentication\Actions;
102+
103+
use CodeIgniter\Shield\Authentication\Actions\ConditionalActionInterface;
104+
use CodeIgniter\Shield\Authentication\Actions\Email2FA;
105+
use CodeIgniter\Shield\Entities\User;
106+
107+
final class AdminEmail2FA extends Email2FA implements ConditionalActionInterface
108+
{
109+
public function appliesTo(User $user): bool
110+
{
111+
return $user->inGroup('admin', 'superadmin');
112+
}
113+
}
114+
```
115+
116+
Then register your conditional action in **app/Config/Auth.php**:
117+
118+
```php
119+
public array $actions = [
120+
'register' => null,
121+
'login' => \App\Authentication\Actions\AdminEmail2FA::class,
122+
];
123+
```
124+
125+
When `appliesTo()` returns `true`, Shield starts the action as usual and
126+
discovers any stored identity for that action. When it returns `false`, Shield
127+
does not start the action and ignores stored identities for that action while
128+
the condition remains false. The exception is activation: if a user is already
129+
inactive and has a stored activation identity, Shield continues to require that
130+
activation before login can complete.
131+
132+
The `appliesTo()` method may be called more than once while Shield checks for
133+
actions, so keep it deterministic, free of side effects, and fail closed when
134+
the condition cannot be determined. It is not a replacement for authorization.
135+
136+
Once an action is already pending in the session, Shield continues that pending
137+
action instead of rechecking the condition.

‎docs/references/authorization.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ if ($user->isActivated()) {
256256
!!! note
257257

258258
If no activator is specified in the `Auth` config file, `actions['register']` property, then this will always return `true`.
259+
If a conditional activator does not apply during registration, the newly registered user is activated immediately.
259260

260261
You can check if a user has not been activated yet via the `isNotActivated()` method.
261262

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter Shield.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Shield\Authentication\Actions;
15+
16+
use CodeIgniter\Shield\Entities\User;
17+
18+
/**
19+
* Allows an authentication action to decide if it applies to a user.
20+
*/
21+
interface ConditionalActionInterface
22+
{
23+
/**
24+
* Determines if this action applies to the given user.
25+
*
26+
* This method may be called while Shield starts or discovers pending actions.
27+
* It should be deterministic and free of side effects.
28+
*/
29+
public function appliesTo(User $user): bool;
30+
}

‎src/Authentication/Authenticators/Session.php‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use CodeIgniter\HTTP\Response;
2020
use CodeIgniter\I18n\Time;
2121
use CodeIgniter\Shield\Authentication\Actions\ActionInterface;
22+
use CodeIgniter\Shield\Authentication\Actions\ConditionalActionInterface;
2223
use CodeIgniter\Shield\Authentication\AuthenticationException;
2324
use CodeIgniter\Shield\Authentication\AuthenticatorInterface;
2425
use CodeIgniter\Shield\Authentication\Passwords;
@@ -185,11 +186,11 @@ public function attempt(array $credentials): Result
185186
}
186187

187188
/**
188-
* If an action has been defined, start it up.
189+
* If an action has been defined and applies to the user, start it up.
189190
*
190191
* @param string $type 'register', 'login'
191192
*
192-
* @return bool If the action has been defined or not.
193+
* @return bool If the action was started or not.
193194
*/
194195
public function startUpAction(string $type, User $user): bool
195196
{
@@ -202,6 +203,10 @@ public function startUpAction(string $type, User $user): bool
202203
/** @var ActionInterface $action */
203204
$action = Factories::actions($actionClass); // @phpstan-ignore-line
204205

206+
if (! $this->actionAppliesToUser($action, $user)) {
207+
return false;
208+
}
209+
205210
// Create identity for the action.
206211
$action->createIdentity($user);
207212

@@ -472,14 +477,21 @@ private function setAuthAction(): bool
472477

473478
$authActions = setting('Auth.actions');
474479

475-
foreach ($authActions as $actionClass) {
480+
foreach ($authActions as $type => $actionClass) {
476481
if ($actionClass === null || $actionClass === '') {
477482
continue;
478483
}
479484

480485
/** @var ActionInterface $action */
481486
$action = Factories::actions($actionClass); // @phpstan-ignore-line
482487

488+
if (
489+
! $this->actionAppliesToUser($action, $this->user)
490+
&& ! $this->inactiveUserNeedsRegisterAction($type, $this->user)
491+
) {
492+
continue;
493+
}
494+
483495
$identity = $this->userIdentityModel->getIdentityByType($this->user, $action->getType());
484496

485497
if ($identity instanceof UserIdentity) {
@@ -504,31 +516,49 @@ private function getIdentitiesForAction(User $user): array
504516
{
505517
return $this->userIdentityModel->getIdentitiesByTypes(
506518
$user,
507-
$this->getActionTypes(),
519+
$this->getActionTypes($user),
508520
);
509521
}
510522

511523
/**
512524
* @return list<string>
513525
*/
514-
private function getActionTypes(): array
526+
private function getActionTypes(User $user): array
515527
{
516528
$actions = setting('Auth.actions');
517529
$types = [];
518530

519-
foreach ($actions as $actionClass) {
531+
foreach ($actions as $type => $actionClass) {
520532
if ($actionClass === null || $actionClass === '') {
521533
continue;
522534
}
523535

524536
/** @var ActionInterface $action */
525-
$action = Factories::actions($actionClass); // @phpstan-ignore-line
537+
$action = Factories::actions($actionClass); // @phpstan-ignore-line
538+
539+
if (
540+
! $this->actionAppliesToUser($action, $user)
541+
&& ! $this->inactiveUserNeedsRegisterAction($type, $user)
542+
) {
543+
continue;
544+
}
545+
526546
$types[] = $action->getType();
527547
}
528548

529549
return $types;
530550
}
531551

552+
private function actionAppliesToUser(ActionInterface $action, User $user): bool
553+
{
554+
return ! $action instanceof ConditionalActionInterface || $action->appliesTo($user);
555+
}
556+
557+
private function inactiveUserNeedsRegisterAction(int|string $type, User $user): bool
558+
{
559+
return $type === 'register' && ! $user->active;
560+
}
561+
532562
/**
533563
* Checks if the user is currently in pending login state.
534564
* They need to do an auth action.

‎src/Config/Auth.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class Auth extends BaseConfig
9898
* Custom Actions and Requirements:
9999
*
100100
* - All actions must implement \CodeIgniter\Shield\Authentication\Actions\ActionInterface.
101+
* - Actions may implement \CodeIgniter\Shield\Authentication\Actions\ConditionalActionInterface to apply only to certain users.
101102
* - Custom actions for "register" must have a class name that ends with the suffix "Activator" (e.g., `CustomSmsActivator`) ensure proper functionality.
102103
*
103104
* @var array<string, class-string<ActionInterface>|null>

‎src/Filters/SessionAuth.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ public function before(RequestInterface $request, $arguments = null)
7474
return redirect()->route('auth-action-show')
7575
->with('error', lang('Auth.activationBlocked'));
7676
}
77+
78+
$authenticator->logout();
79+
80+
return redirect()->to(config('Auth')->logoutRedirect())
81+
->with('error', lang('Auth.activationBlocked'));
7782
}
7883

7984
return;

‎tests/Authentication/Filters/SessionFilterTest.php‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
namespace Tests\Authentication\Filters;
1515

1616
use CodeIgniter\I18n\Time;
17+
use CodeIgniter\Shield\Authentication\Authenticators\Session;
1718
use CodeIgniter\Shield\Filters\SessionAuth;
19+
use CodeIgniter\Shield\Models\UserIdentityModel;
1820
use CodeIgniter\Shield\Models\UserModel;
1921
use CodeIgniter\Test\DatabaseTestTrait;
22+
use Tests\Support\AdminEmailActivator;
2023

2124
/**
2225
* @internal
@@ -94,6 +97,47 @@ public function testBlocksInactiveUsersAndRedirectsToAuthAction(): void
9497
setting('Auth.actions', ['register' => null]);
9598
}
9699

100+
public function testBlocksInactiveUsersWhenConditionalActivatorDoesNotApply(): void
101+
{
102+
$user = fake(UserModel::class, ['active' => false]);
103+
104+
setting('Auth.actions', ['register' => AdminEmailActivator::class]);
105+
106+
$result = $this->actingAs($user)
107+
->get('protected-route');
108+
109+
$result->assertRedirectTo(config('Auth')->logoutRedirect());
110+
$result->assertSessionHas('error', lang('Auth.activationBlocked'));
111+
$this->assertNull(auth('session')->id());
112+
113+
setting('Auth.actions', ['register' => null]);
114+
}
115+
116+
public function testRedirectsInactiveUsersToStoredConditionalActivationAction(): void
117+
{
118+
$user = fake(UserModel::class, ['active' => false]);
119+
120+
setting('Auth.actions', ['register' => AdminEmailActivator::class]);
121+
122+
model(UserIdentityModel::class)->insert([
123+
'user_id' => $user->id,
124+
'type' => Session::ID_TYPE_EMAIL_ACTIVATE,
125+
'secret' => '123456',
126+
'name' => 'register',
127+
'extra' => lang('Auth.needVerification'),
128+
]);
129+
130+
/** @var Session $authenticator */
131+
$authenticator = auth('session')->getAuthenticator();
132+
$this->assertTrue($authenticator->hasAction($user->id));
133+
134+
$result = $this->get('protected-route');
135+
136+
$result->assertRedirectTo('/auth/a/show');
137+
138+
setting('Auth.actions', ['register' => null]);
139+
}
140+
97141
public function testStoreRedirectsToEntraceUrlIntoSession(): void
98142
{
99143
$result = $this->call('get', 'protected-route');

‎tests/Controllers/LoginTest.php‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
use CodeIgniter\I18n\Time;
1818
use CodeIgniter\Shield\Authentication\Actions\Email2FA;
1919
use CodeIgniter\Shield\Config\Auth;
20+
use CodeIgniter\Shield\Models\UserIdentityModel;
2021
use CodeIgniter\Test\FeatureTestTrait;
22+
use CodeIgniter\Test\TestResponse;
2123
use Config\Services;
2224
use Config\Validation;
25+
use Tests\Support\AdminEmail2FA;
2326
use Tests\Support\DatabaseTestCase;
2427
use Tests\Support\FakeUser;
2528

@@ -256,4 +259,90 @@ public function testLoginRedirectsToActionIfDefined(): void
256259
$result->assertSessionMissing('errors');
257260
$this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl());
258261
}
262+
263+
public function testLoginRedirectsToConditionalActionWhenItApplies(): void
264+
{
265+
$this->enableAdminEmail2FA();
266+
267+
$this->user->addGroup('admin');
268+
$this->createUserEmailIdentity();
269+
270+
$result = $this->loginUser();
271+
272+
$result->assertStatus(302);
273+
$result->assertRedirect();
274+
$this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl());
275+
}
276+
277+
public function testLoginSkipsConditionalActionWhenItDoesNotApply(): void
278+
{
279+
$this->enableAdminEmail2FA();
280+
$this->createUserEmailIdentity();
281+
282+
$result = $this->loginUser();
283+
284+
$result->assertStatus(302);
285+
$result->assertRedirect();
286+
$this->assertSame(site_url(), $result->getRedirectUrl());
287+
}
288+
289+
public function testLoginIgnoresStoredConditionalActionIdentityWhenItDoesNotApply(): void
290+
{
291+
$this->enableAdminEmail2FA();
292+
$this->createUserEmailIdentity();
293+
294+
model(UserIdentityModel::class)->insert([
295+
'user_id' => $this->user->id,
296+
'type' => 'email_2fa',
297+
'name' => 'login',
298+
'secret' => '123456',
299+
'extra' => lang('Auth.need2FA'),
300+
]);
301+
302+
$result = $this->loginUser();
303+
304+
$result->assertStatus(302);
305+
$result->assertRedirect();
306+
$this->assertSame(site_url(), $result->getRedirectUrl());
307+
}
308+
309+
public function testLoginKeepsExistingPendingConditionalActionInSession(): void
310+
{
311+
$this->enableAdminEmail2FA();
312+
$this->createUserEmailIdentity();
313+
314+
$result = $this->withSession([
315+
'user' => [
316+
'id' => $this->user->id,
317+
'auth_action' => AdminEmail2FA::class,
318+
],
319+
])->get('/login');
320+
321+
$result->assertStatus(302);
322+
$result->assertRedirect();
323+
$this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl());
324+
}
325+
326+
private function enableAdminEmail2FA(): void
327+
{
328+
$config = config('Auth');
329+
$config->actions['login'] = AdminEmail2FA::class;
330+
Factories::injectMock('config', 'Auth', $config);
331+
}
332+
333+
private function createUserEmailIdentity(): void
334+
{
335+
$this->user->createEmailIdentity([
336+
'email' => 'foo@example.com',
337+
'password' => 'secret123',
338+
]);
339+
}
340+
341+
private function loginUser(): TestResponse
342+
{
343+
return $this->post('/login', [
344+
'email' => 'foo@example.com',
345+
'password' => 'secret123',
346+
]);
347+
}
259348
}

0 commit comments

Comments
 (0)