From f322630606a32df662cf6aea3f570cee81066009 Mon Sep 17 00:00:00 2001 From: CosDiabos <14842802+CosDiabos@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:22:06 +0100 Subject: [PATCH] feat: expand 2FA support --- docs/quick_start_guide/using_session_auth.md | 50 ++++- psalm.xml | 1 + .../Actions/ActionInterface.php | 5 + src/Authentication/Actions/Email2FA.php | 13 ++ src/Authentication/Actions/EmailActivator.php | 13 ++ src/Authentication/Authenticators/Session.php | 96 +++++++-- src/Config/Auth.php | 64 +++++- .../2020-12-28-223112_create_auth_tables.php | 1 + src/Entities/User.php | 9 + src/Models/UserModel.php | 1 + tests/Controllers/ActionsTest.php | 3 +- tests/Controllers/LoginTest.php | 194 +++++++++++++++++- tests/_support/FakeAction.php | 58 ++++++ tests/_support/TestCase.php | 7 +- 14 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 tests/_support/FakeAction.php diff --git a/docs/quick_start_guide/using_session_auth.md b/docs/quick_start_guide/using_session_auth.md index 4613fabac..cb6c48203 100644 --- a/docs/quick_start_guide/using_session_auth.md +++ b/docs/quick_start_guide/using_session_auth.md @@ -53,25 +53,65 @@ By default, once a user registers they have an active account that can be used. ```php public array $actions = [ 'register' => \CodeIgniter\Shield\Authentication\Actions\EmailActivator::class, - 'login' => null, ]; ``` ### Enable Two-Factor Authentication +Turned off by default, Shield's 2FA can be enabled by setting `$Mfa` to `true` in the `Auth` config file. Shield allows you to force two-factor authentication for every login, or per user via the `$forceMfa` setting. + +```php +public bool $Mfa = true; + +public bool $forceMfa = true; // for every login +public bool $forceMfa = false; // based on user preference +``` + +To enable Shield's Email-based 2FA can be enabled by configuring the `$actionsMfa` in the `Auth` config file. + !!! note You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](../getting_started/install.md#initial-setup). -Turned off by default, Shield's Email-based 2FA can be enabled by specifying the class to use in the `Auth` config file. +```php +public array $actionsMfa = [ + 'email' => \CodeIgniter\Shield\Authentication\Actions\Email2Fa::class, + ]; +``` + +Custom 2FA actions can be implemented by implementing `\CodeIgniter\Shield\Authentication\Actions\ActionInterface` and added in `$actionsMfa`. + +Define the default action for the 2FA by setting the `$defaultMfa`. ```php -public array $actions = [ - 'register' => null, - 'login' => \CodeIgniter\Shield\Authentication\Actions\Email2FA::class, +public string $defaultMfa = "email"; +``` + +Shield also allows to define custom 2FA actions on a user group basis by defining them the `$matrixMfa` matrix array. The default user group defined at `AuthGroups::$defaultGroup` config file, will use the value from `$defaultMfa` and can't be overridden by `$matrixMfa`. + +```php +public array $matrixMfa = [ + 'admin' => \CodeIgniter\Shield\Authentication\Actions\Email2FA::class, ]; ``` +To enable 2FA for a specific user, set the User field 'mfa' to true. It is possible to check if a user has 2FA activated with `isMfaActive()` + +```php +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); +$user = $users->findById(123); + +$user->isMfaActive(); // false + +$user->fill([ + 'mfa' => true +]); +$users->save($user); + +$user->isMfaActive(); // true +``` + ## Customizing Routes If you need to customize how any of the auth features are handled, you can still diff --git a/psalm.xml b/psalm.xml index 5b3a32593..61677a083 100644 --- a/psalm.xml +++ b/psalm.xml @@ -11,6 +11,7 @@ errorBaseline="psalm-baseline.xml" findUnusedBaselineEntry="false" findUnusedCode="false" + ensureOverrideAttribute="false" > diff --git a/src/Authentication/Actions/ActionInterface.php b/src/Authentication/Actions/ActionInterface.php index b2025bde3..c4e45615a 100644 --- a/src/Authentication/Actions/ActionInterface.php +++ b/src/Authentication/Actions/ActionInterface.php @@ -64,4 +64,9 @@ public function getType(): string; * @return string secret */ public function createIdentity(User $user): string; + + /** + * Retrieves the action message for the user (e.g. extra) + */ + public function getActionMessage(): string; } diff --git a/src/Authentication/Actions/Email2FA.php b/src/Authentication/Actions/Email2FA.php index f7bf42717..6588e7642 100644 --- a/src/Authentication/Actions/Email2FA.php +++ b/src/Authentication/Actions/Email2FA.php @@ -185,4 +185,17 @@ public function getType(): string { return $this->type; } + + /** + * Retrieves the action message for the user (e.g. extra) + */ + public function getActionMessage(): string + { + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + $user = $authenticator->getPendingUser(); + $identity = $this->getIdentity($user); + + return $identity->extra; + } } diff --git a/src/Authentication/Actions/EmailActivator.php b/src/Authentication/Actions/EmailActivator.php index 2ca9aa611..c5194c6b9 100644 --- a/src/Authentication/Actions/EmailActivator.php +++ b/src/Authentication/Actions/EmailActivator.php @@ -177,4 +177,17 @@ public function getType(): string { return $this->type; } + + /** + * Retrieves the action message for the user (e.g. extra) + */ + public function getActionMessage(): string + { + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + $user = $authenticator->getPendingUser(); + $identity = $this->getIdentity($user); + + return $identity->extra; + } } diff --git a/src/Authentication/Authenticators/Session.php b/src/Authentication/Authenticators/Session.php index 31836a631..beae4360a 100644 --- a/src/Authentication/Authenticators/Session.php +++ b/src/Authentication/Authenticators/Session.php @@ -166,10 +166,11 @@ public function attempt(array $credentials): Result $user->touchIdentity($user->getEmailIdentity()); // Set auth action from database. - $this->setAuthAction(); - - // If an action has been defined for login, start it up. - $this->startUpAction('login', $user); + if (! $this->setAuthAction()) { + // If no auth action from datadase, + // then run an action for login, start it up. + $this->startUpAction('login', $user); + } $this->startLogin($user); @@ -193,21 +194,39 @@ public function attempt(array $credentials): Result */ public function startUpAction(string $type, User $user): bool { - $actionClass = setting('Auth.actions')[$type] ?? null; + $authActions = []; - if ($actionClass === null) { - return false; + switch ($type) { + case 'register': + $authActions[$type] = setting('Auth.actions')[$type]; + break; + + case 'login': + if (setting('Auth.Mfa')) { + $authActions = $this->populateMfaActions(); + } + break; + + default: + return false; } - /** @var ActionInterface $action */ - $action = Factories::actions($actionClass); // @phpstan-ignore-line + foreach ($authActions as $actionClass) { + if ($actionClass === null) { + continue; + } - // Create identity for the action. - $action->createIdentity($user); + /** @var ActionInterface $action */ + $action = Factories::actions($actionClass); // @phpstan-ignore-line - $this->setAuthAction(); + // Create identity for the action. + $action->createIdentity($user); + $this->setAuthAction(); - return true; + return true; + } + + return false; } /** @@ -469,8 +488,11 @@ private function setAuthAction(): bool if ($this->user === null) { return false; } - $authActions = setting('Auth.actions'); + // if Mfa is enabled + if (setting('Auth.Mfa')) { + $authActions = array_merge($authActions, $this->populateMfaActions()); + } foreach ($authActions as $actionClass) { if ($actionClass === null) { @@ -486,7 +508,7 @@ private function setAuthAction(): bool $this->userState = self::STATE_PENDING; $this->setSessionUserKey('auth_action', $actionClass); - $this->setSessionUserKey('auth_action_message', $identity->extra); + $this->setSessionUserKey('auth_action_message', $action->getActionMessage()); return true; } @@ -976,4 +998,48 @@ private function refreshRememberMeToken(stdClass $token): void $this->setRememberMeCookie($rawToken); } + + private function populateMfaActions(): array + { + // add the register from actions + $authActions = setting('Auth.actions'); + $userGroupAction = setting('AuthGroups.defaultGroup'); + // if Mfa is forced for all ou the user has mfa enabled, add the default mfa to authActions + if (setting('Auth.forceMfa') || $this->user->mfa) { + if (! $this->user->inGroup(setting('AuthGroups.defaultGroup')) && count($this->user->getGroups()) > 0) { + // The user isn't on the default group, but in a group, grab first group + $userGroupAction = $this->user->getGroups()[0]; + $mfaAction = setting('Auth.actionsMfa')[setting('Auth.matrixMfa')[$userGroupAction]]; + + // check if there is a custom action defined in the matrix for this user group + if (setting('Auth.matrixMfa')[$userGroupAction] !== null) { + // set it up + $authActions[setting('Auth.matrixMfa')[$userGroupAction]] = $mfaAction; + } else { + // No custom action, fallback for default action + $authActions[setting('Auth.defaultMfa')] = setting('Auth.actionsMfa')[setting('Auth.defaultMfa')]; + } + } else { + // default mfa action for default group or user with no group + $authActions[setting('Auth.defaultMfa')] = setting('Auth.actionsMfa')[setting('Auth.defaultMfa')]; + } + // Get all existing identities to match against Auth.actionsMfa + $identities = $this->user->getIdentities('all'); + + foreach ($identities as $item) { + if ($item->type !== setting('Auth.defaultMfa') && array_key_exists($item->type, setting('Auth.actionsMfa'))) { + $authActions[$item->type] = setting('Auth.actionsMfa')[$item->type]; + + // got a hit on a stored identity, removing defaults... + unset($authActions[setting('Auth.defaultMfa')]); + if (isset(setting('Auth.matrixMfa')[$userGroupAction])) { + unset($authActions[setting('Auth.matrixMfa')[$userGroupAction]]); + } + break; + } + } + } + + return $authActions; + } } diff --git a/src/Config/Auth.php b/src/Config/Auth.php index b4f007b2c..0fe5ab075 100644 --- a/src/Config/Auth.php +++ b/src/Config/Auth.php @@ -87,13 +87,12 @@ class Auth extends BaseConfig * Authentication Actions * -------------------------------------------------------------------- * Specifies the class that represents an action to take after - * the user logs in or registers a new account at the site. + * the user registers a new account at the site. * * You must register actions in the order of the actions to be performed. * * Available actions with Shield: * - register: \CodeIgniter\Shield\Authentication\Actions\EmailActivator::class - * - login: \CodeIgniter\Shield\Authentication\Actions\Email2FA::class * * Custom Actions and Requirements: * @@ -104,7 +103,66 @@ class Auth extends BaseConfig */ public array $actions = [ 'register' => null, - 'login' => null, + ]; + + /** + * -------------------------------------------------------------------- + * Allow Multifactor Authentication (MFA) + * -------------------------------------------------------------------- + * Determines whether MFA is enabled for the site logins. + */ + public bool $Mfa = false; + + /** + * -------------------------------------------------------------------- + * Multifactor Authentication (MFA) Per User + * -------------------------------------------------------------------- + * Determines whether MFA must be forced for all the site logins (true) or + * only if the user activates a preferred method (false). + */ + public bool $forceMfa = true; + + /** + * -------------------------------------------------------------------- + * Multifactor Authentication Actions + * -------------------------------------------------------------------- + * Specifies all classes that represent a multifactor action to take after + * the user logs in at the site. This allows the user to choose a favorite + * MFA method. + * + * You must register actions in the order of the actions to be performed. + * + * Available actions with Shield: + * - email: \CodeIgniter\Shield\Authentication\Actions\Email2FA::class + * + * Custom Actions and Requirements: + * + * - All actions must implement \CodeIgniter\Shield\Authentication\Actions\ActionInterface. + * + * @var array|null> + */ + public array $actionsMfa = [ + 'email' => null, + ]; + + /** + * -------------------------------------------------------------------- + * Default Multifactor Action + * -------------------------------------------------------------------- + * Specifies the default MFA action to which to take when the user doesn't + * specifiy a preference ($forceMfa = true). + */ + public string $defaultMfa = 'email'; + + /** + * -------------------------------------------------------------------- + * Multifactor Action to Group Matrix + * -------------------------------------------------------------------- + * Maps the default MFA action to a user group. The "user" group + * follows the $defaultMfa directive. + */ + public array $matrixMfa = [ + // 'group' => 'Mfa action' ]; /** diff --git a/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php b/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php index 6b75e4e31..63c7017e4 100644 --- a/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php +++ b/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php @@ -50,6 +50,7 @@ public function up(): void 'status' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'status_message' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'active' => ['type' => 'tinyint', 'constraint' => 1, 'null' => 0, 'default' => 0], + 'mfa' => ['type' => 'tinyint', 'constraint' => 1, 'null' => 0, 'default' => 0], 'last_active' => ['type' => 'datetime', 'null' => true], 'created_at' => ['type' => 'datetime', 'null' => true], 'updated_at' => ['type' => 'datetime', 'null' => true], diff --git a/src/Entities/User.php b/src/Entities/User.php index 8fef86822..e90ff1e5d 100644 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -69,6 +69,7 @@ class User extends Entity protected $casts = [ 'id' => '?integer', 'active' => 'int-bool', + 'mfa' => 'bool', 'permissions' => 'array', 'groups' => 'array', ]; @@ -299,4 +300,12 @@ public function lastLogin(): ?Login return $logins->lastLogin($this); } + + /** + * Returns if this user has Multifactor Authentication (MFA) active + */ + public function isMfaActive(): bool + { + return $this->mfa; + } } diff --git a/src/Models/UserModel.php b/src/Models/UserModel.php index e4a002fbb..f33e346fa 100644 --- a/src/Models/UserModel.php +++ b/src/Models/UserModel.php @@ -36,6 +36,7 @@ class UserModel extends BaseModel 'status', 'status_message', 'active', + 'mfa', 'last_active', ]; protected $useTimestamps = true; diff --git a/tests/Controllers/ActionsTest.php b/tests/Controllers/ActionsTest.php index b409e0cd7..0c87b4218 100644 --- a/tests/Controllers/ActionsTest.php +++ b/tests/Controllers/ActionsTest.php @@ -44,7 +44,8 @@ protected function setUp(): void // Ensure our actions are registered with the system $config = config('Auth'); - $config->actions['login'] = Email2FA::class; + $config->forceMfa = true; + $config->actionsMfa['email'] = Email2FA::class; $config->actions['register'] = EmailActivator::class; Factories::injectMock('config', 'Auth', $config); diff --git a/tests/Controllers/LoginTest.php b/tests/Controllers/LoginTest.php index acdb1e894..0ea243b57 100644 --- a/tests/Controllers/LoginTest.php +++ b/tests/Controllers/LoginTest.php @@ -17,10 +17,14 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Actions\Email2FA; use CodeIgniter\Shield\Config\Auth; +use CodeIgniter\Shield\Entities\User; +use CodeIgniter\Shield\Models\UserIdentityModel; +use CodeIgniter\Shield\Models\UserModel; use CodeIgniter\Test\FeatureTestTrait; use Config\Services; use Config\Validation; use Tests\Support\DatabaseTestCase; +use Tests\Support\FakeAction; use Tests\Support\FakeUser; /** @@ -235,8 +239,9 @@ public function testLogoutAction(): void public function testLoginRedirectsToActionIfDefined(): void { // Ensure our action is defined - $config = config('Auth'); - $config->actions['login'] = Email2FA::class; + $config = config('Auth'); + $config->Mfa = true; + $config->actionsMfa['email'] = Email2FA::class; Factories::injectMock('config', 'Auth', $config); $this->user->createEmailIdentity([ @@ -256,4 +261,189 @@ public function testLoginRedirectsToActionIfDefined(): void $result->assertSessionMissing('errors'); $this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl()); } + + public function testLoginNotForcedMFAUserWithoutMFA(): void + { + // Ensure our action is defined + $config = config('Auth'); + $config->Mfa = true; + $config->forceMfa = false; + $config->actionsMfa['email'] = Email2FA::class; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login', [ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + // Should have been redirected to the action's page. + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionMissing('errors'); + $this->assertSame(site_url(), $result->getRedirectUrl()); + } + + public function testLoginNotForcedMFAUserWithMFA(): void + { + // Ensure our action is defined + $config = config('Auth'); + $config->Mfa = true; + $config->forceMfa = false; + $config->actionsMfa['email'] = Email2FA::class; + Factories::injectMock('config', 'Auth', $config); + + $users = model(UserModel::class); + $this->assertFalse($this->user->isMfaActive()); + $this->user->fill(['mfa' => 1]); + $users->save($this->user); + $this->assertTrue($this->user->isMfaActive()); + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login', [ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + // Should have been redirected to the action's page. + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionMissing('errors'); + $this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl()); + } + + public function testLoginForcedMFAUserWithoutMFA(): void + { + // Ensure our action is defined + $config = config('Auth'); + $config->Mfa = true; + $config->forceMfa = true; + $config->actionsMfa['email'] = Email2FA::class; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login', [ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + // Should have been redirected to the action's page. + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionMissing('errors'); + $this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl()); + } + + public function testLoginMFADisabled(): void + { + // Ensure our action is defined + $config = config('Auth'); + $config->actionsMfa['email'] = Email2FA::class; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login', [ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + // Should have been redirected to the action's page. + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionMissing('errors'); + $this->assertSame(site_url(), $result->getRedirectUrl()); + } + + public function testLoginMultipleMFA(): void + { + // Ensure our action is defined + $config = config('Auth'); + $config->Mfa = true; + $config->actionsMfa['email'] = Email2FA::class; + $config->actionsMfa['test'] = FakeAction::class; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + fake(UserIdentityModel::class, ['user_id' => $this->user->id, 'type' => 'test', 'name' => 'login', 'secret' => 'secret-for-test-token', 'extra' => serialize(['recover_pos' => []])]); + + $this->assertFalse($this->user->isMfaActive()); + $this->user->fill(['mfa' => 1]); + $users = model(UserModel::class); + $users->save($this->user); + $this->assertTrue($this->user->isMfaActive()); + + $result = $this->post('/login', [ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + // Should have been redirected to the action's page. + $result->assertStatus(302); + $result->assertRedirect(); + + $result->assertSessionMissing('error'); + $result->assertSessionMissing('errors'); + $this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl()); + } + + public function testLoginMultipleMFAGroups(): void + { + // Ensure our action is defined + $config = config('Auth'); + $config->Mfa = true; + $config->forceMfa = false; + $config->actionsMfa['email'] = Email2FA::class; + $config->actionsMfa['test'] = FakeAction::class; + $config->matrixMfa['admin'] = 'test'; + Factories::injectMock('config', 'Auth', $config); + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + fake(UserIdentityModel::class, ['user_id' => $this->user->id, 'type' => 'test', 'name' => 'login', 'secret' => 'secret-for-test-token', 'extra' => '']); + + $this->assertFalse($this->user->isMfaActive()); + $this->user->fill(['mfa' => 1]); + $users = model(UserModel::class); + $users->save($this->user); + $this->assertTrue($this->user->isMfaActive()); + + $result = $this->post('/login', [ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + // Should have been redirected to the action's page. + $result->assertStatus(302); + $result->assertRedirect(); + + $result->assertSessionMissing('error'); + $result->assertSessionMissing('errors'); + $this->assertSame(site_url('auth/a/show'), $result->getRedirectUrl()); + } } diff --git a/tests/_support/FakeAction.php b/tests/_support/FakeAction.php new file mode 100644 index 000000000..828e19651 --- /dev/null +++ b/tests/_support/FakeAction.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\Shield\Authentication\Actions\ActionInterface; +use CodeIgniter\Shield\Entities\User; + +/** + * @internal + */ +// Simulate new action + +final class FakeAction implements ActionInterface +{ + private string $type = 'test'; + + public function show(): string + { + return 'show'; + } + + public function handle(IncomingRequest $request): string + { + return 'handle'; + } + + public function verify(IncomingRequest $request): string + { + return 'verify'; + } + + public function getActionMessage(): string + { + return 'Finish 2FA'; + } + + public function createIdentity(User $user): string + { + return 'created!'; + } + + public function getType(): string + { + return $this->type; + } +} diff --git a/tests/_support/TestCase.php b/tests/_support/TestCase.php index a7af822ea..208d36e1e 100644 --- a/tests/_support/TestCase.php +++ b/tests/_support/TestCase.php @@ -43,8 +43,11 @@ protected function setUp(): void setting('Email.fromName', 'John Smith'); // Clear any actions - $config = config('Auth'); - $config->actions = ['login' => null, 'register' => null]; + $config = config('Auth'); + $config->Mfa = false; + $config->forceMfa = true; + $config->actions = ['register' => null]; + $config->actionsMfa = ['email' => null]; Factories::injectMock('config', 'Auth', $config); // Set Config\Security::$csrfProtection to 'session'