Skip to content
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

feat: expand 2FA support #1254

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 45 additions & 5 deletions docs/quick_start_guide/using_session_auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
errorBaseline="psalm-baseline.xml"
findUnusedBaselineEntry="false"
findUnusedCode="false"
ensureOverrideAttribute="false"
>
<projectFiles>
<directory name="src/" />
Expand Down
5 changes: 5 additions & 0 deletions src/Authentication/Actions/ActionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions src/Authentication/Actions/Email2FA.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
13 changes: 13 additions & 0 deletions src/Authentication/Actions/EmailActivator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
96 changes: 81 additions & 15 deletions src/Authentication/Authenticators/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
64 changes: 61 additions & 3 deletions src/Config/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand All @@ -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<string, class-string<ActionInterface>|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'
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
9 changes: 9 additions & 0 deletions src/Entities/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class User extends Entity
protected $casts = [
'id' => '?integer',
'active' => 'int-bool',
'mfa' => 'bool',
'permissions' => 'array',
'groups' => 'array',
];
Expand Down Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions src/Models/UserModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class UserModel extends BaseModel
'status',
'status_message',
'active',
'mfa',
'last_active',
];
protected $useTimestamps = true;
Expand Down
3 changes: 2 additions & 1 deletion tests/Controllers/ActionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading