diff --git a/README.md b/README.md index a05e053..dbf0aba 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,38 @@ [![tests](https://github.com/nails/module-multi-factor-auth/actions/workflows/build_and_test.yml/badge.svg )](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.', + '
', + ]), +]); diff --git a/admin/views/User/tabs/mfa.php b/admin/views/User/tabs/mfa.php new file mode 100644 index 0000000..0630563 --- /dev/null +++ b/admin/views/User/tabs/mfa.php @@ -0,0 +1,68 @@ + $aModes + * @var \Nails\MFA\Resource\UserMethod[] $aMethods + * @var array $aDriverLabels + */ + +$fnLabel = static fn(string $sDriver): string => $aDriverLabels[$sDriver] ?? $sDriver; + +echo form_field([ + 'key' => 'mfa_group_policy', + 'label' => 'Group Policy', + 'default' => $aModes[$sGroupMode] ?? $sGroupMode, + 'readonly' => true, + 'info' => 'Inherited from the user\'s group; change it on the group itself.', +]); + +if (empty($aMethods)) { + + echo form_field([ + 'key' => 'mfa_methods', + 'label' => 'Enrolled Methods', + 'default' => 'None', + 'readonly' => true, + 'info' => implode('', [ + '
', + 'This user must enrol a method themselves; secrets and QR codes are never shown here.', + '
', + ]), + ]); + +} else { + + echo form_field_radio([ + 'key' => 'mfa_default_driver', + 'label' => 'Default Method', + 'options' => array_values(array_map( + static fn($oMethod) => [ + 'value' => (string) $oMethod->driver, + 'label' => $fnLabel((string) $oMethod->driver), + 'selected' => (bool) $oMethod->is_default, + ], + $aMethods + )), + 'info' => 'The method the user is challenged with by default.', + ]); + + echo form_field_checkbox([ + 'key' => 'mfa_reset_driver[]', + 'label' => 'Reset Methods', + 'options' => array_values(array_map( + static fn($oMethod) => [ + 'value' => (string) $oMethod->driver, + 'label' => $fnLabel((string) $oMethod->driver), + ], + $aMethods + )), + 'info' => implode('', [ + '
', + 'Resetting removes the enrolment; the user must set the method up again. ', + 'Secrets and QR codes are never shown here.', + '
', + ]), + ]); +} diff --git a/mfa/controllers/Mfa.php b/mfa/controllers/Mfa.php index 8c4a6d5..f8f527d 100644 --- a/mfa/controllers/Mfa.php +++ b/mfa/controllers/Mfa.php @@ -22,6 +22,8 @@ class Mfa extends Controller\Base { + private const SESSION_PENDING_SETUP = 'mfa-manage-pending'; + private Logger $oLogger; // -------------------------------------------------------------------------- @@ -63,10 +65,6 @@ public function index() $oMfaService = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG); /** @var 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); // -------------------------------------------------------------------------- @@ -77,48 +75,45 @@ public function index() } $oToken = $oMfaService->getTokenFromCookie(); - - $oDriver = $this->selectDriver( - $oMfaService->getAuthenticationMethods( - $oToken->user() - ) - ); + $oUser = $oToken->user(); if ($oInput::post('action') === 'verify') { $this->log('User is attempting to verify'); + $oDriver = $oMfaService->selectDriver($oUser, $oToken); + if (!$oDriver) { + throw new Exception\MfaException('No MFA driver is available for this account.'); + } + try { - // Claim the attempt before comparing the code so concurrent - // requests cannot all pass validation before being counted. $oMfaService->registerFailedAttempt($oToken); - $oDriver->validate($oToken, $oInput::post('code')); - $oMfaService->setIsPrivileged($oToken->user(), (bool) $oInput::post('remember')); - $oTokenModel->delete($oToken->id); - $oMfaService->clearTokenCookie(); - $oAuthenticationService->login($oToken->user()); - - if ($oToken->getData($oMfaService::TOKEN_DATA_KEY_IS_REMEMBERED)) { - $oUserModel->setRememberCookie( - $oToken->user()->id, - $oToken->user()->password, - $oToken->user()->email + $oDriver->validate($oToken, (string) $oInput::post('code')); + + if ( + $oToken->getData($oMfaService::TOKEN_DATA_KEY_IS_SETUP) + && !$oMfaService->getUserMethod($oUser, (string) $oDriver->getSlug()) + ) { + $oMfaService->enrollMethod( + $oUser, + (string) $oDriver->getSlug(), + $oDriver->setupComplete($oUser, '', (object) []), + true ); } - $sRedirectUrl = $oToken->getData( - $oMfaService::TOKEN_DATA_KEY_RETURN_TO - ) ?: siteUrl(); + $sRedirectUrl = $oMfaService->completeChallenge( + $oToken, + (bool) $oInput::post('remember') + ); $this->log(sprintf( 'User verified successfully, redirecting to "%s"', $sRedirectUrl )); - redirect( - $sRedirectUrl - ); + redirect($sRedirectUrl); } catch (Exception\InvalidCodeException $e) { $this->log(sprintf( @@ -127,23 +122,113 @@ public function index() $e->getMessage() )); $oUserFeedback->error($e->getMessage()); - $this->renderForm($oDriver, $oToken); + $this->renderForm($oDriver, $oToken, $oMfaService); } - } elseif ($oInput::post('action') === 'restart') { - $this->log('User is restarting authentication'); - $oTokenModel->delete($oToken->id); - $oMfaService->clearTokenCookie(); - $oMfaService->authenticate( - $oToken->user(), - $oToken->getData($oMfaService::TOKEN_DATA_KEY_IS_REMEMBERED), - true - ); + } elseif ($oInput::post('action') === 'resend') { + + $this->log('User requested another verification code'); + + /** + * The challenge is kept intact; discarding it here would lose the + * driver the user picked (and any setup in progress), and would + * spend one of their hourly challenges on a resend. + */ + $oDriver = $oMfaService->selectDriver($oUser, $oToken); + if (!$oDriver) { + throw new Exception\MfaException('No MFA driver is available for this account.'); + } + + if (!$oDriver->canTryAgain()) { + $oUserFeedback->error('This verification method cannot issue another code.'); + + } elseif ($oMfaService->claimResend($oToken)) { + $oDriver->resend($oToken, $oUserFeedback); + + } else { + $this->log('Resend allowance for this token is exhausted'); + $oUserFeedback->error( + 'You have requested too many codes. Please use the most recent one, or sign in again.' + ); + } + + $this->renderForm($oDriver, $oToken, $oMfaService); + $oDriver->postForm($oToken); + + } elseif ($oInput::post('action') === 'setup_back') { + + $this->log('User is choosing a different MFA setup method'); + $oMfaService->clearPendingSetup($oToken); + $oToken->setData((object) [ + $oMfaService::TOKEN_DATA_KEY_DRIVER => null, + $oMfaService::TOKEN_DATA_KEY_IS_SETUP => true, + ]); + $this->renderSetup($oToken, $oMfaService); + + } elseif ($oInput::post('action') === 'setup_cancel') { + + $this->cancelSetup($oToken, $oMfaService, $oTokenModel, $oUserFeedback); + + } elseif ($oInput::post('action') === 'switch') { + + $sSlug = (string) $oInput::post('driver'); + $this->log('User is switching MFA method to ' . $sSlug); + + $oSwitched = null; + foreach ($oMfaService->getAuthenticationMethods($oUser) as $oCandidate) { + if ($oCandidate->getSlug() === $sSlug) { + $oSwitched = $oCandidate; + break; + } + } + + if (!$oSwitched) { + $oUserFeedback->error('That verification method is not available.'); + $oDriver = $oMfaService->selectDriver($oUser, $oToken); + if ($oDriver) { + $this->renderForm($oDriver, $oToken, $oMfaService); + } else { + $this->renderSetup($oToken, $oMfaService); + } + return; + } + + $oToken->setData((object) [ + $oMfaService::TOKEN_DATA_KEY_DRIVER => $oSwitched->getSlug(), + ]); + $oSwitched->preForm($oToken, $oUserFeedback); + $this->renderForm($oSwitched, $oToken, $oMfaService); + $oSwitched->postForm($oToken); + + } elseif ($oInput::post('action') === 'setup_choose') { + + $this->handleSetupChoose($oToken, $oMfaService, $oUserFeedback); + + } elseif ($oInput::post('action') === 'setup_confirm') { + + $this->handleSetupConfirm($oToken, $oMfaService, $oUserFeedback, $oInput); } else { + if ($oMfaService->userNeedsSetup($oUser) || $oMfaService->getPendingSetup($oToken)) { + if ($oMfaService->getPendingSetup($oToken)) { + $sSlug = (string) $oToken->getData($oMfaService::TOKEN_DATA_KEY_DRIVER); + $oDriver = $oMfaService->getDriverBySlug($sSlug); + $this->renderSetupConfirm($oDriver, $oToken, $oMfaService); + } else { + $this->renderSetup($oToken, $oMfaService); + } + return; + } + + $oDriver = $oMfaService->selectDriver($oUser, $oToken); + if (!$oDriver) { + $this->renderSetup($oToken, $oMfaService); + return; + } + $oDriver->preForm($oToken, $oUserFeedback); - $this->renderForm($oDriver, $oToken); + $this->renderForm($oDriver, $oToken, $oMfaService); $oDriver->postForm($oToken); } @@ -190,6 +275,293 @@ public function index() // -------------------------------------------------------------------------- + public function manage(): void + { + if (!isLoggedIn()) { + unauthorised('Please log in to manage your verification methods.'); + } + + /** @var MultiFactorAuth $oMfaService */ + $oMfaService = Factory::service('MultiFactorAuth', Constants::MODULE_SLUG); + /** @var Service\Input $oInput */ + $oInput = Factory::service('Input'); + /** @var Service\UserFeedback $oUserFeedback */ + $oUserFeedback = Factory::service('UserFeedback'); + /** @var Service\Session $oSession */ + $oSession = Factory::service('Session'); + /** @var Service\View $oView */ + $oView = Factory::service('View'); + + $oUser = activeUser(); + + if (!$oMfaService->userCanManageMethods($oUser)) { + show404(); + } + + $oPending = $oSession->getUserData(static::SESSION_PENDING_SETUP); + + try { + + if ($oInput::post('action') === 'set_default') { + $oMfaService->setDefaultMethod($oUser, (string) $oInput::post('driver')); + $oUserFeedback->success('Default verification method updated.'); + redirect('mfa/manage'); + + } elseif ($oInput::post('action') === 'remove') { + $oMfaService->removeMethod($oUser, (string) $oInput::post('driver')); + $oUserFeedback->success('Verification method removed.'); + redirect('mfa/manage'); + + } elseif ($oInput::post('action') === 'setup_choose') { + $sSlug = (string) $oInput::post('driver'); + $oDriver = $this->findSetupDriver($oMfaService, $oUser, $sSlug); + if ($oDriver->requiresEnrollment()) { + $oStart = $oDriver->setupStart($oUser); + $oSession->setUserData(static::SESSION_PENDING_SETUP, (object) [ + 'driver' => $sSlug, + 'pending' => $oStart, + ]); + $oPending = $oSession->getUserData(static::SESSION_PENDING_SETUP); + } else { + $oMfaService->enrollMethod( + $oUser, + $sSlug, + $oDriver->setupComplete($oUser, '', (object) []), + empty($oMfaService->getUserMethods($oUser)) + ); + $oUserFeedback->success($oDriver->getLabel() . ' has been added to your account.'); + redirect('mfa/manage'); + } + + } elseif ($oInput::post('action') === 'setup_confirm' && $oPending) { + $oDriver = $oMfaService->getDriverBySlug($oPending->driver); + $oData = $oDriver->setupComplete( + $oUser, + (string) $oInput::post('code'), + (object) $oPending->pending + ); + $oMfaService->enrollMethod( + $oUser, + $oPending->driver, + $oData, + empty($oMfaService->getUserMethods($oUser)) + ); + $oSession->unsetUserData(static::SESSION_PENDING_SETUP); + $oUserFeedback->success($oDriver->getLabel() . ' has been added to your account.'); + redirect('mfa/manage'); + + } elseif ($oInput::post('action') === 'setup_cancel') { + $oSession->unsetUserData(static::SESSION_PENDING_SETUP); + $oPending = null; + } + + } catch (Exception\InvalidCodeException $e) { + $oUserFeedback->error($e->getMessage()); + + } catch (Exception\MfaException $e) { + $oUserFeedback->error($e->getMessage()); + } + + $this->data['oUser'] = $oUser; + $this->data['sGroupMode'] = $oMfaService->getGroupMode($oUser); + $this->data['aMethods'] = $oMfaService->getUserMethods($oUser); + $this->data['aSetupDrivers'] = $oMfaService->getSetupDrivers($oUser); + $this->data['oPending'] = is_object($oPending) ? $oPending : null; + $this->data['aCanRemove'] = []; + $this->data['aDriverLabels'] = []; + + foreach ($this->data['aMethods'] as $oMethod) { + $sDriver = (string) $oMethod->driver; + + $this->data['aCanRemove'][$sDriver] = $oMfaService->userCanRemoveMethod($oUser, $sDriver); + } + + try { + foreach ($oMfaService->getEnabledDrivers() as $oDriver) { + $this->data['aDriverLabels'][$oDriver->getSlug()] = $oDriver->getLabel(); + } + } catch (Exception\MfaException $e) { + $this->data['aDriverLabels'] = []; + } + + $this->oMetaData->setTitles(['Security', 'Two-factor authentication']); + + $this->loadStyles(Config::get('NAILS_APP_PATH') . 'application/modules/mfa/views/manage.php'); + + $oView + ->load([ + 'mfa/structure/header', + 'mfa/manage', + 'mfa/structure/footer', + ]); + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + * @throws ModelException + * @throws NailsException + */ + private function handleSetupChoose( + Resource\Token $oToken, + MultiFactorAuth $oMfaService, + Service\UserFeedback $oUserFeedback + ): void { + /** @var Service\Input $oInput */ + $oInput = Factory::service('Input'); + $sSlug = (string) $oInput::post('driver'); + $oUser = $oToken->user(); + $oDriver = $this->findSetupDriver($oMfaService, $oUser, $sSlug); + + $this->log('User chose MFA setup driver ' . $sSlug); + + $this->beginSetup($oDriver, $oToken, $oMfaService, $oUserFeedback); + } + + // -------------------------------------------------------------------------- + + /** + * Starts setup of a chosen driver, either by collecting whatever it needs to + * enroll the user or, for drivers with nothing to enroll, by challenging them + * with it straight away. + * + * @throws FactoryException + * @throws ModelException + * @throws NailsException + */ + private function beginSetup( + Interfaces\Authentication\Driver $oDriver, + Resource\Token $oToken, + MultiFactorAuth $oMfaService, + Service\UserFeedback $oUserFeedback + ): void { + $sSlug = (string) $oDriver->getSlug(); + + $oToken->setData((object) [ + $oMfaService::TOKEN_DATA_KEY_DRIVER => $sSlug, + $oMfaService::TOKEN_DATA_KEY_IS_SETUP => true, + ]); + + if ($oDriver->requiresEnrollment()) { + $oPending = $oDriver->setupStart($oToken->user()); + $oMfaService->setPendingSetup($oToken, $sSlug, $oPending); + $this->renderSetupConfirm($oDriver, $oToken, $oMfaService); + return; + } + + $oDriver->preForm($oToken, $oUserFeedback); + $this->renderForm($oDriver, $oToken, $oMfaService); + $oDriver->postForm($oToken); + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + * @throws ModelException + * @throws NailsException + */ + private function handleSetupConfirm( + Resource\Token $oToken, + MultiFactorAuth $oMfaService, + Service\UserFeedback $oUserFeedback, + Service\Input $oInput + ): void { + $oPending = $oMfaService->getPendingSetup($oToken); + $sSlug = (string) $oToken->getData($oMfaService::TOKEN_DATA_KEY_DRIVER); + + if (!$oPending || $sSlug === '') { + $oUserFeedback->error('Your setup session expired. Please try again.'); + $this->renderSetup($oToken, $oMfaService); + return; + } + + $oDriver = $oMfaService->getDriverBySlug($sSlug); + + try { + $oMfaService->registerFailedAttempt($oToken); + $oData = $oDriver->setupComplete( + $oToken->user(), + (string) $oInput::post('code'), + $oPending + ); + $oMfaService->enrollMethod($oToken->user(), $sSlug, $oData, true); + $oMfaService->clearPendingSetup($oToken); + + $sRedirectUrl = $oMfaService->completeChallenge( + $oToken, + (bool) $oInput::post('remember') + ); + + $this->log('User completed MFA setup and signed in'); + redirect($sRedirectUrl); + + } catch (Exception\InvalidCodeException $e) { + $oUserFeedback->error($e->getMessage()); + $this->renderSetupConfirm($oDriver, $oToken, $oMfaService); + } + } + + // -------------------------------------------------------------------------- + + /** + * @throws Exception\MfaException + */ + private function findSetupDriver( + MultiFactorAuth $oMfaService, + \Nails\Auth\Resource\User $oUser, + string $sSlug + ): Interfaces\Authentication\Driver { + foreach ($oMfaService->getSetupDrivers($oUser) as $oDriver) { + if ($oDriver->getSlug() === $sSlug) { + return $oDriver; + } + } + + throw new Exception\MfaException('That verification method is not available.'); + } + + // -------------------------------------------------------------------------- + + private function cancelSetup( + Resource\Token $oToken, + MultiFactorAuth $oMfaService, + Model\Token $oTokenModel, + Service\UserFeedback $oUserFeedback + ): void { + $this->log('User cancelled MFA setup'); + + if ($oToken->id) { + $oTokenModel->delete($oToken->id); + } + + $oMfaService->clearTokenCookie(); + $oUserFeedback->info('Two-factor authentication setup was cancelled. Please sign in to try again.'); + redirect(loginUrl(null)); + } + + // -------------------------------------------------------------------------- + + /** + * Describes the trusted device window in the units the configured TTL divides into + */ + private function trustedForLabel(MultiFactorAuth $oMfaService): string + { + $iTtl = $oMfaService->getTrustedDeviceTtl(); + + foreach ([86400 => 'day', 3600 => 'hour', 60 => 'minute'] as $iSeconds => $sUnit) { + if ($iTtl >= $iSeconds) { + $iValue = (int) round($iTtl / $iSeconds); + return sprintf('%d %s', $iValue, $iValue === 1 ? $sUnit : $sUnit . 's'); + } + } + + return sprintf('%d seconds', $iTtl); + } + + // -------------------------------------------------------------------------- + private function log(string $sMessage): void { $this->oLogger->info(sprintf( @@ -201,10 +573,41 @@ private function log(string $sMessage): void // -------------------------------------------------------------------------- - private function selectDriver(array $aDrivers): Interfaces\Authentication\Driver - { - // @todo (Pablo 2023-02-22) - choose the appropriate driver for the user (e.g. email, app, etc) - return reset($aDrivers); + /** + * @throws FactoryException + * @throws ViewNotFoundException + * @throws \Exception + */ + private function renderForm( + Interfaces\Authentication\Driver $oDriver, + Resource\Token $oToken, + MultiFactorAuth $oMfaService + ): void { + $this->loadStyles(Config::get('NAILS_APP_PATH') . 'application/modules/mfa/views/form.php'); + + $aOtherMethods = []; + foreach ($oMfaService->getAuthenticationMethods($oToken->user()) as $oOther) { + if ($oOther->getSlug() !== $oDriver->getSlug()) { + $aOtherMethods[] = $oOther; + } + } + + /** @var Service\View $oView */ + $oView = Factory::service('View'); + $oView + ->setData([ + 'oDriver' => $oDriver, + 'oToken' => $oToken, + 'aOtherMethods' => $aOtherMethods, + 'bIsSetup' => (bool) $oToken->getData($oMfaService::TOKEN_DATA_KEY_IS_SETUP), + 'bCanGoBack' => count($oMfaService->getSetupDrivers($oToken->user())) > 1, + 'sTrustedForLabel' => $this->trustedForLabel($oMfaService), + ]) + ->load([ + 'mfa/structure/header', + 'mfa/form', + 'mfa/structure/footer', + ]); } // -------------------------------------------------------------------------- @@ -212,23 +615,71 @@ private function selectDriver(array $aDrivers): Interfaces\Authentication\Driver /** * @throws FactoryException * @throws ViewNotFoundException - * @throws \Exception */ - private function renderForm(Interfaces\Authentication\Driver $oDriver, Resource\Token $oToken): void + private function renderSetup(Resource\Token $oToken, MultiFactorAuth $oMfaService): void { - $this->loadStyles(Config::get('NAILS_APP_PATH') . 'application/modules/mfa/views/form.php'); + $aDrivers = $oMfaService->getSetupDrivers($oToken->user()); + + // A choice of one is no choice at all + if (count($aDrivers) === 1) { + + $oDriver = reset($aDrivers); + + $this->log(sprintf( + 'Only one MFA setup method is available, choosing %s', + $oDriver->getSlug() + )); + + /** @var Service\UserFeedback $oUserFeedback */ + $oUserFeedback = Factory::service('UserFeedback'); + + $this->beginSetup($oDriver, $oToken, $oMfaService, $oUserFeedback); + return; + } + + $this->loadStyles(Config::get('NAILS_APP_PATH') . 'application/modules/mfa/views/setup.php'); /** @var Service\View $oView */ $oView = Factory::service('View'); $oView ->setData([ - 'oDriver' => $oDriver, - 'oToken' => $oToken, + 'oToken' => $oToken, + 'aDrivers' => $aDrivers, ]) ->load([ - 'structure/header/blank', - 'mfa/form', - 'structure/footer/blank', + 'mfa/structure/header', + 'mfa/setup', + 'mfa/structure/footer', + ]); + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + * @throws ViewNotFoundException + */ + private function renderSetupConfirm( + Interfaces\Authentication\Driver $oDriver, + Resource\Token $oToken, + MultiFactorAuth $oMfaService + ): void { + $this->loadStyles(Config::get('NAILS_APP_PATH') . 'application/modules/mfa/views/setup_confirm.php'); + + /** @var Service\View $oView */ + $oView = Factory::service('View'); + $oView + ->setData([ + 'oDriver' => $oDriver, + 'oToken' => $oToken, + 'oPending' => $oMfaService->getPendingSetup($oToken), + 'bCanGoBack' => count($oMfaService->getSetupDrivers($oToken->user())) > 1, + 'sTrustedForLabel' => $this->trustedForLabel($oMfaService), + ]) + ->load([ + 'mfa/structure/header', + 'mfa/setup_confirm', + 'mfa/structure/footer', ]); } @@ -240,13 +691,14 @@ private function renderForm(Interfaces\Authentication\Driver $oDriver, Resource\ */ protected function loadStyles($sView): void { - // Test if a view has been provided by the app if (!is_file($sView)) { /** @var Service\Asset $oAsset */ $oAsset = Factory::service('Asset'); $oAsset ->clear() - ->load('nails.min.css', \Nails\Common\Constants::MODULE_SLUG); + ->load('nails.min.css', \Nails\Common\Constants::MODULE_SLUG) + // Sizes the .nails-auth wrapper these views share with the auth pages + ->load('styles.min.css', Auth\Constants::MODULE_SLUG); } } } diff --git a/mfa/views/form.php b/mfa/views/form.php index 98990c7..381c653 100644 --- a/mfa/views/form.php +++ b/mfa/views/form.php @@ -6,8 +6,14 @@ /** * @var \Nails\MFA\Interfaces\Authentication\Driver $oDriver * @var \Nails\MFA\Resource\Token $oToken + * @var \Nails\MFA\Interfaces\Authentication\Driver[] $aOtherMethods + * @var bool $bIsSetup + * @var bool $bCanGoBack + * @var string $sTrustedForLabel */ +$aOtherMethods = $aOtherMethods ?? []; + /** @var View $oView */ $oView = Factory::service('View'); @@ -28,13 +34,23 @@ ?>
- +
- +
-
+ +

+ + 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()) { ?> - + + +
+ + +
+ + + + +
+ -
diff --git a/mfa/views/manage.php b/mfa/views/manage.php new file mode 100644 index 0000000..d8710b3 --- /dev/null +++ b/mfa/views/manage.php @@ -0,0 +1,205 @@ + $aDriverLabels + * @var array $aCanRemove + */ + +use Nails\Common\Service\View; +use Nails\Factory; +use Nails\MFA\Model\GroupPolicy; + +/** @var View $oView */ +$oView = Factory::service('View'); + +?> +
+

Two-factor authentication

+

+ Verification methods ask you to confirm your identity when you sign in, in addition to your password. + +

+ load('auth/_components/alerts'); + + if ($oPending) { + + $sPendingLabel = $aDriverLabels[$oPending->driver] ?? $oPending->driver; + $oPendingData = (object) $oPending->pending; + + ?> +
+
+

+ Finish setting up +

+
+ +
+ qr_svg)) { ?> +

+ qr_svg?> +

+ + secret)) { ?> +

+ secret)?> + Enter this secret manually if you cannot scan the code +

+ +
+ + + + Enter the code from to confirm setup. + +
+
+ + +
+ +
+
+

Your methods

+
+
+ +

You have not set up any verification methods yet.

+ +
    + driver; + $bCanRemove = !empty($aCanRemove[$sDriver]); + $bHasActions = $bCanRemove || !$oMethod->is_default; + + ?> +
  • + + + + + is_default) { ?> + Used by default + + + + + is_default) { ?> + + + + + + + + +
  • + +
+ +
+ + + +
+ +
+
+

Add a method

+
+
+
    + +
  • + + getLabel())?> + getSetupDescription())?> + + + + + +
  • + +
+
+
+ +
diff --git a/mfa/views/setup.php b/mfa/views/setup.php new file mode 100644 index 0000000..38d1067 --- /dev/null +++ b/mfa/views/setup.php @@ -0,0 +1,56 @@ + +
+

+ Set up two-factor authentication +

+

+ Choose how you would like to verify your identity when you sign in. +

+ load('auth/_components/alerts'); + + foreach ($aDrivers as $oDriver) { + echo form_open(null, 'class="form"'); + ?> +
+
+

getLabel())?>

+

getSetupDescription())?>

+
+ +
+ +
+
+

No verification methods are available. Please contact support.

+
+
+ +
diff --git a/mfa/views/setup_confirm.php b/mfa/views/setup_confirm.php new file mode 100644 index 0000000..93ea5a9 --- /dev/null +++ b/mfa/views/setup_confirm.php @@ -0,0 +1,81 @@ + +
+
+
+

+ Set up getLabel())?> +

+
+
+ load('auth/_components/alerts'); + + if (!empty($oPending->qr_svg)) { + ?> +
+ qr_svg?> +
+ secret)) { + ?> +

+ Secret: secret)?> +

+ +

Scan the QR code with your authenticator app, then enter the code it shows to confirm setup.

+
+ + +
+
+ + +
+

+ + getLabel())?> will become your default method. You can change your + default later from your two-factor authentication settings. + +

+
+ + + + + +
+ +
+
+
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() ); }