From 40cfd36d89c3391ad2ef1ee1362d0e4826377e51 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Wed, 9 Sep 2026 16:57:03 +0100
Subject: [PATCH] feat: Add group policies, method management, and a safer MFA
flow
Co-authored-by: Cursor
---
README.md | 35 +
admin/views/User/Group/tabs/mfa.php | 22 +
admin/views/User/tabs/mfa.php | 68 ++
mfa/controllers/Mfa.php | 560 ++++++++++++--
mfa/views/form.php | 55 +-
mfa/views/manage.php | 205 ++++++
mfa/views/setup.php | 56 ++
mfa/views/setup_confirm.php | 81 +++
mfa/views/structure/footer.php | 10 +
mfa/views/structure/header.php | 10 +
services/services.php | 28 +
src/Auth/Admin/User/Group/Tab/Mfa.php | 110 +++
src/Auth/Admin/User/Tab/Mfa.php | 108 +++
src/Console/Command/Command.php | 396 ++++++++++
src/Console/Command/Config.php | 70 ++
src/Console/Command/Driver/Disable.php | 73 ++
src/Console/Command/Driver/Enable.php | 57 ++
src/Console/Command/Driver/Setting.php | 94 +++
src/Console/Command/Group/Policy.php | 82 +++
src/Console/Command/User/Method/Add.php | 89 +++
.../Command/User/Method/DefaultMethod.php | 74 ++
src/Console/Command/User/Method/Remove.php | 70 ++
src/Console/Command/User/Status.php | 78 ++
src/Database/Migration/Migration2.php | 69 ++
src/Event/Listener/User/LogIn.php | 60 +-
src/Event/Listener/User/LogOut.php | 55 ++
src/Interfaces/Authentication/Driver.php | 19 +
src/Model/GroupPolicy.php | 105 +++
src/Model/UserMethod.php | 99 +++
src/Resource/GroupPolicy.php | 14 +
src/Resource/UserMethod.php | 61 ++
src/Routes.php | 13 +-
src/Service/MultiFactorAuth.php | 682 +++++++++++++++++-
tests/ConsoleCommandsTest.php | 48 ++
tests/RoutesTest.php | 5 +-
35 files changed, 3557 insertions(+), 104 deletions(-)
create mode 100644 admin/views/User/Group/tabs/mfa.php
create mode 100644 admin/views/User/tabs/mfa.php
create mode 100644 mfa/views/manage.php
create mode 100644 mfa/views/setup.php
create mode 100644 mfa/views/setup_confirm.php
create mode 100644 mfa/views/structure/footer.php
create mode 100644 mfa/views/structure/header.php
create mode 100644 src/Auth/Admin/User/Group/Tab/Mfa.php
create mode 100644 src/Auth/Admin/User/Tab/Mfa.php
create mode 100644 src/Console/Command/Command.php
create mode 100644 src/Console/Command/Config.php
create mode 100644 src/Console/Command/Driver/Disable.php
create mode 100644 src/Console/Command/Driver/Enable.php
create mode 100644 src/Console/Command/Driver/Setting.php
create mode 100644 src/Console/Command/Group/Policy.php
create mode 100644 src/Console/Command/User/Method/Add.php
create mode 100644 src/Console/Command/User/Method/DefaultMethod.php
create mode 100644 src/Console/Command/User/Method/Remove.php
create mode 100644 src/Console/Command/User/Status.php
create mode 100644 src/Database/Migration/Migration2.php
create mode 100644 src/Event/Listener/User/LogOut.php
create mode 100644 src/Model/GroupPolicy.php
create mode 100644 src/Model/UserMethod.php
create mode 100644 src/Resource/GroupPolicy.php
create mode 100644 src/Resource/UserMethod.php
create mode 100644 tests/ConsoleCommandsTest.php
diff --git a/README.md b/README.md
index a05e053..dbf0aba 100644
--- a/README.md
+++ b/README.md
@@ -4,3 +4,38 @@
[](https://github.com/nails/module-multi-factor-auth/actions)
This is the MFA module for Nails, it provides two factor authentication support for `module-auth` powered by drivers.
+
+## Configuration
+
+Set these as config properties (e.g. in `config/app.php`):
+
+- `MFA_TRUSTED_DEVICE_TTL` — how long, in seconds, a device stays trusted when the user opts to not be asked again. Defaults to 14 days.
+- `MFA_TRUST_SURVIVES_LOGOUT` — whether a trusted device stays trusted after the user signs out. Defaults to `false`, i.e. signing out ends that device's trust.
+
+The service's constants can also be overridden by extending it at app level, most usefully `TOKEN_TTL` (how long a challenge lasts), `MAX_VERIFICATION_ATTEMPTS` (incorrect codes allowed per challenge), `MAX_RESENDS_PER_TOKEN` (replacement codes a user can request per challenge), and `MAX_TOKEN_MINTS_PER_HOUR` (new challenges issued per user per hour).
+
+## Views
+
+MFA pages use Nails' blank header and footer by default. Applications can inject their own page shell by providing `application/modules/mfa/views/structure/header.php` and `application/modules/mfa/views/structure/footer.php`; individual MFA views can be overridden in the same module view directory.
+
+## Console utilities
+
+Run commands through the Nails console:
+
+```bash
+php vendor/nails/module-console/console.php mfa:config
+```
+
+Available MFA commands:
+
+- `mfa:config` — show installed/enabled drivers and group policies.
+- `mfa:driver:enable --driver=` — enable an installed driver.
+- `mfa:driver:disable --driver=` — disable a driver while retaining user enrollments.
+- `mfa:driver:setting --driver= [--key= [--value=]]` — inspect or update driver app settings. Use `--json` for structured values.
+- `mfa:group:policy --group= [--mode=DISABLED|OPTIONAL|REQUIRED]` — inspect or update a group policy.
+- `mfa:user:status --user=` — show a user's effective policy and enrolled methods.
+- `mfa:user:method:add --user= --driver= [--default]` — enroll a non-interactive driver such as Email.
+- `mfa:user:method:remove --user= --driver=` — remove an enrollment.
+- `mfa:user:method:default --user= --driver=` — change the user's default method.
+
+Omit `--driver`, `--user`, or `--group` in an interactive terminal to be prompted. `--force` and `--no-interaction` skip prompts and require those options to be set. Mutating commands request confirmation. Pass `--force` for unattended execution. Drivers which hold a user secret, such as Authenticator, must be enrolled interactively so the secret and QR code are delivered directly to the user.
diff --git a/admin/views/User/Group/tabs/mfa.php b/admin/views/User/Group/tabs/mfa.php
new file mode 100644
index 0000000..de55833
--- /dev/null
+++ b/admin/views/User/Group/tabs/mfa.php
@@ -0,0 +1,22 @@
+ $aModes
+ * @var string $sPolicyMode
+ */
+
+echo form_field_dropdown([
+ 'key' => 'mfa_group_policy',
+ 'label' => 'Policy',
+ 'class' => 'select2',
+ 'default' => $sPolicyMode,
+ 'options' => $aModes,
+ 'info' => implode('', [
+ '
',
+ 'Disabled skips MFA for this group.',
+ ' ' . 'Optional challenges users once they enrol a method.',
+ ' ' . 'Required always challenges users, and forces setup on their next login if nothing is enrolled.',
+ '
+
+ =htmlspecialchars($oDriver->getLabel())?> will become your default method. You can change your
+ default later from your two-factor authentication settings.
+
+
+
+
@@ -42,18 +58,47 @@
if ($oDriver->canTryAgain()) {
?>
-
+ =form_close()?>
+
+
+
+
+
+ Use =htmlspecialchars($oOther->getLabel())?>
+
+
+
+
+ =htmlspecialchars($oDriver->getLabel())?> will become your default method. You can change your
+ default later from your two-factor authentication settings.
+
+
+
+
+ Confirm and continue
+
+
+
+ Choose another method
+
+
+
+ Cancel setup
+
+
+ =form_close()?>
+
+
+
diff --git a/mfa/views/structure/footer.php b/mfa/views/structure/footer.php
new file mode 100644
index 0000000..168a97c
--- /dev/null
+++ b/mfa/views/structure/footer.php
@@ -0,0 +1,10 @@
+load(\Nails\Config::get('NAILS_COMMON_PATH') . 'views/structure/footer/blank.php');
diff --git a/mfa/views/structure/header.php b/mfa/views/structure/header.php
new file mode 100644
index 0000000..0f8339d
--- /dev/null
+++ b/mfa/views/structure/header.php
@@ -0,0 +1,10 @@
+load(\Nails\Config::get('NAILS_COMMON_PATH') . 'views/structure/header/blank.php');
diff --git a/services/services.php b/services/services.php
index 22dc906..3ee2297 100644
--- a/services/services.php
+++ b/services/services.php
@@ -37,6 +37,20 @@
return new Model\Token();
}
},
+ 'GroupPolicy' => function (): Model\GroupPolicy {
+ if (class_exists('\App\MFA\Model\GroupPolicy')) {
+ return new \App\MFA\Model\GroupPolicy();
+ } else {
+ return new Model\GroupPolicy();
+ }
+ },
+ 'UserMethod' => function (): Model\UserMethod {
+ if (class_exists('\App\MFA\Model\UserMethod')) {
+ return new \App\MFA\Model\UserMethod();
+ } else {
+ return new Model\UserMethod();
+ }
+ },
],
'resources' => [
'Token' => function ($resource, $model): Resource\Token {
@@ -46,6 +60,20 @@
return new Resource\Token($resource, $model);
}
},
+ 'GroupPolicy' => function ($resource, $model): Resource\GroupPolicy {
+ if (class_exists('\App\MFA\Resource\GroupPolicy')) {
+ return new \App\MFA\Resource\GroupPolicy($resource, $model);
+ } else {
+ return new Resource\GroupPolicy($resource, $model);
+ }
+ },
+ 'UserMethod' => function ($resource, $model): Resource\UserMethod {
+ if (class_exists('\App\MFA\Resource\UserMethod')) {
+ return new \App\MFA\Resource\UserMethod($resource, $model);
+ } else {
+ return new Resource\UserMethod($resource, $model);
+ }
+ },
],
'factories' => [
'EmailCode' => function (): Factory\Email\Code {
diff --git a/src/Auth/Admin/User/Group/Tab/Mfa.php b/src/Auth/Admin/User/Group/Tab/Mfa.php
new file mode 100644
index 0000000..fb1243b
--- /dev/null
+++ b/src/Auth/Admin/User/Group/Tab/Mfa.php
@@ -0,0 +1,110 @@
+load(
+ ['User/Group/tabs/mfa'],
+ [
+ 'oGroup' => $oGroup,
+ 'aModes' => GroupPolicy::modes(),
+ 'sPolicyMode' => $oPolicyModel->getModeForGroup((int) $oGroup->id),
+ ],
+ true
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getAdditionalMarkup(?Group $oGroup): string
+ {
+ return '';
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getValidationRules(?Group $oGroup): array
+ {
+ return [
+ 'mfa_group_policy' => [
+ static function ($sMode): void {
+ if (!array_key_exists((string) $sMode, GroupPolicy::modes())) {
+ throw new ValidationException('Select a valid MFA group policy.');
+ }
+ },
+ ],
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getPostData(?Group $oGroup, array $aPost): array
+ {
+ return [];
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function afterSave(Group $oGroup, array $aPost): void
+ {
+ $sMode = (string) ($aPost['mfa_group_policy'] ?? '');
+ if (!array_key_exists($sMode, GroupPolicy::modes())) {
+ return;
+ }
+
+ /** @var GroupPolicy $oPolicyModel */
+ $oPolicyModel = Factory::model('GroupPolicy', Constants::MODULE_SLUG);
+ $oPolicyModel->setModeForGroup((int) $oGroup->id, $sMode);
+
+ /** @var Logger $oLogger */
+ $oLogger = Factory::service('Logger', Constants::MODULE_SLUG);
+ $oLogger->info(sprintf(
+ 'Updated MFA policy for group %s to %s',
+ $oGroup->id,
+ $sMode
+ ));
+ }
+}
diff --git a/src/Auth/Admin/User/Tab/Mfa.php b/src/Auth/Admin/User/Tab/Mfa.php
new file mode 100644
index 0000000..4bd3201
--- /dev/null
+++ b/src/Auth/Admin/User/Tab/Mfa.php
@@ -0,0 +1,108 @@
+getEnabledDrivers() as $oDriver) {
+ $aDriverLabels[$oDriver->getSlug()] = $oDriver->getLabel();
+ }
+ } catch (MfaException $e) {
+ $aDriverLabels = [];
+ }
+
+ return $oView->load(
+ ['User/tabs/mfa'],
+ [
+ 'oUser' => $oUser,
+ 'sGroupMode' => $oMfa->getGroupMode($oUser),
+ 'aModes' => GroupPolicy::modes(),
+ 'aMethods' => $oMfa->getUserMethods($oUser),
+ 'aDriverLabels' => $aDriverLabels,
+ ],
+ true
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getAdditionalMarkup(User $oUser): string
+ {
+ return '';
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getValidationRules(User $oUser): array
+ {
+ return [];
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getPostData(User $oUser, array $aPost): array
+ {
+ /** @var MultiFactorAuth $oMfa */
+ $oMfa = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+ /** @var Logger $oLogger */
+ $oLogger = Factory::service('Logger', Constants::MODULE_SLUG);
+
+ $sDefault = $aPost['mfa_default_driver'] ?? '';
+ if (is_string($sDefault) && $sDefault !== '' && $oMfa->getUserMethod($oUser, $sDefault)) {
+ $oMfa->setDefaultMethod($oUser, $sDefault);
+ }
+
+ $aResets = array_filter((array) ($aPost['mfa_reset_driver'] ?? []));
+ foreach ($aResets as $sDriver) {
+ $oMfa->removeMethod($oUser, (string) $sDriver, true);
+ $oLogger->info(sprintf(
+ 'Admin reset MFA method %s for user %s',
+ $sDriver,
+ $oUser->id
+ ));
+ }
+
+ return [];
+ }
+}
diff --git a/src/Console/Command/Command.php b/src/Console/Command/Command.php
new file mode 100644
index 0000000..3d50bff
--- /dev/null
+++ b/src/Console/Command/Command.php
@@ -0,0 +1,396 @@
+oInput->getOption($sOption));
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function canPrompt(): bool
+ {
+ if (!$this->oInput->isInteractive()) {
+ return false;
+ }
+
+ return !$this->oInput->hasOption('force')
+ || !$this->oInput->getOption('force');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Asks the user to pick from a keyed list of choices, returning the key
+ *
+ * Symfony returns the label when the choices are a list, and the key when they
+ * are associative, so normalise to a list and map the answer back to its key.
+ *
+ * @param array $aChoices
+ * @throws NailsException
+ */
+ protected function chooseKey(string $sQuestion, array $aChoices): string
+ {
+ $aKeys = array_keys($aChoices);
+ $aLabels = array_values($aChoices);
+
+ $sAnswer = (string) $this->choose($sQuestion, $aLabels);
+ $mIndex = array_search($sAnswer, $aLabels, true);
+
+ if ($mIndex !== false) {
+ return (string) $aKeys[$mIndex];
+ }
+
+ if (array_key_exists($sAnswer, $aChoices)) {
+ return $sAnswer;
+ }
+
+ throw new NailsException(sprintf('"%s" is not a valid choice.', $sAnswer));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws NailsException
+ */
+ protected function requireUser(?string $sIdentifier = null): User
+ {
+ $sIdentifier = trim((string) ($sIdentifier ?? $this->optionString('user')));
+
+ if ($sIdentifier === '') {
+ if (!$this->canPrompt()) {
+ throw new NailsException('A user is required; use --user=.');
+ }
+
+ $sIdentifier = trim((string) $this->ask('User ID, email, or username', ''));
+ }
+
+ if ($sIdentifier === '') {
+ throw new NailsException('A user is required; use --user=.');
+ }
+
+ return $this->resolveUser($sIdentifier);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws NailsException
+ */
+ protected function requireGroup(?string $sIdentifier = null): Auth\Resource\User\Group
+ {
+ $sIdentifier = trim((string) ($sIdentifier ?? $this->optionString('group')));
+
+ if ($sIdentifier === '') {
+ $aChoices = $this->groupChoices();
+ if (!$this->canPrompt()) {
+ throw new NailsException('A group is required; use --group=.');
+ }
+ if (empty($aChoices)) {
+ throw new NailsException('No user groups are available.');
+ }
+
+ $sIdentifier = $this->chooseKey('Select a user group', $aChoices);
+ }
+
+ return $this->resolveGroup($sIdentifier);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @param array|null $aChoices
+ * @throws NailsException
+ */
+ protected function requireDriver(
+ ?string $sSlug = null,
+ ?array $aChoices = null,
+ string $sQuestion = 'Select an MFA driver',
+ string $sEmptyMessage = 'No MFA drivers are available.'
+ ): Component {
+ $sSlug = trim((string) ($sSlug ?? $this->optionString('driver')));
+ $aChoices = $aChoices ?? $this->driverChoices();
+
+ if ($sSlug === '') {
+ if (!$this->canPrompt()) {
+ throw new NailsException('A driver is required; use --driver=.');
+ }
+ if (empty($aChoices)) {
+ throw new NailsException($sEmptyMessage);
+ }
+
+ $sSlug = $this->chooseKey($sQuestion, $aChoices);
+ }
+
+ return $this->resolveDriver($sSlug);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function driverChoices(?callable $fnFilter = null): array
+ {
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aChoices = [];
+
+ foreach ($oService->getAll() as $oComponent) {
+ if ($fnFilter && !$fnFilter($oComponent, $oService)) {
+ continue;
+ }
+
+ $aChoices[$oComponent->slug] = sprintf(
+ '%s (%s)',
+ $this->driverLabel($oComponent),
+ $oComponent->slug
+ );
+ }
+
+ return $aChoices;
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function driverLabel(Component $oComponent): string
+ {
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+
+ try {
+ $sLabel = (string) $oService->getInstance($oComponent)?->getLabel();
+ } catch (NailsException) {
+ $sLabel = '';
+ }
+
+ return $sLabel ?: (string) $oComponent->name;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function enabledDriverChoices(): array
+ {
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aEnabled = (array) $oService->getEnabledSlug();
+
+ return $this->driverChoices(
+ static fn(Component $oComponent) => in_array($oComponent->slug, $aEnabled, true)
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function disabledDriverChoices(): array
+ {
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aEnabled = (array) $oService->getEnabledSlug();
+
+ return $this->driverChoices(
+ static fn(Component $oComponent) => !in_array($oComponent->slug, $aEnabled, true)
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function enrollableDriverChoices(): array
+ {
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aEnabled = (array) $oService->getEnabledSlug();
+
+ return $this->driverChoices(static function (Component $oComponent) use ($oService, $aEnabled) {
+ if (!in_array($oComponent->slug, $aEnabled, true)) {
+ return false;
+ }
+
+ $oDriver = $oService->getInstance($oComponent);
+
+ return $oDriver && !$oDriver->requiresEnrollment();
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function enrolledDriverChoices(User $oUser): array
+ {
+ /** @var MultiFactorAuth $oMfa */
+ $oMfa = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+ $aChoices = [];
+
+ foreach ($oMfa->getUserMethods($oUser) as $oMethod) {
+ $sSlug = (string) $oMethod->driver;
+ $sLabel = $sSlug;
+
+ try {
+ $sLabel = sprintf(
+ '%s (%s)',
+ $oMfa->getDriverBySlug($sSlug)->getLabel(),
+ $sSlug
+ );
+ } catch (MfaException) {
+ $sLabel = sprintf('%s (unavailable)', $sSlug);
+ }
+
+ $aChoices[$sSlug] = $sLabel;
+ }
+
+ return $aChoices;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function groupChoices(): array
+ {
+ /** @var Auth\Model\User\Group $oModel */
+ $oModel = Factory::model('UserGroup', Auth\Constants::MODULE_SLUG);
+ $aChoices = [];
+
+ foreach ($oModel->getAll() as $oGroup) {
+ $sKey = trim((string) $oGroup->slug) ?: (string) $oGroup->id;
+ $aChoices[$sKey] = sprintf(
+ '%s (%s)',
+ $oGroup->label,
+ $oGroup->slug
+ );
+ }
+
+ return $aChoices;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws NailsException
+ */
+ protected function resolveUser(string $sIdentifier): User
+ {
+ $sIdentifier = trim($sIdentifier);
+ if ($sIdentifier === '') {
+ throw new NailsException('A user is required; use --user=.');
+ }
+
+ /** @var Auth\Model\User $oModel */
+ $oModel = Factory::model('User', Auth\Constants::MODULE_SLUG);
+
+ if (is_numeric($sIdentifier)) {
+ $oUser = $oModel->getById((int) $sIdentifier);
+ } else {
+ $oUser = $oModel->getByEmail($sIdentifier)
+ ?: $oModel->getByUsername($sIdentifier);
+ }
+
+ if (!$oUser) {
+ throw new NailsException(sprintf(
+ 'Could not find a user by ID, email, or username "%s".',
+ $sIdentifier
+ ));
+ }
+
+ return $oUser;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws NailsException
+ */
+ protected function resolveGroup(string $sIdentifier): Auth\Resource\User\Group
+ {
+ $sIdentifier = trim($sIdentifier);
+ if ($sIdentifier === '') {
+ throw new NailsException('A group is required; use --group=.');
+ }
+
+ /** @var Auth\Model\User\Group $oModel */
+ $oModel = Factory::model('UserGroup', Auth\Constants::MODULE_SLUG);
+ $oGroup = $oModel->getByIdOrSlug($sIdentifier);
+
+ if (!$oGroup) {
+ throw new NailsException(sprintf(
+ 'Could not find a user group by ID or slug "%s".',
+ $sIdentifier
+ ));
+ }
+
+ return $oGroup;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws NailsException
+ */
+ protected function resolveDriver(string $sSlug): Component
+ {
+ $sSlug = trim($sSlug);
+ if ($sSlug === '') {
+ throw new NailsException('A driver is required; use --driver=.');
+ }
+
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $oDriver = $oService->getBySlug($sSlug);
+
+ if (!$oDriver) {
+ throw new NailsException(sprintf(
+ 'MFA driver "%s" is not installed.',
+ $sSlug
+ ));
+ }
+
+ return $oDriver;
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function shouldContinue(InputInterface $oInput, string $sQuestion): bool
+ {
+ return (bool) $oInput->getOption('force')
+ || $this->confirm($sQuestion, false);
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function describeUser(User $oUser): string
+ {
+ return sprintf(
+ '#%s %s (%s)',
+ $oUser->id,
+ trim((string) $oUser->name),
+ $oUser->email
+ );
+ }
+}
diff --git a/src/Console/Command/Config.php b/src/Console/Command/Config.php
new file mode 100644
index 0000000..d0f88d3
--- /dev/null
+++ b/src/Console/Command/Config.php
@@ -0,0 +1,70 @@
+setName('mfa:config')
+ ->setDescription('Displays MFA drivers, app settings, and group policies');
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Configuration');
+
+ /** @var AuthenticationDriver $oDrivers */
+ $oDrivers = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aEnabled = (array) $oDrivers->getEnabledSlug();
+
+ $oOutput->writeln('Drivers');
+ (new Table($oOutput))
+ ->setHeaders(['Status', 'Driver', 'Package'])
+ ->setRows(array_map(
+ static fn($oComponent) => [
+ in_array($oComponent->slug, $aEnabled, true) ? 'enabled' : 'disabled',
+ $oComponent->name,
+ $oComponent->slug,
+ ],
+ $oDrivers->getAll()
+ ))
+ ->render();
+
+ /** @var Auth\Model\User\Group $oGroups */
+ $oGroups = Factory::model('UserGroup', Auth\Constants::MODULE_SLUG);
+ /** @var GroupPolicy $oPolicies */
+ $oPolicies = Factory::model('GroupPolicy', Constants::MODULE_SLUG);
+
+ $oOutput->writeln('');
+ $oOutput->writeln('Group policies');
+ (new Table($oOutput))
+ ->setHeaders(['ID', 'Group', 'Slug', 'Policy'])
+ ->setRows(array_map(
+ static fn($oGroup) => [
+ $oGroup->id,
+ $oGroup->label,
+ $oGroup->slug,
+ $oPolicies->getModeForGroup((int) $oGroup->id),
+ ],
+ $oGroups->getAll()
+ ))
+ ->render();
+
+ $oOutput->writeln('');
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/Driver/Disable.php b/src/Console/Command/Driver/Disable.php
new file mode 100644
index 0000000..bf339ac
--- /dev/null
+++ b/src/Console/Command/Driver/Disable.php
@@ -0,0 +1,73 @@
+setName('mfa:driver:disable')
+ ->setDescription('Disables an MFA driver without deleting user enrollments')
+ ->addOption('driver', 'd', InputOption::VALUE_REQUIRED, 'Composer package slug; prompted if omitted')
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Do not ask for confirmation; also permits disabling the last driver'
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Disable Driver');
+
+ $sSlug = $this->requireDriver(
+ null,
+ $this->enabledDriverChoices(),
+ 'Select an MFA driver to disable',
+ 'No MFA drivers are currently enabled.'
+ )->slug;
+
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aEnabled = (array) $oService->getEnabledSlug();
+
+ if (!in_array($sSlug, $aEnabled, true)) {
+ $oOutput->writeln(sprintf('%s is already disabled.', $sSlug));
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (count($aEnabled) === 1 && !$oInput->getOption('force')) {
+ $this->error([
+ 'Refusing to disable the last enabled MFA driver.',
+ 'Required users would be unable to sign in.',
+ 'Use --force only if this is intentional.',
+ ]);
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ if (!$this->shouldContinue(
+ $oInput,
+ sprintf('Disable MFA driver "%s"? Existing enrollments will be retained.', $sSlug)
+ )) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ $oService->saveEnabled(array_values(array_diff($aEnabled, [$sSlug])));
+
+ $oOutput->writeln(sprintf('Disabled %s.', $sSlug));
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/Driver/Enable.php b/src/Console/Command/Driver/Enable.php
new file mode 100644
index 0000000..9a18e35
--- /dev/null
+++ b/src/Console/Command/Driver/Enable.php
@@ -0,0 +1,57 @@
+setName('mfa:driver:enable')
+ ->setDescription('Enables an installed MFA driver')
+ ->addOption('driver', 'd', InputOption::VALUE_REQUIRED, 'Composer package slug; prompted if omitted')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation');
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Enable Driver');
+
+ $sSlug = $this->requireDriver(
+ null,
+ $this->disabledDriverChoices(),
+ 'Select an MFA driver to enable',
+ 'All installed MFA drivers are already enabled.'
+ )->slug;
+
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $aEnabled = (array) $oService->getEnabledSlug();
+
+ if (in_array($sSlug, $aEnabled, true)) {
+ $oOutput->writeln(sprintf('%s is already enabled.', $sSlug));
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (!$this->shouldContinue($oInput, sprintf('Enable MFA driver "%s"?', $sSlug))) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ $aEnabled[] = $sSlug;
+ $oService->saveEnabled($aEnabled);
+
+ $oOutput->writeln(sprintf('Enabled %s.', $sSlug));
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/Driver/Setting.php b/src/Console/Command/Driver/Setting.php
new file mode 100644
index 0000000..9919817
--- /dev/null
+++ b/src/Console/Command/Driver/Setting.php
@@ -0,0 +1,94 @@
+setName('mfa:driver:setting')
+ ->setDescription('Lists or updates app settings for an MFA driver')
+ ->addOption('driver', 'd', InputOption::VALUE_REQUIRED, 'Composer package slug; prompted if omitted')
+ ->addOption('key', 'k', InputOption::VALUE_OPTIONAL, 'Setting key')
+ ->addOption('value', null, InputOption::VALUE_OPTIONAL, 'New setting value')
+ ->addOption('json', null, InputOption::VALUE_NONE, 'Decode the value as JSON')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws JsonException
+ * @throws NailsException
+ */
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Driver Setting');
+
+ $sDriver = $this->requireDriver()->slug;
+
+ $sKey = $oInput->getOption('key');
+ $mValue = $oInput->getOption('value');
+
+ if ($sKey === null || $sKey === '') {
+ $aSettings = appSetting(null, $sDriver, []);
+ if (empty($aSettings)) {
+ $oOutput->writeln('No app settings have been saved for this driver.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ (new Table($oOutput))
+ ->setHeaders(['Key', 'Value'])
+ ->setRows(array_map(
+ static fn($sSetting, $mSettingValue) => [
+ $sSetting,
+ is_scalar($mSettingValue) || $mSettingValue === null
+ ? json_encode($mSettingValue)
+ : json_encode($mSettingValue, JSON_PRETTY_PRINT),
+ ],
+ array_keys($aSettings),
+ array_values($aSettings)
+ ))
+ ->render();
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if ($mValue === null) {
+ $mCurrent = appSetting((string) $sKey, $sDriver);
+ $oOutput->writeln(sprintf(
+ '%s = %s',
+ $sKey,
+ json_encode($mCurrent, JSON_PRETTY_PRINT)
+ ));
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if ($oInput->getOption('json')) {
+ $mValue = json_decode((string) $mValue, true, 512, JSON_THROW_ON_ERROR);
+ }
+
+ if (!$this->shouldContinue(
+ $oInput,
+ sprintf('Set %s:%s to %s?', $sDriver, $sKey, json_encode($mValue))
+ )) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ if (!setAppSetting((string) $sKey, $sDriver, $mValue)) {
+ throw new NailsException('Failed to save the driver setting.');
+ }
+
+ $oOutput->writeln('Driver setting saved.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/Group/Policy.php b/src/Console/Command/Group/Policy.php
new file mode 100644
index 0000000..6b95d71
--- /dev/null
+++ b/src/Console/Command/Group/Policy.php
@@ -0,0 +1,82 @@
+setName('mfa:group:policy')
+ ->setDescription('Shows or changes the MFA policy for a user group')
+ ->addOption('group', 'g', InputOption::VALUE_REQUIRED, 'Group ID or slug; prompted if omitted')
+ ->addOption(
+ 'mode',
+ 'm',
+ InputOption::VALUE_OPTIONAL,
+ 'Policy: DISABLED, OPTIONAL, or REQUIRED'
+ )
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation');
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Group Policy');
+
+ $oGroup = $this->requireGroup();
+
+ /** @var GroupPolicyModel $oModel */
+ $oModel = Factory::model('GroupPolicy', Constants::MODULE_SLUG);
+ $sCurrentMode = $oModel->getModeForGroup((int) $oGroup->id);
+ $sNewMode = strtoupper(trim((string) $oInput->getOption('mode')));
+
+ if ($sNewMode === '') {
+ $oOutput->writeln(sprintf(
+ '%s (%s): %s',
+ $oGroup->label,
+ $oGroup->slug,
+ $sCurrentMode
+ ));
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (!array_key_exists($sNewMode, GroupPolicyModel::modes())) {
+ throw new NailsException(sprintf(
+ 'Invalid mode "%s". Expected one of: %s.',
+ $sNewMode,
+ implode(', ', array_keys(GroupPolicyModel::modes()))
+ ));
+ }
+
+ if ($sCurrentMode === $sNewMode) {
+ $oOutput->writeln('The group already uses that MFA policy.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (!$this->shouldContinue($oInput, sprintf(
+ 'Change MFA policy for "%s" from %s to %s?',
+ $oGroup->label,
+ $sCurrentMode,
+ $sNewMode
+ ))) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ $oModel->setModeForGroup((int) $oGroup->id, $sNewMode);
+ $oOutput->writeln('Group MFA policy updated.');
+
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/User/Method/Add.php b/src/Console/Command/User/Method/Add.php
new file mode 100644
index 0000000..a64afb3
--- /dev/null
+++ b/src/Console/Command/User/Method/Add.php
@@ -0,0 +1,89 @@
+setName('mfa:user:method:add')
+ ->setDescription('Enrolls a user in an MFA method which requires no interactive setup')
+ ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID, email, or username; prompted if omitted')
+ ->addOption('driver', 'd', InputOption::VALUE_REQUIRED, 'Composer package slug; prompted if omitted')
+ ->addOption('default', null, InputOption::VALUE_NONE, 'Make this the default method')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation');
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Add User Method');
+
+ $oUser = $this->requireUser();
+ $sDriver = $this->requireDriver(
+ null,
+ $this->enrollableDriverChoices(),
+ 'Select an MFA driver to enroll',
+ 'No MFA drivers can be enrolled from the console.'
+ )->slug;
+
+ /** @var MultiFactorAuth $oMfa */
+ $oMfa = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+ $oDriver = $oMfa->getDriverBySlug($sDriver);
+
+ $aEnabled = array_map(
+ static fn($oEnabledDriver) => $oEnabledDriver->getSlug(),
+ $oMfa->getEnabledDrivers()
+ );
+ if (!in_array($sDriver, $aEnabled, true)) {
+ throw new NailsException(sprintf(
+ 'MFA driver "%s" is disabled. Enable it before enrolling users.',
+ $sDriver
+ ));
+ }
+
+ if ($oDriver->requiresEnrollment()) {
+ throw new NailsException(sprintf(
+ '%s requires interactive enrollment. Ask the user to set it up at /mfa/manage.',
+ $oDriver->getLabel()
+ ));
+ }
+
+ if ($oMfa->getUserMethod($oUser, $sDriver)) {
+ $oOutput->writeln('The user is already enrolled in that method.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (!$this->shouldContinue($oInput, sprintf(
+ 'Enroll %s in %s?',
+ $this->describeUser($oUser),
+ $oDriver->getLabel()
+ ))) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ $oData = $oDriver->setupComplete($oUser, '', new stdClass());
+ $oMfa->enrollMethod(
+ $oUser,
+ $sDriver,
+ $oData,
+ (bool) $oInput->getOption('default')
+ );
+
+ $oOutput->writeln('User method enrolled.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/User/Method/DefaultMethod.php b/src/Console/Command/User/Method/DefaultMethod.php
new file mode 100644
index 0000000..7998634
--- /dev/null
+++ b/src/Console/Command/User/Method/DefaultMethod.php
@@ -0,0 +1,74 @@
+setName('mfa:user:method:default')
+ ->setDescription('Changes a user\'s default enrolled MFA method')
+ ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID, email, or username; prompted if omitted')
+ ->addOption('driver', 'd', InputOption::VALUE_REQUIRED, 'Composer package slug; prompted if omitted')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation');
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Set Default User Method');
+
+ $oUser = $this->requireUser();
+ $sDriver = $this->requireDriver(
+ null,
+ $this->enrolledDriverChoices($oUser),
+ 'Select the default MFA method',
+ 'The user has no enrolled MFA methods.'
+ )->slug;
+
+ /** @var MultiFactorAuth $oMfa */
+ $oMfa = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+ $oCurrent = $oMfa->getDefaultUserMethod($oUser);
+
+ $aEnabled = array_map(
+ static fn($oEnabledDriver) => $oEnabledDriver->getSlug(),
+ $oMfa->getEnabledDrivers()
+ );
+ if (!in_array($sDriver, $aEnabled, true)) {
+ throw new NailsException(sprintf(
+ 'MFA driver "%s" is disabled and cannot be made the default.',
+ $sDriver
+ ));
+ }
+
+ if ($oCurrent?->driver === $sDriver) {
+ $oOutput->writeln('That method is already the default.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (!$this->shouldContinue($oInput, sprintf(
+ 'Set "%s" as the default MFA method for %s?',
+ $sDriver,
+ $this->describeUser($oUser)
+ ))) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ $oMfa->setDefaultMethod($oUser, $sDriver);
+
+ $oOutput->writeln('Default user method updated.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/User/Method/Remove.php b/src/Console/Command/User/Method/Remove.php
new file mode 100644
index 0000000..af36956
--- /dev/null
+++ b/src/Console/Command/User/Method/Remove.php
@@ -0,0 +1,70 @@
+setName('mfa:user:method:remove')
+ ->setDescription('Removes an enrolled MFA method from a user')
+ ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID, email, or username; prompted if omitted')
+ ->addOption('driver', 'd', InputOption::VALUE_REQUIRED, 'Composer package slug; prompted if omitted')
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Do not ask for confirmation; also permits removing the last required method'
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: Remove User Method');
+
+ $oUser = $this->requireUser();
+ $sDriver = $this->requireDriver(
+ null,
+ $this->enrolledDriverChoices($oUser),
+ 'Select an enrolled MFA method to remove',
+ 'The user has no enrolled MFA methods.'
+ )->slug;
+
+ /** @var MultiFactorAuth $oMfa */
+ $oMfa = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+
+ if (!$oMfa->getUserMethod($oUser, $sDriver)) {
+ $oOutput->writeln('The user is not enrolled in that method.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ if (!$this->shouldContinue($oInput, sprintf(
+ 'Remove MFA method "%s" from %s?',
+ $sDriver,
+ $this->describeUser($oUser)
+ ))) {
+ return static::EXIT_CODE_FAILURE;
+ }
+
+ $oMfa->removeMethod(
+ $oUser,
+ $sDriver,
+ (bool) $oInput->getOption('force')
+ );
+
+ $oOutput->writeln('User method removed.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Console/Command/User/Status.php b/src/Console/Command/User/Status.php
new file mode 100644
index 0000000..19f8961
--- /dev/null
+++ b/src/Console/Command/User/Status.php
@@ -0,0 +1,78 @@
+setName('mfa:user:status')
+ ->setDescription('Displays a user\'s MFA policy and enrolled methods')
+ ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID, email, or username; prompted if omitted');
+ }
+
+ // --------------------------------------------------------------------------
+
+ protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
+ {
+ parent::execute($oInput, $oOutput);
+ $this->banner('MFA: User Status');
+
+ $oUser = $this->requireUser();
+
+ /** @var MultiFactorAuth $oMfa */
+ $oMfa = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+
+ $aLabels = [];
+ try {
+ foreach ($oMfa->getEnabledDrivers() as $oDriver) {
+ $aLabels[$oDriver->getSlug()] = $oDriver->getLabel();
+ }
+ } catch (MfaException) {
+ // Status should remain useful even if no drivers are enabled
+ }
+
+ $oOutput->writeln(sprintf('User: %s', $this->describeUser($oUser)));
+ $oOutput->writeln(sprintf(
+ 'Group policy: %s',
+ $oMfa->getGroupMode($oUser)
+ ));
+ $oOutput->writeln(sprintf(
+ 'Challenge required: %s',
+ $oMfa->userRequiresChallenge($oUser) ? 'yes' : 'no'
+ ));
+ $oOutput->writeln('');
+
+ $aMethods = $oMfa->getUserMethods($oUser);
+ if (empty($aMethods)) {
+ $oOutput->writeln('No methods enrolled.');
+ return static::EXIT_CODE_SUCCESS;
+ }
+
+ (new Table($oOutput))
+ ->setHeaders(['Default', 'Method', 'Package', 'Enrolled'])
+ ->setRows(array_map(
+ static fn($oMethod) => [
+ $oMethod->is_default ? 'yes' : '',
+ $aLabels[$oMethod->driver] ?? 'Unavailable driver',
+ $oMethod->driver,
+ (string) $oMethod->created,
+ ],
+ $aMethods
+ ))
+ ->render();
+
+ return static::EXIT_CODE_SUCCESS;
+ }
+}
diff --git a/src/Database/Migration/Migration2.php b/src/Database/Migration/Migration2.php
new file mode 100644
index 0000000..d4b95b0
--- /dev/null
+++ b/src/Database/Migration/Migration2.php
@@ -0,0 +1,69 @@
+query(
+ <<<'EOT'
+ CREATE TABLE `{{NAILS_DB_PREFIX}}mfa_group_policy` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `group_id` int unsigned NOT NULL,
+ `mode` enum('DISABLED','OPTIONAL','REQUIRED') NOT NULL DEFAULT 'DISABLED',
+ `created` datetime NOT NULL,
+ `created_by` int unsigned DEFAULT NULL,
+ `modified` datetime NOT NULL,
+ `modified_by` int unsigned DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `group_id` (`group_id`),
+ KEY `created_by` (`created_by`),
+ KEY `modified_by` (`modified_by`),
+ CONSTRAINT `{{NAILS_DB_PREFIX}}mfa_group_policy_ibfk_1` FOREIGN KEY (`group_id`) REFERENCES `{{NAILS_DB_PREFIX}}user_group` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `{{NAILS_DB_PREFIX}}mfa_group_policy_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL,
+ CONSTRAINT `{{NAILS_DB_PREFIX}}mfa_group_policy_ibfk_3` FOREIGN KEY (`modified_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ EOT
+ );
+
+ $this->query(
+ <<<'EOT'
+ CREATE TABLE `{{NAILS_DB_PREFIX}}mfa_user_method` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` int unsigned NOT NULL,
+ `driver` varchar(150) NOT NULL DEFAULT '',
+ `is_default` tinyint(1) unsigned NOT NULL DEFAULT 0,
+ `data` text DEFAULT NULL,
+ `created` datetime NOT NULL,
+ `created_by` int unsigned DEFAULT NULL,
+ `modified` datetime NOT NULL,
+ `modified_by` int unsigned DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `user_driver` (`user_id`, `driver`),
+ KEY `user_id` (`user_id`),
+ KEY `created_by` (`created_by`),
+ KEY `modified_by` (`modified_by`),
+ CONSTRAINT `{{NAILS_DB_PREFIX}}mfa_user_method_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `{{NAILS_DB_PREFIX}}mfa_user_method_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL,
+ CONSTRAINT `{{NAILS_DB_PREFIX}}mfa_user_method_ibfk_3` FOREIGN KEY (`modified_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ EOT
+ );
+ }
+}
diff --git a/src/Event/Listener/User/LogIn.php b/src/Event/Listener/User/LogIn.php
index 6479e53..6e4034a 100644
--- a/src/Event/Listener/User/LogIn.php
+++ b/src/Event/Listener/User/LogIn.php
@@ -2,17 +2,16 @@
namespace Nails\MFA\Event\Listener\User;
-use App\Api\Controller\VirtualAdviser;
+use Nails\Auth\Events;
use Nails\Auth\Model\User;
+use Nails\Common\Events\Subscription;
+use Nails\Common\Service\UserFeedback;
+use Nails\Factory;
use Nails\MFA\Constants;
-use Nails\Auth\Events;
-use Nails\Auth\Service\Authentication;
+use Nails\MFA\Exception\MfaException;
use Nails\MFA\Service\Logger;
use Nails\MFA\Service\MultiFactorAuth;
-use Nails\Common\Events\Subscription;
-use Nails\Common\Exception\ValidationException;
-use Nails\Common\Helper\Model\Expand;
-use Nails\Factory;
+use Throwable;
class LogIn extends Subscription
{
@@ -45,9 +44,48 @@ public function execute(\Nails\Auth\Resource\User $oUser): void
$oUser->email
));
- $oService->authenticate(
- $oUserModel->activeUser(),
- $oUserModel->isRemembered()
- );
+ try {
+
+ $oService->authenticate(
+ $oUserModel->activeUser(),
+ $oUserModel->isRemembered()
+ );
+
+ } catch (Throwable $e) {
+
+ /**
+ * The user is logged in by the time this event fires, so letting the
+ * exception surface would leave them signed in without ever being
+ * challenged. Fail closed: sign them out and send them back to login.
+ */
+
+ $oLogger->error(sprintf(
+ 'MFA could not be applied to user #%s, signing them out: [%s] %s',
+ $oUser->id,
+ $e::class,
+ $e->getMessage()
+ ));
+
+ /** @var UserFeedback $oUserFeedback */
+ $oUserFeedback = Factory::service('UserFeedback');
+
+ /**
+ * Clearing the login data is enough to sign the user out, and unlike
+ * Authentication::logout() it leaves the session intact; that destroys
+ * the PHP session, which takes the feedback message below with it and
+ * bounces the user back to a login form with no explanation.
+ */
+ if (isLoggedIn()) {
+ $oUserModel->clearLoginData();
+ }
+
+ $oUserFeedback->error(
+ $e instanceof MfaException
+ ? $e->getMessage()
+ : 'We could not complete your sign-in. Please try again.'
+ );
+
+ redirect(loginUrl(null));
+ }
}
}
diff --git a/src/Event/Listener/User/LogOut.php b/src/Event/Listener/User/LogOut.php
new file mode 100644
index 0000000..030e83d
--- /dev/null
+++ b/src/Event/Listener/User/LogOut.php
@@ -0,0 +1,55 @@
+setEvent(Events::USER_LOG_OUT)
+ ->setNamespace(Events::getEventNamespace())
+ ->setCallback([$this, 'execute']);
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function execute(?int $iUserId = null): void
+ {
+ /** @var MultiFactorAuth $oService */
+ $oService = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG);
+ /** @var Logger $oLogger */
+ $oLogger = Factory::service('Logger', Constants::MODULE_SLUG);
+
+ $bTrustSurvives = $oService->trustSurvivesLogout();
+
+ $oLogger->info(sprintf(
+ 'Caught user logout event for #%s; discarding MFA cookies (trusted device survives: %s)',
+ $iUserId ?: 'unknown',
+ json_encode($bTrustSurvives)
+ ));
+
+ // A half finished challenge is never worth keeping
+ $oService->clearTokenCookie();
+
+ /**
+ * Signing out is taken as intent to end this device's trust, otherwise
+ * the next person to sign in here skips the challenge. Sites which would
+ * rather honour the full trust window can opt out with the
+ * MFA_TRUST_SURVIVES_LOGOUT config property.
+ */
+ if (!$bTrustSurvives) {
+ $oService->clearIsPrivilegedCookie();
+ }
+ }
+}
diff --git a/src/Interfaces/Authentication/Driver.php b/src/Interfaces/Authentication/Driver.php
index a8a6a19..c5370de 100644
--- a/src/Interfaces/Authentication/Driver.php
+++ b/src/Interfaces/Authentication/Driver.php
@@ -2,8 +2,11 @@
namespace Nails\MFA\Interfaces\Authentication;
+use Nails\Auth\Resource\User;
use Nails\Common\Service\UserFeedback;
use Nails\MFA\Resource\Token;
+use Nails\MFA\Resource\UserMethod;
+use stdClass;
interface Driver
{
@@ -11,6 +14,8 @@ public function getLabel(): string;
public function getDescription(): string;
+ public function getSetupDescription(): string;
+
public function preForm(Token $oToken, UserFeedback $oUserFeedback): void;
public function postForm(Token $oToken): void;
@@ -18,4 +23,18 @@ public function postForm(Token $oToken): void;
public function validate(Token $oToken, string $sCode): void;
public function canTryAgain(): bool;
+
+ /**
+ * Issues a replacement code for an in-progress challenge; only ever called
+ * on drivers which report canTryAgain().
+ */
+ public function resend(Token $oToken, UserFeedback $oUserFeedback): void;
+
+ public function requiresEnrollment(): bool;
+
+ public function setupStart(User $oUser): stdClass;
+
+ public function setupComplete(User $oUser, string $sCode, stdClass $oPending): stdClass;
+
+ public function reset(User $oUser, UserMethod $oMethod): void;
}
diff --git a/src/Model/GroupPolicy.php b/src/Model/GroupPolicy.php
new file mode 100644
index 0000000..a676e30
--- /dev/null
+++ b/src/Model/GroupPolicy.php
@@ -0,0 +1,105 @@
+hasOne('group', 'UserGroup', Auth\Constants::MODULE_SLUG);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return string[]
+ */
+ public static function modes(): array
+ {
+ return [
+ static::MODE_DISABLED => 'Disabled',
+ static::MODE_OPTIONAL => 'Optional',
+ static::MODE_REQUIRED => 'Required',
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getByGroupId(int $iGroupId): ?Resource\GroupPolicy
+ {
+ /** @var Resource\GroupPolicy|null $oPolicy */
+ $oPolicy = $this->getAll([
+ new Where('group_id', $iGroupId),
+ ])[0] ?? null;
+
+ return $oPolicy;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getModeForGroup(int $iGroupId): string
+ {
+ $oPolicy = $this->getByGroupId($iGroupId);
+
+ return $oPolicy->mode ?? static::MODE_DISABLED;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function setModeForGroup(int $iGroupId, string $sMode): Resource\GroupPolicy
+ {
+ if (!array_key_exists($sMode, static::modes())) {
+ throw new ModelException('Invalid MFA group policy mode: ' . $sMode);
+ }
+
+ $oPolicy = $this->getByGroupId($iGroupId);
+
+ if ($oPolicy) {
+ $this->update($oPolicy->id, ['mode' => $sMode]);
+ /** @var Resource\GroupPolicy $oUpdated */
+ $oUpdated = $this->getById($oPolicy->id);
+ return $oUpdated;
+ }
+
+ /** @var Resource\GroupPolicy $oCreated */
+ $oCreated = $this->create([
+ 'group_id' => $iGroupId,
+ 'mode' => $sMode,
+ ], true);
+
+ return $oCreated;
+ }
+}
diff --git a/src/Model/UserMethod.php b/src/Model/UserMethod.php
new file mode 100644
index 0000000..b734b8f
--- /dev/null
+++ b/src/Model/UserMethod.php
@@ -0,0 +1,99 @@
+hasOne('user', 'User', Auth\Constants::MODULE_SLUG);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return Resource\UserMethod[]
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getByUserId(int $iUserId): array
+ {
+ return $this->getAll([
+ new Where('user_id', $iUserId),
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getByUserAndDriver(int $iUserId, string $sDriver): ?Resource\UserMethod
+ {
+ /** @var Resource\UserMethod|null $oMethod */
+ $oMethod = $this->getAll([
+ new Where('user_id', $iUserId),
+ new Where('driver', $sDriver),
+ ])[0] ?? null;
+
+ return $oMethod;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getDefaultForUser(int $iUserId): ?Resource\UserMethod
+ {
+ $aMethods = $this->getByUserId($iUserId);
+
+ foreach ($aMethods as $oMethod) {
+ if ($oMethod->is_default) {
+ return $oMethod;
+ }
+ }
+
+ return $aMethods[0] ?? null;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function setDefault(int $iUserId, string $sDriver): void
+ {
+ /** @var Database $oDb */
+ $oDb = Factory::service('Database');
+ $sTable = $this->getTableName();
+
+ $oDb->where('user_id', $iUserId);
+ $oDb->update($sTable, ['is_default' => 0]);
+
+ $oDb->where('user_id', $iUserId);
+ $oDb->where('driver', $sDriver);
+ $oDb->update($sTable, ['is_default' => 1]);
+ }
+}
diff --git a/src/Resource/GroupPolicy.php b/src/Resource/GroupPolicy.php
new file mode 100644
index 0000000..5881ff7
--- /dev/null
+++ b/src/Resource/GroupPolicy.php
@@ -0,0 +1,14 @@
+data === null || $this->data === '') {
+ return (object) [];
+ }
+
+ /** @var Encrypt $oEncrypt */
+ $oEncrypt = Factory::service('Encrypt');
+
+ return (object) json_decode($oEncrypt::decode($this->data), false);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws EnvironmentException
+ * @throws FactoryException
+ */
+ public function setDecodedData(stdClass $oData): self
+ {
+ /** @var Encrypt $oEncrypt */
+ $oEncrypt = Factory::service('Encrypt');
+ $this->data = $oEncrypt::encode(json_encode($oData) ?: '{}');
+
+ if ($this->id) {
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+ $oModel->update($this->id, ['data' => $this->data]);
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Routes.php b/src/Routes.php
index 34ebb29..f48ebc3 100644
--- a/src/Routes.php
+++ b/src/Routes.php
@@ -1,15 +1,5 @@
'mfa/index',
+ 'mfa' => 'mfa/index',
+ 'mfa/manage' => 'mfa/manage',
];
}
}
diff --git a/src/Service/MultiFactorAuth.php b/src/Service/MultiFactorAuth.php
index 3c93cff..f16e184 100644
--- a/src/Service/MultiFactorAuth.php
+++ b/src/Service/MultiFactorAuth.php
@@ -6,12 +6,14 @@
use Nails\Auth;
use Nails\Auth\Model\User\Password;
use Nails\Auth\Resource\User;
-use Nails\Common\Exception\Encrypt\DecodeException;
use Nails\Common\Exception\EnvironmentException;
use Nails\Common\Exception\FactoryException;
use Nails\Common\Exception\ModelException;
use Nails\Common\Exception\NailsException;
use Nails\Common\Factory\Component;
+use Nails\Common\Helper\Model\Limit;
+use Nails\Common\Helper\Model\Sort;
+use Nails\Common\Helper\Model\Where;
use Nails\Common\Helper\Strings;
use Nails\Common\Service\Cookie;
use Nails\Common\Service\Database;
@@ -26,19 +28,26 @@
use Nails\MFA\Exception\TokenMintLimitException;
use Nails\MFA\Resource\Token;
use ReflectionException;
+use stdClass;
use Throwable;
class MultiFactorAuth
{
const int TOKEN_TTL = 300;
+ const int TOKEN_REUSE_MIN_TTL = 30;
const int MAX_VERIFICATION_ATTEMPTS = 5;
+ const int MAX_RESENDS_PER_TOKEN = 3;
const int MAX_TOKEN_MINTS_PER_HOUR = 5;
const string MFA_URL = 'mfa';
const string MFA_COOKIE_TOKEN_KEY = 'mfa-token';
const string MFA_COOKIE_IS_PRIVILEGED_KEY = 'mfa-is-privileged';
const int MFA_COOKIE_IS_PRIVILEGED_TTL = 1209600; // 14 days
- const string TOKEN_DATA_KEY_RETURN_TO = 'return_to';
- const string TOKEN_DATA_KEY_IS_REMEMBERED = 'is_remembered';
+ const string TOKEN_DATA_KEY_RETURN_TO = 'return_to';
+ const string TOKEN_DATA_KEY_IS_REMEMBERED = 'is_remembered';
+ const string TOKEN_DATA_KEY_DRIVER = 'driver';
+ const string TOKEN_DATA_KEY_PENDING_SETUP = 'pending_setup';
+ const string TOKEN_DATA_KEY_IS_SETUP = 'is_setup';
+ const string TOKEN_DATA_KEY_RESENDS = 'resends';
// --------------------------------------------------------------------------
@@ -125,13 +134,123 @@ public function isAuthenticated(): bool
public function requiresAuthentication(): bool
{
- $bResult = !$this->isAuthenticated() && !wasAdmin();
+ $bResult = !$this->isAuthenticated()
+ && !wasAdmin()
+ && isLoggedIn()
+ && $this->userRequiresChallenge(activeUser());
+
$this->oLogger->info('Requires Authentication: ' . json_encode($bResult));
return $bResult;
}
// --------------------------------------------------------------------------
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function userRequiresChallenge(User $oUser): bool
+ {
+ $sMode = $this->getGroupMode($oUser);
+
+ if ($sMode === MFA\Model\GroupPolicy::MODE_DISABLED) {
+ return false;
+ }
+
+ if ($sMode === MFA\Model\GroupPolicy::MODE_OPTIONAL && empty($this->getUserMethods($oUser))) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getGroupMode(User $oUser): string
+ {
+ /** @var MFA\Model\GroupPolicy $oPolicyModel */
+ $oPolicyModel = Factory::model('GroupPolicy', Constants::MODULE_SLUG);
+
+ return $oPolicyModel->getModeForGroup((int) $oUser->group_id);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether the user's group allows them to manage MFA methods.
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function userCanManageMethods(User $oUser): bool
+ {
+ $sMode = $this->getGroupMode($oUser);
+
+ return $sMode === MFA\Model\GroupPolicy::MODE_OPTIONAL
+ || $sMode === MFA\Model\GroupPolicy::MODE_REQUIRED;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether the user has anything they can change on the management screen:
+ * adding a method, removing one, or choosing a different default.
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function userCanConfigureMethods(User $oUser): bool
+ {
+ if (!$this->userCanManageMethods($oUser)) {
+ return false;
+ }
+
+ $aMethods = $this->getUserMethods($oUser);
+
+ // Several methods can be removed, or have the default moved between them
+ if (count($aMethods) > 1) {
+ return true;
+ }
+
+ foreach ($aMethods as $oMethod) {
+ if ($this->userCanRemoveMethod($oUser, (string) $oMethod->driver)) {
+ return true;
+ }
+ }
+
+ try {
+ return !empty($this->getSetupDrivers($oUser));
+ } catch (MFA\Exception\MfaException $e) {
+ // With no enabled drivers there is nothing new to set up
+ return false;
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether a method can be given up: a user cannot be left with none while
+ * their group requires MFA.
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function userCanRemoveMethod(User $oUser, string $sDriver): bool
+ {
+ if (!$this->getUserMethod($oUser, $sDriver)) {
+ return false;
+ }
+
+ return count($this->getUserMethods($oUser)) > 1
+ || $this->getGroupMode($oUser) !== MFA\Model\GroupPolicy::MODE_REQUIRED;
+ }
+
+ // --------------------------------------------------------------------------
+
/**
* @throws FactoryException
* @throws ModelException
@@ -188,6 +307,32 @@ private function generateToken(User $oUser, bool $bIsRemembered, string $sIp): T
[$oUser->id]
);
+ /**
+ * A user only ever needs one outstanding challenge, so an abandoned
+ * one is handed back rather than minting another. Without this, simply
+ * signing in again consumes the hourly allowance and locks the user
+ * out of their own account. The expiry is deliberately not extended.
+ */
+ $oExisting = $this->getLiveToken($oUser);
+
+ if ($oExisting) {
+
+ $this->oLogger->info(sprintf(
+ 'Reusing live token with ID %s',
+ $oExisting->id,
+ ));
+
+ $this->oLogger->info(sprintf(
+ 'Setting token data; %s',
+ json_encode($oData)
+ ));
+
+ $oExisting->setData($oData);
+ $oDb->transaction()->commit();
+
+ return $oExisting;
+ }
+
$oDb->where('user_id', $oUser->id);
$oDb->where('created >=', 'DATE_SUB(NOW(), INTERVAL 1 HOUR)', false);
@@ -234,8 +379,37 @@ private function generateToken(User $oUser, bool $bIsRemembered, string $sIp): T
// --------------------------------------------------------------------------
/**
- * @throws DecodeException
- * @throws EnvironmentException
+ * The user's outstanding challenge, if they have one which is still worth
+ * completing, i.e. it has enough life left in it and has not had its
+ * verification attempts exhausted.
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ protected function getLiveToken(User $oUser): ?Token
+ {
+ /** @var MFA\Model\Token $oTokenModel */
+ $oTokenModel = Factory::model('Token', Constants::MODULE_SLUG);
+
+ /** @var \DateTime $oThreshold */
+ $oThreshold = Factory::factory('DateTime');
+ $oThreshold->add(new \DateInterval(sprintf('PT%dS', static::TOKEN_REUSE_MIN_TTL)));
+
+ /** @var Token|null $oToken */
+ $oToken = $oTokenModel->getAll([
+ new Where('user_id', $oUser->id),
+ new Where('expires >', $oThreshold->format('Y-m-d H:i:s')),
+ new Where('attempts <', static::MAX_VERIFICATION_ATTEMPTS),
+ new Sort('id', Sort::DESC),
+ new Limit(1),
+ ])[0] ?? null;
+
+ return $oToken;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
* @throws FactoryException
* @throws ModelException
* @throws TokenException
@@ -251,10 +425,16 @@ public function getToken(string $sEncryptedToken): Token
/** @var MFA\Model\Token $oTokenModel */
$oTokenModel = Factory::model('Token', Constants::MODULE_SLUG);
- /** @var Encrypt $oEncrypt */
- $oEncrypt = Factory::service('Encrypt');
- $sDecryptedToken = $oEncrypt::decode($sEncryptedToken);
+ $sDecryptedToken = $this->decryptUntrustedValue(
+ $sEncryptedToken,
+ sprintf('the "%s" cookie', static::MFA_COOKIE_TOKEN_KEY)
+ );
+
+ if ($sDecryptedToken === null) {
+ throw new TokenException('Token could not be decrypted');
+ }
+
[$sSalt, $sToken] = array_pad(explode(Token::DELIMITER, $sDecryptedToken), 2, null);
if (empty($sSalt) || empty($sToken)) {
@@ -282,8 +462,6 @@ public function getToken(string $sEncryptedToken): Token
// --------------------------------------------------------------------------
/**
- * @throws DecodeException
- * @throws EnvironmentException
* @throws FactoryException
* @throws ModelException
* @throws TokenException
@@ -305,6 +483,28 @@ public function getTokenFromCookie(): Token
// --------------------------------------------------------------------------
+ /**
+ * Claims one of the challenge's replacement codes, if any are left. The
+ * allowance exists because each one sends the user a message they did not
+ * necessarily ask for.
+ */
+ public function claimResend(Token $oToken): bool
+ {
+ $iResends = (int) $oToken->getData(static::TOKEN_DATA_KEY_RESENDS);
+
+ if ($iResends >= static::MAX_RESENDS_PER_TOKEN) {
+ return false;
+ }
+
+ $oToken->setData((object) [
+ static::TOKEN_DATA_KEY_RESENDS => $iResends + 1,
+ ]);
+
+ return true;
+ }
+
+ // --------------------------------------------------------------------------
+
/**
* Atomically claims a verification attempt and invalidates exhausted tokens.
* This must be called before the driver compares the code. Recording only
@@ -390,12 +590,58 @@ public function clearTokenCookie(): void
// --------------------------------------------------------------------------
+ /**
+ * @throws FactoryException
+ */
+ public function clearIsPrivilegedCookie(): void
+ {
+ /** @var Cookie $oCookie */
+ $oCookie = Factory::service('Cookie');
+ $oCookie->delete(static::MFA_COOKIE_IS_PRIVILEGED_KEY, '/');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Decrypts a value which originated from user input, e.g. a cookie.
+ *
+ * Such values are routinely truncated, tampered with, or encrypted using a
+ * since-rotated key; the crypto library treats all of those as exceptions.
+ * They are not exceptional here, so return null and let the caller decide
+ * how to recover. Callers must treat null as "no valid value".
+ */
+ protected function decryptUntrustedValue(string $sCipher, string $sDescription): ?string
+ {
+ if ($sCipher === '') {
+ return null;
+ }
+
+ /** @var Encrypt $oEncrypt */
+ $oEncrypt = Factory::service('Encrypt');
+
+ try {
+ return $oEncrypt::decode($sCipher);
+
+ } catch (Throwable $e) {
+ $this->oLogger->warning(sprintf(
+ 'Could not decrypt %s; treating it as absent: [%s] %s',
+ $sDescription,
+ $e::class,
+ $e->getMessage()
+ ));
+
+ return null;
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
/**
* @return MFA\Interfaces\Authentication\Driver[]
* @throws NailsException
* @throws MFA\Exception\MfaException
*/
- public function getAuthenticationMethods(User $oUser): array
+ public function getEnabledDrivers(): array
{
/** @var AuthenticationDriver $oService */
$oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
@@ -407,8 +653,6 @@ public function getAuthenticationMethods(User $oUser): array
$aDrivers[] = $oService->getInstance($oComponent);
}
- // @todo (Pablo 2023-02-22) - filter out drivers which are not configured for this user
-
if (empty($aDrivers)) {
throw new MFA\Exception\MfaException('No MFA drivers are available');
}
@@ -419,16 +663,367 @@ public function getAuthenticationMethods(User $oUser): array
// --------------------------------------------------------------------------
/**
- * @throws DecodeException
- * @throws EnvironmentException
+ * Enabled drivers the user has enrolled in.
+ *
+ * @return MFA\Interfaces\Authentication\Driver[]
+ * @throws NailsException
+ * @throws MFA\Exception\MfaException
+ */
+ public function getAuthenticationMethods(User $oUser): array
+ {
+ $aEnrolledSlugs = array_map(
+ fn(MFA\Resource\UserMethod $oMethod) => $oMethod->driver,
+ $this->getUserMethods($oUser)
+ );
+
+ $aDrivers = [];
+ foreach ($this->getEnabledDrivers() as $oDriver) {
+ $sSlug = $oDriver->getSlug();
+ if (in_array($sSlug, $aEnrolledSlugs, true)) {
+ $aDrivers[] = $oDriver;
+ }
+ }
+
+ return $aDrivers;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Enabled drivers the user can still enroll in.
+ *
+ * @return MFA\Interfaces\Authentication\Driver[]
+ * @throws NailsException
+ */
+ public function getSetupDrivers(User $oUser): array
+ {
+ $aEnrolledSlugs = array_map(
+ fn(MFA\Resource\UserMethod $oMethod) => $oMethod->driver,
+ $this->getUserMethods($oUser)
+ );
+
+ $aDrivers = [];
+ foreach ($this->getEnabledDrivers() as $oDriver) {
+ if (!in_array($oDriver->getSlug(), $aEnrolledSlugs, true)) {
+ $aDrivers[] = $oDriver;
+ }
+ }
+
+ return $aDrivers;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws NailsException
+ * @throws MFA\Exception\MfaException
+ */
+ public function getDriverBySlug(string $sSlug): MFA\Interfaces\Authentication\Driver
+ {
+ /** @var AuthenticationDriver $oService */
+ $oService = Factory::service('AuthenticationDriver', Constants::MODULE_SLUG);
+ $oDriver = $oService->getInstance($sSlug);
+
+ if (empty($oDriver)) {
+ throw new MFA\Exception\MfaException('Unknown MFA driver: ' . $sSlug);
+ }
+
+ return $oDriver;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return MFA\Resource\UserMethod[]
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getUserMethods(User $oUser): array
+ {
+ /** @var MFA\Model\UserMethod $oModel */
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+
+ return $oModel->getByUserId((int) $oUser->id);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getUserMethod(User $oUser, string $sDriver): ?MFA\Resource\UserMethod
+ {
+ /** @var MFA\Model\UserMethod $oModel */
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+
+ return $oModel->getByUserAndDriver((int) $oUser->id, $sDriver);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getDefaultUserMethod(User $oUser): ?MFA\Resource\UserMethod
+ {
+ /** @var MFA\Model\UserMethod $oModel */
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+
+ return $oModel->getDefaultForUser((int) $oUser->id);
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function userNeedsSetup(User $oUser): bool
+ {
+ return $this->getGroupMode($oUser) === MFA\Model\GroupPolicy::MODE_REQUIRED
+ && empty($this->getUserMethods($oUser));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ * @throws NailsException
+ */
+ public function selectDriver(User $oUser, Token $oToken): ?MFA\Interfaces\Authentication\Driver
+ {
+ $sSelected = $oToken->getData(static::TOKEN_DATA_KEY_DRIVER);
+ if (is_string($sSelected) && $sSelected !== '') {
+ if ($oToken->getData(static::TOKEN_DATA_KEY_IS_SETUP)) {
+ foreach ($this->getSetupDrivers($oUser) as $oDriver) {
+ if ($oDriver->getSlug() === $sSelected) {
+ return $oDriver;
+ }
+ }
+ }
+
+ foreach ($this->getAuthenticationMethods($oUser) as $oDriver) {
+ if ($oDriver->getSlug() === $sSelected) {
+ return $oDriver;
+ }
+ }
+ }
+
+ $oDefault = $this->getDefaultUserMethod($oUser);
+ if ($oDefault) {
+ try {
+ $oDriver = $this->getDriverBySlug((string) $oDefault->driver);
+ foreach ($this->getAuthenticationMethods($oUser) as $oEnabled) {
+ if ($oEnabled->getSlug() === $oDriver->getSlug()) {
+ $oToken->setData((object) [
+ static::TOKEN_DATA_KEY_DRIVER => $oDriver->getSlug(),
+ ]);
+ return $oDriver;
+ }
+ }
+ } catch (MFA\Exception\MfaException $e) {
+ $this->oLogger->info('Default MFA driver is not enabled: ' . $e->getMessage());
+ }
+ }
+
+ $aMethods = $this->getAuthenticationMethods($oUser);
+ if (empty($aMethods)) {
+ return null;
+ }
+
+ $oDriver = reset($aMethods);
+ $oToken->setData((object) [
+ static::TOKEN_DATA_KEY_DRIVER => $oDriver->getSlug(),
+ ]);
+
+ return $oDriver;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function enrollMethod(User $oUser, string $sDriver, stdClass $oData, bool $bMakeDefault = false): MFA\Resource\UserMethod
+ {
+ /** @var MFA\Model\UserMethod $oModel */
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+ $aExisting = $this->getUserMethods($oUser);
+ $bIsDefault = $bMakeDefault || empty($aExisting);
+
+ $oExisting = $this->getUserMethod($oUser, $sDriver);
+ if ($oExisting) {
+ $oExisting->setDecodedData($oData);
+ if ($bIsDefault) {
+ $oModel->setDefault((int) $oUser->id, $sDriver);
+ }
+ /** @var MFA\Resource\UserMethod $oUpdated */
+ $oUpdated = $oModel->getById($oExisting->id);
+ return $oUpdated;
+ }
+
+ /** @var Encrypt $oEncrypt */
+ $oEncrypt = Factory::service('Encrypt');
+
+ /** @var MFA\Resource\UserMethod $oMethod */
+ $oMethod = $oModel->create([
+ 'user_id' => $oUser->id,
+ 'driver' => $sDriver,
+ 'is_default' => $bIsDefault ? 1 : 0,
+ 'data' => $oEncrypt::encode(json_encode($oData) ?: '{}'),
+ ], true);
+
+ if ($bIsDefault) {
+ $oModel->setDefault((int) $oUser->id, $sDriver);
+ }
+
+ $this->oLogger->info(sprintf(
+ 'Enrolled MFA method %s for user %s',
+ $sDriver,
+ $oUser->id
+ ));
+
+ return $oMethod;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function setDefaultMethod(User $oUser, string $sDriver): void
+ {
+ if (!$this->getUserMethod($oUser, $sDriver)) {
+ throw new MFA\Exception\MfaException('That method is not enrolled.');
+ }
+
+ /** @var MFA\Model\UserMethod $oModel */
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+ $oModel->setDefault((int) $oUser->id, $sDriver);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ * @throws ModelException
+ * @throws NailsException
+ */
+ public function removeMethod(User $oUser, string $sDriver, bool $bAllowLastRequired = false): void
+ {
+ $oMethod = $this->getUserMethod($oUser, $sDriver);
+ if (!$oMethod) {
+ throw new MFA\Exception\MfaException('That method is not enrolled.');
+ }
+
+ if (!$bAllowLastRequired && !$this->userCanRemoveMethod($oUser, $sDriver)) {
+ throw new MFA\Exception\MfaException(
+ 'You cannot remove your last verification method while MFA is required for your account.'
+ );
+ }
+
+ $oDriver = $this->getDriverBySlug($sDriver);
+ $oDriver->reset($oUser, $oMethod);
+
+ /** @var MFA\Model\UserMethod $oModel */
+ $oModel = Factory::model('UserMethod', Constants::MODULE_SLUG);
+ $oModel->delete($oMethod->id);
+
+ $aRemaining = $this->getUserMethods($oUser);
+ if ($oMethod->is_default && !empty($aRemaining)) {
+ $oModel->setDefault((int) $oUser->id, (string) $aRemaining[0]->driver);
+ }
+
+ $this->oLogger->info(sprintf(
+ 'Removed MFA method %s for user %s',
+ $sDriver,
+ $oUser->id
+ ));
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function setPendingSetup(Token $oToken, string $sDriver, stdClass $oPending): void
+ {
+ /** @var Encrypt $oEncrypt */
+ $oEncrypt = Factory::service('Encrypt');
+
+ $oToken->setData((object) [
+ static::TOKEN_DATA_KEY_DRIVER => $sDriver,
+ static::TOKEN_DATA_KEY_PENDING_SETUP => $oEncrypt::encode(json_encode($oPending) ?: '{}'),
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function getPendingSetup(Token $oToken): ?stdClass
+ {
+ $sCipher = $oToken->getData(static::TOKEN_DATA_KEY_PENDING_SETUP);
+ if (empty($sCipher) || !is_string($sCipher)) {
+ return null;
+ }
+
+ $sPending = $this->decryptUntrustedValue($sCipher, 'the pending MFA setup payload');
+
+ if ($sPending === null) {
+ // The user restarts setup rather than being shown an error
+ return null;
+ }
+
+ return (object) json_decode($sPending, false);
+ }
+
+ // --------------------------------------------------------------------------
+
+ public function clearPendingSetup(Token $oToken): void
+ {
+ $oToken->setData((object) [
+ static::TOKEN_DATA_KEY_PENDING_SETUP => null,
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ public function completeChallenge(Token $oToken, bool $bRememberDevice): string
+ {
+ /** @var MFA\Model\Token $oTokenModel */
+ $oTokenModel = Factory::model('Token', Constants::MODULE_SLUG);
+ /** @var Auth\Service\Authentication $oAuthenticationService */
+ $oAuthenticationService = Factory::service('Authentication', Auth\Constants::MODULE_SLUG);
+ /** @var Auth\Model\User $oUserModel */
+ $oUserModel = Factory::model('User', Auth\Constants::MODULE_SLUG);
+
+ $this->setIsPrivileged($oToken->user(), $bRememberDevice);
+ if ($oToken->id) {
+ $oTokenModel->delete($oToken->id);
+ }
+ $this->clearTokenCookie();
+ $oAuthenticationService->login($oToken->user());
+
+ if ($oToken->getData(static::TOKEN_DATA_KEY_IS_REMEMBERED)) {
+ $oUserModel->setRememberCookie(
+ $oToken->user()->id,
+ $oToken->user()->password,
+ $oToken->user()->email
+ );
+ }
+
+ return $oToken->getData(static::TOKEN_DATA_KEY_RETURN_TO) ?: siteUrl();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
* @throws FactoryException
*/
public function isPrivileged(): bool
{
/** @var Cookie $oCookie */
$oCookie = Factory::service('Cookie');
- /** @var Encrypt $oEncrypt */
- $oEncrypt = Factory::service('Encrypt');
$this->oLogger->info(sprintf(
'Checking if active user is privileged; active user %s',
@@ -442,9 +1037,19 @@ public function isPrivileged(): bool
return false;
}
- $sStoredHash = $oEncrypt::decode($oStoredHashEncrypted->value);
+ $sStoredHash = $this->decryptUntrustedValue(
+ (string) $oStoredHashEncrypted->value,
+ sprintf('the "%s" cookie', static::MFA_COOKIE_IS_PRIVILEGED_KEY)
+ );
+
+ if ($sStoredHash === null) {
+ // Discard the unusable cookie so the user is challenged rather than
+ // presented with the same error on every subsequent request
+ $this->clearIsPrivilegedCookie();
+ return false;
+ }
- $bResult = isLoggedIn() && $sActiveUserHash === $sStoredHash;
+ $bResult = isLoggedIn() && hash_equals($sActiveUserHash, $sStoredHash);
$this->oLogger->info('User is privileged: ' . json_encode($bResult));
@@ -479,9 +1084,13 @@ public function setIsPrivileged(User $oUser, bool $bRemember = true): self
$sKey,
$oEncrypt::encode($sValue),
$bRemember
- ? static::MFA_COOKIE_IS_PRIVILEGED_TTL
+ ? $this->getTrustedDeviceTtl()
: null,
- '/'
+ '/',
+ '',
+ true,
+ true,
+ 'Lax'
);
return $this;
@@ -489,6 +1098,35 @@ public function setIsPrivileged(User $oUser, bool $bRemember = true): self
// --------------------------------------------------------------------------
+ /**
+ * How long a device stays trusted, in seconds; override with the
+ * MFA_TRUSTED_DEVICE_TTL config property.
+ */
+ public function getTrustedDeviceTtl(): int
+ {
+ $iTtl = (int) Config::get(
+ 'MFA_TRUSTED_DEVICE_TTL',
+ static::MFA_COOKIE_IS_PRIVILEGED_TTL
+ );
+
+ return $iTtl > 0
+ ? $iTtl
+ : static::MFA_COOKIE_IS_PRIVILEGED_TTL;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether a trusted device stays trusted after the user signs out; override
+ * with the MFA_TRUST_SURVIVES_LOGOUT config property.
+ */
+ public function trustSurvivesLogout(): bool
+ {
+ return (bool) Config::get('MFA_TRUST_SURVIVES_LOGOUT', false);
+ }
+
+ // --------------------------------------------------------------------------
+
protected function getIsPrivilegedHash(User $oUser): string
{
return sha1(Config::get('PRIVATE_KEY') . $oUser->id . $oUser->salt);
diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php
new file mode 100644
index 0000000..c8f8e5d
--- /dev/null
+++ b/tests/ConsoleCommandsTest.php
@@ -0,0 +1,48 @@
+, string}>
+ */
+ public static function commands(): array
+ {
+ return [
+ 'config' => [Config::class, 'mfa:config'],
+ 'driver disable' => [Disable::class, 'mfa:driver:disable'],
+ 'driver enable' => [Enable::class, 'mfa:driver:enable'],
+ 'driver setting' => [Setting::class, 'mfa:driver:setting'],
+ 'group policy' => [Policy::class, 'mfa:group:policy'],
+ 'method add' => [Add::class, 'mfa:user:method:add'],
+ 'method default' => [DefaultMethod::class, 'mfa:user:method:default'],
+ 'method remove' => [Remove::class, 'mfa:user:method:remove'],
+ 'user status' => [Status::class, 'mfa:user:status'],
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @param class-string $sClass
+ */
+ #[DataProvider('commands')]
+ public function testCommandName(string $sClass, string $sExpected): void
+ {
+ self::assertSame($sExpected, (new $sClass())->getName());
+ }
+}
diff --git a/tests/RoutesTest.php b/tests/RoutesTest.php
index 61e863a..73b93c2 100644
--- a/tests/RoutesTest.php
+++ b/tests/RoutesTest.php
@@ -10,7 +10,10 @@ final class RoutesTest extends TestCase
public function testTokenIsNotPartOfTheRoute(): void
{
self::assertSame(
- ['mfa' => 'mfa/index'],
+ [
+ 'mfa' => 'mfa/index',
+ 'mfa/manage' => 'mfa/manage',
+ ],
Routes::generate()
);
}