From 09380a65677ac27eb5acf32799852a23247b1877 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Tue, 7 May 2024 15:48:21 +0800 Subject: [PATCH 01/18] copy content from the mfa module repo https://github.com/silinternational/simplesamlphp-module-mfa --- features/bootstrap/context/MfaContext.php | 845 ++++++++++++++++ features/fakes/FakeIdBrokerClient.php | 136 +++ features/mfa.feature | 262 ++++- local.env.dist | 6 + modules/mfa/lib/Auth/Process/Mfa.php | 956 ++++++++++++++++++ modules/mfa/src/Assert.php | 57 ++ modules/mfa/src/LoggerFactory.php | 41 + modules/mfa/src/LoginBrowser.php | 41 + modules/mfa/templates/low-on-backup-codes.php | 21 + modules/mfa/templates/must-set-up-mfa.php | 16 + modules/mfa/templates/new-backup-codes.php | 40 + modules/mfa/templates/out-of-backup-codes.php | 37 + .../templates/prompt-for-mfa-backupcode.php | 57 ++ .../mfa/templates/prompt-for-mfa-manager.php | 49 + modules/mfa/templates/prompt-for-mfa-totp.php | 53 + .../mfa/templates/prompt-for-mfa-webauthn.php | 79 ++ modules/mfa/templates/send-manager-mfa.php | 20 + modules/mfa/www/low-on-backup-codes.php | 40 + modules/mfa/www/must-set-up-mfa.php | 32 + modules/mfa/www/new-backup-codes.php | 39 + modules/mfa/www/out-of-backup-codes.php | 42 + modules/mfa/www/prompt-for-mfa.php | 114 +++ modules/mfa/www/send-manager-mfa.php | 45 + modules/mfa/www/simplewebauthn/LICENSE.md | 21 + modules/mfa/www/simplewebauthn/browser.js | 2 + 25 files changed, 3030 insertions(+), 21 deletions(-) create mode 100644 features/bootstrap/context/MfaContext.php create mode 100644 features/fakes/FakeIdBrokerClient.php create mode 100644 modules/mfa/lib/Auth/Process/Mfa.php create mode 100644 modules/mfa/src/Assert.php create mode 100644 modules/mfa/src/LoggerFactory.php create mode 100644 modules/mfa/src/LoginBrowser.php create mode 100644 modules/mfa/templates/low-on-backup-codes.php create mode 100644 modules/mfa/templates/must-set-up-mfa.php create mode 100644 modules/mfa/templates/new-backup-codes.php create mode 100644 modules/mfa/templates/out-of-backup-codes.php create mode 100644 modules/mfa/templates/prompt-for-mfa-backupcode.php create mode 100644 modules/mfa/templates/prompt-for-mfa-manager.php create mode 100644 modules/mfa/templates/prompt-for-mfa-totp.php create mode 100644 modules/mfa/templates/prompt-for-mfa-webauthn.php create mode 100644 modules/mfa/templates/send-manager-mfa.php create mode 100644 modules/mfa/www/low-on-backup-codes.php create mode 100644 modules/mfa/www/must-set-up-mfa.php create mode 100644 modules/mfa/www/new-backup-codes.php create mode 100644 modules/mfa/www/out-of-backup-codes.php create mode 100644 modules/mfa/www/prompt-for-mfa.php create mode 100644 modules/mfa/www/send-manager-mfa.php create mode 100644 modules/mfa/www/simplewebauthn/LICENSE.md create mode 100644 modules/mfa/www/simplewebauthn/browser.js diff --git a/features/bootstrap/context/MfaContext.php b/features/bootstrap/context/MfaContext.php new file mode 100644 index 00000000..020c49e8 --- /dev/null +++ b/features/bootstrap/context/MfaContext.php @@ -0,0 +1,845 @@ +driver = new GoutteDriver(); + $this->session = new Session($this->driver); + $this->session->start(); + } + + /** + * Assert that the given page has a form that contains the given text. + * + * @param string $text The text (or HTML) to search for. + * @param DocumentElement $page The page to search in. + * @return void + */ + protected function assertFormContains($text, $page) + { + $forms = $page->findAll('css', 'form'); + foreach ($forms as $form) { + if (strpos($form->getHtml(), $text) !== false) { + return; + } + } + Assert::fail(sprintf( + "No form found containing %s in this HTML:\n%s", + var_export($text, true), + $page->getHtml() + )); + } + + /** + * Get the "continue" button. + * + * @param DocumentElement $page The page. + * @return NodeElement + */ + protected function getContinueButton($page) + { + $continueButton = $page->find('css', '[name=continue]'); + return $continueButton; + } + + /** + * Get the login button from the given page. + * + * @param DocumentElement $page The page. + * @return NodeElement + */ + protected function getLoginButton($page) + { + $buttons = $page->findAll('css', 'button'); + $loginButton = null; + foreach ($buttons as $button) { + $lcButtonText = strtolower($button->getText()); + if (strpos($lcButtonText, 'login') !== false) { + $loginButton = $button; + break; + } + } + Assert::assertNotNull($loginButton, 'Failed to find the login button'); + return $loginButton; + } + + /** + * Get the button for submitting the MFA form. + * + * @param DocumentElement $page The page. + * @return NodeElement + */ + protected function getSubmitMfaButton($page) + { + $submitMfaButton = $page->find('css', '[name=submitMfa]'); + Assert::assertNotNull($submitMfaButton, 'Failed to find the submit-MFA button'); + return $submitMfaButton; + } + + /** + * @When I login + */ + public function iLogin() + { + $this->session->visit($this->nonPwManagerUrl); + $page = $this->session->getPage(); + try { + $page->fillField('username', $this->username); + $page->fillField('password', $this->password); + $this->submitLoginForm($page); + } catch (ElementNotFoundException $e) { + Assert::fail(sprintf( + "Did not find that element in the page.\nError: %s\nPage content: %s", + $e->getMessage(), + $page->getContent() + )); + } + } + + /** + * @Then I should end up at my intended destination + */ + public function iShouldEndUpAtMyIntendedDestination() + { + $page = $this->session->getPage(); + Assert::assertContains('Your attributes', $page->getHtml()); + } + + /** + * Submit the current form, including the secondary page's form (if + * simpleSAMLphp shows another page because JavaScript isn't supported) by + * clicking the specified button. + * + * @param string $buttonName The value of the desired button's `name` + * attribute. + */ + protected function submitFormByClickingButtonNamed($buttonName) + { + $page = $this->session->getPage(); + $button = $page->find('css', sprintf( + '[name=%s]', + $buttonName + )); + Assert::assertNotNull($button, 'Failed to find button named ' . $buttonName); + $button->click(); + $this->submitSecondarySspFormIfPresent($page); + } + + /** + * Submit the login form, including the secondary page's form (if + * simpleSAMLphp shows another page because JavaScript isn't supported). + * + * @param DocumentElement $page The page. + */ + protected function submitLoginForm($page) + { + $loginButton = $this->getLoginButton($page); + $loginButton->click(); + $this->submitSecondarySspFormIfPresent($page); + } + + + /** + * Submit the MFA form, including the secondary page's form (if + * simpleSAMLphp shows another page because JavaScript isn't supported). + * + * @param DocumentElement $page The page. + */ + protected function submitMfaForm($page) + { + $submitMfaButton = $this->getSubmitMfaButton($page); + $submitMfaButton->click(); + $this->submitSecondarySspFormIfPresent($page); + } + + + /** + * Submit the secondary page's form (if simpleSAMLphp shows another page + * because JavaScript isn't supported). + * + * @param DocumentElement $page The page. + */ + protected function submitSecondarySspFormIfPresent($page) + { + // SimpleSAMLphp 1.15 markup for secondary page: + $postLoginSubmitButton = $page->findButton('postLoginSubmitButton'); + if ($postLoginSubmitButton instanceof NodeElement) { + $postLoginSubmitButton->click(); + } else { + + // SimpleSAMLphp 1.14 markup for secondary page: + $body = $page->find('css', 'body'); + if ($body instanceof NodeElement) { + $onload = $body->getAttribute('onload'); + if ($onload === "document.getElementsByTagName('input')[0].click();") { + $body->pressButton('Submit'); + } + } + } + } + + /** + * @Given I provide credentials that do not need MFA + */ + public function iProvideCredentialsThatDoNotNeedMfa() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'no_mfa_needed'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that need MFA but have no MFA options available + */ + public function iProvideCredentialsThatNeedMfaButHaveNoMfaOptionsAvailable() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'must_set_up_mfa'; + $this->password = 'a'; + } + + /** + * @Then I should see a message that I have to set up MFA + */ + public function iShouldSeeAMessageThatIHaveToSetUpMfa() + { + $page = $this->session->getPage(); + Assert::assertContains('must set up 2-', $page->getHtml()); + } + + /** + * @Then there should be a way to go set up MFA now + */ + public function thereShouldBeAWayToGoSetUpMfaNow() + { + $page = $this->session->getPage(); + $this->assertFormContains('name="setUpMfa"', $page); + } + + /** + * @Given I provide credentials that need MFA and have backup codes available + */ + public function iProvideCredentialsThatNeedMfaAndHaveBackupCodesAvailable() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_backupcode'; + $this->password = 'a'; + } + + /** + * @Then I should see a prompt for a backup code + */ + public function iShouldSeeAPromptForABackupCode() + { + $page = $this->session->getPage(); + $pageHtml = $page->getHtml(); + Assert::assertContains('

Printable Backup Code

', $pageHtml); + Assert::assertContains('Enter code', $pageHtml); + } + + /** + * @Given I provide credentials that need MFA and have TOTP available + */ + public function iProvideCredentialsThatNeedMfaAndHaveTotpAvailable() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_totp'; + $this->password = 'a'; + } + + /** + * @Then I should see a prompt for a TOTP (code) + */ + public function iShouldSeeAPromptForATotpCode() + { + $page = $this->session->getPage(); + $pageHtml = $page->getHtml(); + Assert::assertContains('

Smartphone App

', $pageHtml); + Assert::assertContains('Enter 6-digit code', $pageHtml); + } + + /** + * @Given I provide credentials that need MFA and have WebAuthn available + */ + public function iProvideCredentialsThatNeedMfaAndHaveUfAvailable() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_webauthn'; + $this->password = 'a'; + } + + /** + * @Then I should see a prompt for a WebAuthn (security key) + */ + public function iShouldSeeAPromptForAWebAuthn() + { + $page = $this->session->getPage(); + Assert::assertContains('

USB Security Key

', $page->getHtml()); + } + + /** + * @Given I have logged in (again) + */ + public function iHaveLoggedIn() + { + $this->iLogin(); + } + + protected function submitMfaValue($mfaValue) + { + $page = $this->session->getPage(); + $page->fillField('mfaSubmission', $mfaValue); + $this->submitMfaForm($page); + return $page->getHtml(); + } + + /** + * @When I submit a correct backup code + */ + public function iSubmitACorrectBackupCode() + { + if (! $this->pageContainsElementWithText('h2', 'Printable Backup Code')) { + $this->clickLink('backupcode'); + } + $this->submitMfaValue(FakeIdBrokerClient::CORRECT_VALUE); + } + + protected function pageContainsElementWithText($cssSelector, $text) + { + $page = $this->session->getPage(); + $elements = $page->findAll('css', $cssSelector); + foreach ($elements as $element) { + if (strpos($element->getText(), $text) !== false) { + return true; + } + } + return false; + } + + protected function clickLink($text) + { + $this->session->getPage()->clickLink($text); + } + + /** + * @When I submit an incorrect backup code + */ + public function iSubmitAnIncorrectBackupCode() + { + $this->submitMfaValue(FakeIdBrokerClient::INCORRECT_VALUE); + } + + /** + * @Then I should see a message that I have to wait before trying again + */ + public function iShouldSeeAMessageThatIHaveToWaitBeforeTryingAgain() + { + $page = $this->session->getPage(); + $pageHtml = $page->getHtml(); + Assert::assertContains(' wait ', $pageHtml); + Assert::assertContains('try again', $pageHtml); + } + + /** + * @Then I should see a message that it was incorrect + */ + public function iShouldSeeAMessageThatItWasIncorrect() + { + $page = $this->session->getPage(); + $pageHtml = $page->getHtml(); + Assert::assertContains('Incorrect 2-step verification code', $pageHtml); + } + + /** + * @Given I provide credentials that have a rate-limited MFA + */ + public function iProvideCredentialsThatHaveARateLimitedMfa() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_rate_limited_mfa'; + $this->password = 'a'; + } + + /** + * @Then there should be a way to continue to my intended destination + */ + public function thereShouldBeAWayToContinueToMyIntendedDestination() + { + $page = $this->session->getPage(); + $this->assertFormContains('name="continue"', $page); + } + + /** + * @When I click the remind-me-later button + */ + public function iClickTheRemindMeLaterButton() + { + $this->submitFormByClickingButtonNamed('continue'); + } + + /** + * @When I click the set-up-MFA button + */ + public function iClickTheSetUpMfaButton() + { + $this->submitFormByClickingButtonNamed('setUpMfa'); + } + + /** + * @Then I should end up at the mfa-setup URL + */ + public function iShouldEndUpAtTheMfaSetupUrl() + { + $mfaSetupUrl = Env::get('MFA_SETUP_URL_FOR_TESTS'); + Assert::assertNotEmpty($mfaSetupUrl, 'No MFA_SETUP_URL_FOR_TESTS provided'); + $currentUrl = $this->session->getCurrentUrl(); + Assert::assertStringStartsWith( + $mfaSetupUrl, + $currentUrl, + 'Did NOT end up at the MFA-setup URL' + ); + } + + /** + * @Then there should NOT be a way to continue to my intended destination + */ + public function thereShouldNotBeAWayToContinueToMyIntendedDestination() + { + $page = $this->session->getPage(); + $continueButton = $this->getContinueButton($page); + Assert::assertNull($continueButton, 'Should not have found a continue button'); + } + + /** + * @Then I should NOT be able to get to my intended destination + */ + public function iShouldNotBeAbleToGetToMyIntendedDestination() + { + $this->session->visit($this->nonPwManagerUrl); + Assert::assertStringStartsNotWith( + $this->nonPwManagerUrl, + $this->session->getCurrentUrl(), + 'Failed to prevent me from getting to SPs other than the MFA setup URL' + ); + } + + /** + * @Given I provide credentials that need MFA and have 4 backup codes available + */ + public function iProvideCredentialsThatNeedMfaAndHave4BackupCodesAvailable() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_4_backupcodes'; + $this->password = 'a'; + } + + /** + * @Then I should see a message that I am running low on backup codes + */ + public function iShouldSeeAMessageThatIAmRunningLowOnBackupCodes() + { + $page = $this->session->getPage(); + Assert::assertContains( + 'You are almost out of Printable Backup Codes', + $page->getHtml() + ); + } + + /** + * @Then there should be a way to get more backup codes now + */ + public function thereShouldBeAWayToGetMoreBackupCodesNow() + { + $page = $this->session->getPage(); + $this->assertFormContains('name="getMore"', $page); + } + + /** + * @Given I provide credentials that need MFA and have 1 backup code available and no other MFA + */ + public function iProvideCredentialsThatNeedMfaAndHave1BackupCodeAvailableAndNoOtherMfa() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_1_backupcode_only'; + $this->password = 'a'; + } + + /** + * @Then I should see a message that I have used up my backup codes + */ + public function iShouldSeeAMessageThatIHaveUsedUpMyBackupCodes() + { + $page = $this->session->getPage(); + Assert::assertContains( + 'You just used your last Printable Backup Code', + $page->getHtml() + ); + } + + /** + * @Given I provide credentials that need MFA and have 1 backup code available plus some other MFA + */ + public function iProvideCredentialsThatNeedMfaAndHave1BackupCodeAvailablePlusSomeOtherMfa() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_1_backupcode_plus'; + $this->password = 'a'; + } + + /** + * @When I click the get-more-backup-codes button + */ + public function iClickTheGetMoreBackupCodesButton() + { + $this->submitFormByClickingButtonNamed('getMore'); + } + + /** + * @Then I should be told I only have :numRemaining backup codes left + */ + public function iShouldBeToldIOnlyHaveBackupCodesLeft($numRemaining) + { + $page = $this->session->getPage(); + Assert::assertContains( + 'You only have ' . $numRemaining . ' remaining', + $page->getHtml() + ); + } + + /** + * @Then I should be given more backup codes + */ + public function iShouldBeGivenMoreBackupCodes() + { + $page = $this->session->getPage(); + Assert::assertContains( + 'Here are your new Printable Backup Codes', + $page->getContent() + ); + } + + /** + * @Given I provide credentials that have WebAuthn + */ + public function iProvideCredentialsThatHaveUf() + { + $this->iProvideCredentialsThatNeedMfaAndHaveUfAvailable(); + } + + /** + * @Given the user's browser supports WebAuthn + */ + public function theUsersBrowserSupportsUf() + { + $userAgentWithWebAuthn = self::USER_AGENT_WITH_WEBAUTHN_SUPPORT; + Assert::assertTrue( + LoginBrowser::supportsWebAuthn($userAgentWithWebAuthn), + 'Update USER_AGENT_WITH_WEBAUTHN_SUPPORT to a User Agent with WebAuthn support' + ); + + $this->driver->getClient()->setServerParameter('HTTP_USER_AGENT', $userAgentWithWebAuthn); + } + + /** + * @Given I provide credentials that have WebAuthn, TOTP + */ + public function iProvideCredentialsThatHaveUfTotp() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_webauthn_totp'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have WebAuthn, backup codes + */ + public function iProvideCredentialsThatHaveUfBackupCodes() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_webauthn_backupcodes'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have WebAuthn, TOTP, backup codes + */ + public function iProvideCredentialsThatHaveUfTotpBackupCodes() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_webauthn_totp_backupcodes'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have TOTP + */ + public function iProvideCredentialsThatHaveTotp() + { + $this->iProvideCredentialsThatNeedMfaAndHaveTotpAvailable(); + } + + /** + * @Given I provide credentials that have TOTP, backup codes + */ + public function iProvideCredentialsThatHaveTotpBackupCodes() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_totp_backupcodes'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have backup codes + */ + public function iProvideCredentialsThatHaveBackupCodes() + { + $this->iProvideCredentialsThatNeedMfaAndHaveBackupCodesAvailable(); + } + + /** + * @Given I provide credentials that have a manager code, a WebAuthn and a more recently used TOTP + */ + public function IProvideCredentialsThatHaveManagerCodeWebauthnAndMoreRecentlyUsedTotp() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_mgr_code_webauthn_and_more_recently_used_totp'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have a used WebAuthn + */ + public function IProvideCredentialsThatHaveUsedWebAuthn() + { + $this->username = 'has_webauthn_'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have a used TOTP + */ + public function IProvideCredentialsThatHaveUsedTotp() + { + $this->username = 'has_totp_'; + $this->password = 'a'; + } + + /** + * @Given I provide credentials that have a used backup code + */ + public function IProvideCredentialsThatHaveUsedBackupCode() + { + $this->username = 'has_backup_code_'; + $this->password = 'a'; + } + + /** + * @Given and I have a more recently used TOTP + */ + public function IHaveMoreRecentlyUsedTotp() + { + $this->username .= 'and_more_recently_used_totp'; + $this->password = 'a'; + } + + /** + * @Given and I have a more recently used Webauthn + */ + public function IHaveMoreRecentlyUsedWebauthn() + { + $this->username .= 'and_more_recently_used_webauthn'; + $this->password = 'a'; + } + + /** + * @Given and I have a more recently used backup code + */ + public function IHaveMoreRecentlyUsedBackupCode() + { + $this->username .= 'and_more_recently_used_backup_code'; + $this->password = 'a'; + } + + /** + * @Given the user's browser does not support WebAuthn + */ + public function theUsersBrowserDoesNotSupportUf() + { + $userAgentWithoutWebAuthn = self::USER_AGENT_WITHOUT_WEBAUTHN_SUPPORT; + Assert::assertFalse( + LoginBrowser::supportsWebAuthn($userAgentWithoutWebAuthn), + 'Update USER_AGENT_WITHOUT_WEBAUTHN_SUPPORT to a User Agent without WebAuthn support' + ); + + $this->driver->getClient()->setServerParameter('HTTP_USER_AGENT', $userAgentWithoutWebAuthn); + } + + /** + * @Then I should not see an error message about WebAuthn being unsupported + */ + public function iShouldNotSeeAnErrorMessageAboutUfBeingUnsupported() + { + $page = $this->session->getPage(); + Assert::assertNotContains('USB Security Keys are not supported', $page->getContent()); + } + + /** + * @Then I should see an error message about WebAuthn being unsupported + */ + public function iShouldSeeAnErrorMessageAboutUfBeingUnsupported() + { + $page = $this->session->getPage(); + Assert::assertContains('USB Security Keys are not supported', $page->getContent()); + } + + /** + * @Given the user has a manager email + */ + public function theUserHasAManagerEmail() + { + $this->username .= '_and_mgr'; + } + + /** + * @Then I should see a link to send a code to the user's manager + */ + public function iShouldSeeALinkToSendACodeToTheUsersManager() + { + $page = $this->session->getPage(); + Assert::assertContains('Can\'t use any of your 2-Step Verification options', $page->getContent()); + } + + /** + * @Given the user does not have a manager email + */ + public function theUserDoesntHaveAManagerEmail() + { + /* + * No change to username needed. + */ + } + + /** + * @Then I should not see a link to send a code to the user's manager + */ + public function iShouldNotSeeALinkToSendACodeToTheUsersManager() + { + $page = $this->session->getPage(); + Assert::assertNotContains('Send a code to your manager', $page->getContent()); + } + + /** + * @When I click the Request Assistance link + */ + public function iClickTheRequestAssistanceLink() + { + $this->clickLink('Click here'); + } + + /** + * @When I click the Send a code link + */ + public function iClickTheRequestACodeLink() + { + $this->submitFormByClickingButtonNamed('send'); + } + + /** + * @Then I should see a prompt for a manager rescue code + */ + public function iShouldSeeAPromptForAManagerRescueCode() + { + $page = $this->session->getPage(); + $pageHtml = $page->getHtml(); + Assert::assertContains('

Manager Rescue Code

', $pageHtml); + Assert::assertContains('Enter code', $pageHtml); + } + + /** + * @When I submit the correct manager code + */ + public function iSubmitTheCorrectManagerCode() + { + $this->submitMfaValue(FakeIdBrokerClient::CORRECT_VALUE); + } + + /** + * @When I submit an incorrect manager code + */ + public function iSubmitAnIncorrectManagerCode() + { + $this->submitMfaValue(FakeIdBrokerClient::INCORRECT_VALUE); + } + + /** + * @Given I provide credentials that have a manager code + */ + public function iProvideCredentialsThatHaveAManagerCode() + { + // See `development/idp-local/config/authsources.php` for options. + $this->username = 'has_mgr_code'; + $this->password = 'a'; + } + + /** + * @Then there should be a way to request a manager code + */ + public function thereShouldBeAWayToRequestAManagerCode() + { + $page = $this->session->getPage(); + $this->assertFormContains('name="send"', $page); + } + + /** + * @When I click the Cancel button + */ + public function iClickTheCancelButton() + { + $this->submitFormByClickingButtonNamed('cancel'); + } +} diff --git a/features/fakes/FakeIdBrokerClient.php b/features/fakes/FakeIdBrokerClient.php new file mode 100644 index 00000000..3f339b7c --- /dev/null +++ b/features/fakes/FakeIdBrokerClient.php @@ -0,0 +1,136 @@ + $id, + 'type' => 'backupcode', + 'label' => 'Printable Codes', + 'created_utc' => '2019-01-02T03:04:05Z', + 'data' => [ + 'count' => 4, + ], + ]; + } + + /** + * Create a new MFA configuration + * @param string $employee_id + * @param string $type + * @param string $label + * @return array|null + * @throws Exception + */ + public function mfaCreate($employee_id, $type, $label = null) + { + if (empty($employee_id)) { + throw new InvalidArgumentException('employee_id is required'); + } + + if ($type === 'backupcode') { + return [ + "id" => 1234, + "data" => [ + "00000000", + "11111111", + "22222222", + "33333333", + "44444444", + "55555555", + "66666666", + "77777777", + "88888888", + "99999999" + ], + ]; + } + + if ($type === 'manager') { + return [ + "id" => 5678, + "data" => [], + ]; + } + + throw new InvalidArgumentException(sprintf( + 'This Fake ID Broker class does not support creating %s MFA records.', + $type + )); + } + + /** + * Get a list of MFA configurations for given user + * @param string $employee_id + * @return array + * @throws ServiceException + */ + public function mfaList($employee_id) + { + return [ + [ + 'id' => 1, + 'type' => 'backupcode', + 'label' => 'Printable Codes', + 'created_utc' => '2019-04-02T16:02:14Z', + 'last_used_utc' => '2019-04-01T00:00:00Z', + 'data' => [ + 'count' => 10 + ], + ], + [ + 'id' => 2, + 'type' => 'totp', + 'label' => 'Smartphone App', + 'created_utc' => '2019-04-02T16:02:14Z', + 'last_used_utc' => '2019-04-01T00:00:00Z', + 'data' => [ + ], + ], + ]; + } +} diff --git a/features/mfa.feature b/features/mfa.feature index 2700429b..a3a00432 100644 --- a/features/mfa.feature +++ b/features/mfa.feature @@ -1,21 +1,241 @@ -Feature: Multi-Factor Authentication (MFA) module - - Scenario: Low on backup codes - - Scenario: Must set up MFA - - Scenario: New backup codes - - Scenario: Other MFAs - - Scenario: Out of backup codes - - Scenario: Prompt for MFA (backup code) - - Scenario: Prompt for MFA (manager) - - Scenario: Prompt for MFA (TOTP) - - Scenario: Prompt for MFA (U2F) - - Scenario: Send manager MFA +Feature: Prompt for MFA credentials + + Scenario: Don't prompt for MFA + Given I provide credentials that do not need MFA + When I login + Then I should end up at my intended destination + + Scenario: Needs MFA, but no MFA options are available + Given I provide credentials that need MFA but have no MFA options available + When I login + Then I should see a message that I have to set up MFA + And there should be a way to go set up MFA now + And there should NOT be a way to continue to my intended destination + + Scenario: Following the requirement to go set up MFA + Given I provide credentials that need MFA but have no MFA options available + And I login + When I click the set-up-MFA button + Then I should end up at the mfa-setup URL + And I should NOT be able to get to my intended destination + + Scenario: Needs MFA, has backup code option available + Given I provide credentials that need MFA and have backup codes available + When I login + Then I should see a prompt for a backup code + + Scenario: Needs MFA, has TOTP option available + Given I provide credentials that need MFA and have TOTP available + When I login + Then I should see a prompt for a TOTP code + + Scenario: Needs MFA, has WebAuthn option available + Given I provide credentials that need MFA and have WebAuthn available + And the user's browser supports WebAuthn + When I login + Then I should see a prompt for a WebAuthn security key + + Scenario: Accepting a (non-rate-limited) correct MFA value + Given I provide credentials that need MFA and have backup codes available + And I have logged in + When I submit a correct backup code + Then I should end up at my intended destination + + Scenario: Rejecting a (non-rate-limited) wrong MFA value + Given I provide credentials that need MFA and have backup codes available + And I have logged in + When I submit an incorrect backup code + Then I should see a message that it was incorrect + + Scenario: Blocking an incorrect MFA value while rate-limited + Given I provide credentials that have a rate-limited MFA + And I have logged in + When I submit an incorrect backup code + Then I should see a message that I have to wait before trying again + + Scenario: Blocking a correct MFA value while rate-limited + Given I provide credentials that have a rate-limited MFA + And I have logged in + When I submit a correct backup code + Then I should see a message that I have to wait before trying again + + Scenario: Warning when running low on backup codes + Given I provide credentials that need MFA and have 4 backup codes available + And I have logged in + When I submit a correct backup code + Then I should see a message that I am running low on backup codes + And I should be told I only have 3 backup codes left + And there should be a way to get more backup codes now + And there should be a way to continue to my intended destination + + Scenario: Requiring user to set up more backup codes when they run out and have no other MFA + Given I provide credentials that need MFA and have 1 backup code available and no other MFA + And I have logged in + When I submit a correct backup code + Then I should see a message that I have used up my backup codes + And there should be a way to get more backup codes now + And there should NOT be a way to continue to my intended destination + + Scenario: Warning user when they run out of backup codes but have other MFA options + Given I provide credentials that need MFA and have 1 backup code available plus some other MFA + And I have logged in + When I submit a correct backup code + Then I should see a message that I have used up my backup codes + And there should be a way to get more backup codes now + And there should be a way to continue to my intended destination + + Scenario: Obeying the nag to set up more backup codes when low + Given I provide credentials that need MFA and have 4 backup codes available + And I have logged in + And I submit a correct backup code + When I click the get-more-backup-codes button + Then I should be given more backup codes + And there should be a way to continue to my intended destination + + Scenario: Ignoring the nag to set up more backup codes when low + Given I provide credentials that need MFA and have 4 backup codes available + And I have logged in + And I submit a correct backup code + When I click the remind-me-later button + Then I should end up at my intended destination + + Scenario: Obeying the requirement to set up more backup codes when out + Given I provide credentials that need MFA and have 1 backup code available and no other MFA + And I have logged in + And I submit a correct backup code + When I click the get-more-backup-codes button + Then I should be given more backup codes + And there should be a way to continue to my intended destination + + Scenario: Obeying the nag to set up more backup codes when out + Given I provide credentials that need MFA and have 1 backup code available plus some other MFA + And I have logged in + And I submit a correct backup code + When I click the get-more-backup-codes button + Then I should be given more backup codes + And there should be a way to continue to my intended destination + + Scenario: Ignoring the nag to set up more backup codes when out + Given I provide credentials that need MFA and have 1 backup code available plus some other MFA + And I have logged in + And I submit a correct backup code + When I click the remind-me-later button + Then I should end up at my intended destination + + Scenario Outline: Defaulting to another option when WebAuthn is not supported + Given I provide credentials that have + And the user's browser + When I login + Then I should see a prompt for a + + Examples: + | WebAuthn? | TOTP? | backup codes? | supports WebAuthn or not | default MFA type | + | WebAuthn | | | supports WebAuthn | WebAuthn | + | WebAuthn | , TOTP | | supports WebAuthn | WebAuthn | + | WebAuthn | | , backup codes | supports WebAuthn | WebAuthn | + | WebAuthn | , TOTP | , backup codes | supports WebAuthn | WebAuthn | + | | TOTP | | supports WebAuthn | TOTP | + | | TOTP | , backup codes | supports WebAuthn | TOTP | + | | | backup codes | supports WebAuthn | backup code | + | WebAuthn | | | does not support WebAuthn | WebAuthn | + | WebAuthn | , TOTP | | does not support WebAuthn | TOTP | + | WebAuthn | | , backup codes | does not support WebAuthn | backup code | + | WebAuthn | , TOTP | , backup codes | does not support WebAuthn | TOTP | + | | TOTP | | does not support WebAuthn | TOTP | + | | TOTP | , backup codes | does not support WebAuthn | TOTP | + | | | backup codes | does not support WebAuthn | backup code | + + + Scenario Outline: Defaulting to the most recently used mfa option + Given I provide credentials that have a used + And and I have a more recently used + And the user's browser + When I login + Then I should see a prompt for a + + Examples: + | MFA type | recent MFA type | supports WebAuthn or not | default MFA type | + | WebAuthn | TOTP | supports WebAuthn | TOTP | + | TOTP | WebAuthn | supports WebAuthn | WebAuthn | + | TOTP | backup code | supports WebAuthn | backup code | + | backup code | TOTP | supports WebAuthn | TOTP | + | TOTP | WebAuthn | does not support WebAuthn | TOTP | + + Scenario: Defaulting to the manager code despite having a used mfa + Given I provide credentials that have a manager code, a WebAuthn and a more recently used TOTP + And the user's browser supports WebAuthn + When I login + Then I should see a prompt for a manager rescue code + + Scenario Outline: When to show the WebAuthn-not-supported error message + Given I provide credentials that have WebAuthn + And the user's browser + When I login + Then I see an error message about WebAuthn being unsupported + + Examples: + | supports WebAuthn or not | should or not | + | supports WebAuthn | should not | + | does not support WebAuthn | should | + + Scenario Outline: When to show the link to send a manager rescue code + Given I provide credentials that have + And the user a manager email + When I login + Then I see a link to send a code to the user's manager + + Examples: + | WebAuthn? | TOTP? | backup codes? | has or does not have | should or should not | + | WebAuthn | | | has | should | + | WebAuthn | , TOTP | | has | should | + | WebAuthn | | , backup codes | has | should | + | WebAuthn | , TOTP | , backup codes | has | should | + | | TOTP | | has | should | + | | TOTP | , backup codes | has | should | + | | | backup codes | has | should | + | WebAuthn | | | does not have | should not | + | WebAuthn | , TOTP | | does not have | should not | + | WebAuthn | | , backup codes | does not have | should not | + | WebAuthn | , TOTP | , backup codes | does not have | should not | + | | TOTP | | does not have | should not | + | | TOTP | , backup codes | does not have | should not | + | | | backup codes | does not have | should not | + + Scenario: Ask for a code to be sent to my manager + Given I provide credentials that have backup codes + And the user has a manager email + And I login + When I click the Request Assistance link + Then there should be a way to request a manager code + + Scenario: Submit a code sent to my manager at an earlier time + Given I provide credentials that have a manager code + And I login + When I submit the correct manager code + Then I should end up at my intended destination + + Scenario: Submit a correct manager code + Given I provide credentials that have backup codes + And the user has a manager email + And I login + And I click the Request Assistance link + And I click the Send a code link + When I submit the correct manager code + Then I should end up at my intended destination + + Scenario: Submit an incorrect manager code + Given I provide credentials that have backup codes + And the user has a manager email + And I login + And I click the Request Assistance link + And I click the Send a code link + When I submit an incorrect manager code + Then I should see a message that it was incorrect + + Scenario: Ask for assistance, but change my mind + Given I provide credentials that have backup codes + And the user has a manager email + And I login + And I click the Request Assistance link + When I click the Cancel button + Then I should see a prompt for a backup code diff --git a/local.env.dist b/local.env.dist index ac3f00f7..23c9cdbf 100644 --- a/local.env.dist +++ b/local.env.dist @@ -74,3 +74,9 @@ PROFILE_URL= # An absolute URL the user can go to to learn more about MFA. MFA_LEARN_MORE_URL= + +# MFA config + +# The URL to send a user to for setting up their MFA. +# Example: https://pw.example.com/#/2sv/intro +MFA_SETUP_URL= diff --git a/modules/mfa/lib/Auth/Process/Mfa.php b/modules/mfa/lib/Auth/Process/Mfa.php new file mode 100644 index 00000000..2eec44b5 --- /dev/null +++ b/modules/mfa/lib/Auth/Process/Mfa.php @@ -0,0 +1,956 @@ +initComposerAutoloader(); + assert('is_array($config)'); + + $this->loggerClass = $config['loggerClass'] ?? Psr3SamlLogger::class; + $this->logger = LoggerFactory::get($this->loggerClass); + + $this->loadValuesFromConfig($config, [ + 'mfaSetupUrl', + 'employeeIdAttr', + 'idBrokerAccessToken', + 'idBrokerBaseUri', + 'idpDomainName', + ]); + + $tempTrustedIpRanges = $config['idBrokerTrustedIpRanges'] ?? ''; + if (! empty($tempTrustedIpRanges)) { + $this->idBrokerTrustedIpRanges = explode(',', $tempTrustedIpRanges); + } + $this->idBrokerAssertValidIp = (bool)($config['idBrokerAssertValidIp'] ?? true); + $this->idBrokerClientClass = $config['idBrokerClientClass'] ?? IdBrokerClient::class; + } + + protected function loadValuesFromConfig($config, $attributes) + { + foreach ($attributes as $attribute) { + $this->$attribute = $config[$attribute] ?? null; + + self::validateConfigValue( + $attribute, + $this->$attribute, + $this->logger + ); + } + } + + /** + * Validate the given config value + * + * @param string $attribute The name of the attribute. + * @param mixed $value The value to check. + * @param LoggerInterface $logger The logger. + * @throws \Exception + */ + public static function validateConfigValue($attribute, $value, $logger) + { + if (empty($value) || !is_string($value)) { + $exception = new \Exception(sprintf( + 'The value we have for %s (%s) is empty or is not a string', + $attribute, + var_export($value, true) + ), 1507146042); + + $logger->critical($exception->getMessage()); + throw $exception; + } + } + + /** + * Get the specified attribute from the given state data. + * + * NOTE: If the attribute's data is an array, the first value will be + * returned. Otherwise, the attribute's data will simply be returned + * as-is. + * + * @param string $attributeName The name of the attribute. + * @param array $state The state data. + * @return mixed The attribute value, or null if not found. + */ + protected function getAttribute($attributeName, $state) + { + $attributeData = $state['Attributes'][$attributeName] ?? null; + + if (is_array($attributeData)) { + return $attributeData[0] ?? null; + } + + return $attributeData; + } + + /** + * Get all of the values for the specified attribute from the given state + * data. + * + * NOTE: If the attribute's data is an array, it will be returned as-is. + * Otherwise, it will be returned as a single-entry array of the data. + * + * @param string $attributeName The name of the attribute. + * @param array $state The state data. + * @return array|null The attribute's value(s), or null if the attribute was + * not found. + */ + protected function getAttributeAllValues($attributeName, $state) + { + $attributeData = $state['Attributes'][$attributeName] ?? null; + + return is_null($attributeData) ? null : (array)$attributeData; + } + + /** + * Get an ID Broker client. + * + * @param array $idBrokerConfig + * @return IdBrokerClient + */ + protected static function getIdBrokerClient($idBrokerConfig) + { + $clientClass = $idBrokerConfig['clientClass']; + $baseUri = $idBrokerConfig['baseUri']; + $accessToken = $idBrokerConfig['accessToken']; + $trustedIpRanges = $idBrokerConfig['trustedIpRanges']; + $assertValidIp = $idBrokerConfig['assertValidIp']; + + return new $clientClass($baseUri, $accessToken, [ + 'http_client_options' => [ + 'timeout' => 10, + ], + IdBrokerClient::TRUSTED_IPS_CONFIG => $trustedIpRanges, + IdBrokerClient::ASSERT_VALID_BROKER_IP_CONFIG => $assertValidIp, + ]); + } + + /** + * Get the MFA type to use based on the available options. + * + * @param array[] $mfaOptions The available MFA options. + * @param int $mfaId The ID of the desired MFA option. + * @return array The MFA option to use. + * @throws \InvalidArgumentException + * @throws \Exception + */ + public static function getMfaOptionById($mfaOptions, $mfaId) + { + if (empty($mfaId)) { + throw new \Exception('No MFA ID was provided.'); + } + + foreach ($mfaOptions as $mfaOption) { + if ((int)$mfaOption['id'] === (int)$mfaId) { + return $mfaOption; + } + } + + throw new \Exception( + 'No MFA option has an ID of ' . var_export($mfaId, true) + ); + } + + /** + * Get the MFA type to use based on the available options. + * + * @param array[] $mfaOptions The available MFA options. + * @param string $userAgent The User-Agent sent by the user's browser, used + * for detecting WebAuthn support. + * @return array The MFA option to use. + * @throws \InvalidArgumentException + * @throws \Exception + */ + public static function getMfaOptionToUse($mfaOptions, $userAgent) + { + if (empty($mfaOptions)) { + throw new \Exception('No MFA options were provided.'); + } + + $recentMfa = self::getMostRecentUsedMfaOption($mfaOptions); + $mfaTypePriority = ['manager']; + + if (LoginBrowser::supportsWebAuthn($userAgent)) { + if (isset($recentMfa['type'])) { + $mfaTypePriority[] = $recentMfa['type']; + } + // Doubling up a type shouldn't be a problem. + array_push($mfaTypePriority, 'webauthn', 'totp', 'backupcode'); + } else { + // Browser doesn't support webauthn, so ensure that's the last option + if (isset($recentMfa['type']) && $recentMfa['type'] != 'webauthn') { + $mfaTypePriority[] = $recentMfa['type']; + } + array_push($mfaTypePriority, 'totp', 'backupcode', 'webauthn'); + } + + foreach ($mfaTypePriority as $mfaType) { + foreach ($mfaOptions as $mfaOption) { + if ($mfaOption['type'] === $mfaType) { + return $mfaOption; + } + } + } + + return $mfaOptions[0]; + } + + /** + * Get the MFA to use based on the one used most recently. + * + * @param array[] $mfaOptions The available MFA options. + * @return ?array The MFA option to use. + */ + private static function getMostRecentUsedMfaOption($mfaOptions) { + $recentMfa = null; + $recentDate = '1991-01-01T00:00:00Z'; + + foreach ($mfaOptions as $mfaOption) { + if (isset($mfaOption['last_used_utc']) && $mfaOption['last_used_utc'] > $recentDate) { + $recentMfa = $mfaOption; + $recentDate = $mfaOption['last_used_utc']; + } + } + return $recentMfa; + } + + /** + * Get the number of backup codes that the user had left PRIOR to this login. + * + * @param array $mfaOptions The list of MFA options. + * @return int The number of backup codes that the user HAD (prior to this + * login). + */ + public static function getNumBackupCodesUserHad(array $mfaOptions) + { + $numBackupCodes = 0; + foreach ($mfaOptions as $mfaOption) { + $mfaType = $mfaOption['type'] ?? null; + if ($mfaType === 'backupcode') { + $numBackupCodes += intval($mfaOption['data']['count'] ?? 0); + } + } + + return $numBackupCodes; + } + + /** + * Get the template identifier (string) to use for the specified MFA type. + * + * @param string $mfaType The desired MFA type, such as 'webauthn', 'totp', or 'backupcode'. + * @return string + * @throws \InvalidArgumentException + */ + public static function getTemplateFor($mfaType) + { + $mfaOptionTemplates = [ + 'backupcode' => 'mfa:prompt-for-mfa-backupcode.php', + 'totp' => 'mfa:prompt-for-mfa-totp.php', + 'webauthn' => 'mfa:prompt-for-mfa-webauthn.php', + 'manager' => 'mfa:prompt-for-mfa-manager.php', + ]; + $template = $mfaOptionTemplates[$mfaType] ?? null; + + if ($template === null) { + throw new \InvalidArgumentException(sprintf( + 'No %s MFA template is available.', + var_export($mfaType, true) + ), 1507219338); + } + return $template; + } + + /** + * Return the saml:RelayState if it begins with "http" or "https". Otherwise + * return an empty string. + * + * @param array $state + * @return string + */ + protected static function getRelayStateUrl($state) + { + if (array_key_exists('saml:RelayState', $state)) { + $samlRelayState = $state['saml:RelayState']; + + if (strpos($samlRelayState, "http://") === 0) { + return $samlRelayState; + } + + if (strpos($samlRelayState, "https://") === 0) { + return $samlRelayState; + } + } + return ''; + } + + /** + * Get new Printable Backup Codes for the user, then redirect the user to a + * page showing the user their new codes. + * + * NOTE: This function never returns. + * + * @param array $state The state data. + * @param LoggerInterface $logger A PSR-3 compatible logger. + */ + public static function giveUserNewBackupCodes(array &$state, $logger) + { + try { + $idBrokerClient = self::getIdBrokerClient($state['idBrokerConfig']); + $newMfaRecord = $idBrokerClient->mfaCreate( + $state['employeeId'], + 'backupcode' + ); + $newBackupCodes = $newMfaRecord['data']; + + $logger->warning(json_encode([ + 'event' => 'New backup codes result: succeeded', + 'employeeId' => $state['employeeId'], + ])); + } catch (\Throwable $t) { + $logger->error(json_encode([ + 'event' => 'New backup codes result: failed', + 'employeeId' => $state['employeeId'], + 'error' => $t->getCode() . ': ' . $t->getMessage(), + ])); + } + + self::updateStateWithNewMfaData($state, $logger); + + $state['newBackupCodes'] = $newBackupCodes ?? null; + $stateId = State::saveState($state, self::STAGE_SENT_TO_NEW_BACKUP_CODES_PAGE); + $url = Module::getModuleURL('mfa/new-backup-codes.php'); + + HTTP::redirectTrustedURL($url, ['StateId' => $stateId]); + } + + protected static function hasMfaOptions($mfa) + { + return (count($mfa['options']) > 0); + } + + /** + * See if the user has any MFA options other than the specified type. + * + * @param string $excludedMfaType + * @param array $state + * @return bool + */ + public static function hasMfaOptionsOtherThan($excludedMfaType, $state) + { + $mfaOptions = $state['mfaOptions'] ?? []; + foreach ($mfaOptions as $mfaOption) { + if (strval($mfaOption['type']) !== strval($excludedMfaType)) { + return true; + } + } + return false; + } + + protected function initComposerAutoloader() + { + $path = __DIR__ . '/../../../vendor/autoload.php'; + if (file_exists($path)) { + require_once $path; + } + } + + protected static function isHeadedToMfaSetupUrl($state, $mfaSetupUrl) + { + if (array_key_exists('saml:RelayState', $state)) { + $currentDestination = self::getRelayStateUrl($state); + if (! empty($currentDestination)) { + return (strpos($currentDestination, $mfaSetupUrl) === 0); + } + } + return false; + } + + /** + * Validate the given MFA submission. If successful, this function + * will NOT return. If the submission does not pass validation, an error + * message will be returned. + * + * @param int $mfaId The ID of the MFA option used. + * @param string $employeeId The Employee ID that this MFA option belongs to. + * @param string $mfaSubmission The value of the MFA submission. + * @param array $state The array of state information. + * @param bool $rememberMe Whether or not to set remember me cookies + * @param LoggerInterface $logger A PSR-3 compatible logger. + * @param string $mfaType The type of the MFA ('webauthn', 'totp', 'backupcode'). + * @param string $rpOrigin The Relying Party Origin (for WebAuthn) + * @return void|string If validation fails, an error message to show to the + * end user will be returned. + * @throws \Sil\PhpEnv\EnvVarNotFoundException + */ + public static function validateMfaSubmission( + $mfaId, + $employeeId, + $mfaSubmission, + $state, + $rememberMe, + LoggerInterface $logger, + string $mfaType, + string $rpOrigin + ) { + if (empty($mfaId)) { + return 'No MFA ID was provided.'; + } elseif (empty($employeeId)) { + return 'No Employee ID was provided.'; + } elseif (empty($mfaSubmission)) { + return 'No MFA submission was provided.'; + } elseif (empty($rpOrigin)) { + return 'No RP Origin was provided.'; + } + + try { + $idBrokerClient = self::getIdBrokerClient($state['idBrokerConfig']); + $mfaDataFromBroker = $idBrokerClient->mfaVerify( + $mfaId, + $employeeId, + $mfaSubmission, + $rpOrigin + ); + } catch (\Throwable $t) { + $message = 'Something went wrong while we were trying to do the ' + . '2-step verification.'; + if ($t instanceof ServiceException) { + if ($t->httpStatusCode === 400) { + if ($mfaType === 'backupcode') { + return 'Incorrect 2-step verification code. Printable backup ' + . 'codes can only be used once, please try a different code.'; + } + return 'Incorrect 2-step verification code.'; + } elseif ($t->httpStatusCode === 429){ + $logger->error(json_encode([ + 'event' => 'MFA is rate-limited', + 'employeeId' => $employeeId, + 'mfaId' => $mfaId, + 'mfaType' => $mfaType, + ])); + return 'There have been too many wrong answers recently. ' + . 'Please wait a minute, then try again.'; + } else { + $message .= ' (code ' . $t->httpStatusCode . ')'; + return $message; + } + } + + $logger->critical($t->getCode() . ': ' . $t->getMessage()); + return $message; + } + + self::updateStateWithNewMfaData($state, $logger); + + // Set remember me cookies if requested + if ($rememberMe) { + self::setRememberMeCookies($state['employeeId'], $state['mfaOptions']); + } + + $logger->warning(json_encode([ + 'event' => 'MFA validation result: success', + 'employeeId' => $employeeId, + 'mfaType' => $mfaType, + ])); + + // Handle situations where the user is running low on backup codes. + if ($mfaType === 'backupcode') { + $numBackupCodesUserHad = self::getNumBackupCodesUserHad( + $state['mfaOptions'] ?? [] + ); + $numBackupCodesRemaining = $numBackupCodesUserHad - 1; + + if ($numBackupCodesRemaining <= 0) { + self::redirectToOutOfBackupCodesMessage($state, $employeeId); + throw new \Exception('Failed to send user to out-of-backup-codes page.'); + } elseif ($numBackupCodesRemaining < 4) { + self::redirectToLowOnBackupCodesNag( + $state, + $employeeId, + $numBackupCodesRemaining + ); + throw new \Exception('Failed to send user to low-on-backup-codes page.'); + } + } + + /* + * If the user had to use a manager code, show the profile review page. + */ + if ($mfaType === 'manager' && isset($state['Attributes']['profile_review'])) { + $state['Attributes']['profile_review'] = 'yes'; + } + + unset($state['Attributes']['manager_email']); + + // The following function call will never return. + ProcessingChain::resumeProcessing($state); + throw new \Exception('Failed to resume processing auth proc chain.'); + } + + /** + * Redirect the user to set up MFA. + * + * @param array $state + */ + public static function redirectToMfaSetup(&$state) + { + $mfaSetupUrl = $state['mfaSetupUrl']; + + // Tell the MFA-setup URL where the user is ultimately trying to go (if known). + $currentDestination = self::getRelayStateUrl($state); + if (! empty($currentDestination)) { + $mfaSetupUrl = HTTP::addURLParameters( + $mfaSetupUrl, + ['returnTo' => $currentDestination] + ); + } + + $logger = LoggerFactory::getAccordingToState($state); + $logger->warning(sprintf( + 'mfa: Sending Employee ID %s to set up MFA at %s', + var_export($state['employeeId'] ?? null, true), + var_export($mfaSetupUrl, true) + )); + + HTTP::redirectTrustedURL($mfaSetupUrl); + } + + /** + * Apply this AuthProc Filter. It will either return (indicating that it + * has completed) or it will redirect the user, in which case it will + * later call `SimpleSAML\Auth\ProcessingChain::resumeProcessing($state)`. + * + * @param array &$state The current state. + */ + public function process(&$state) + { + // Get the necessary info from the state data. + $employeeId = $this->getAttribute($this->employeeIdAttr, $state); + $mfa = $this->getAttributeAllValues('mfa', $state); + $isHeadedToMfaSetupUrl = self::isHeadedToMfaSetupUrl( + $state, + $this->mfaSetupUrl + ); + + // Record to the state what logger class to use. + $state['loggerClass'] = $this->loggerClass; + + // Add to the state any config data we may need for the low-on/out-of + // backup codes pages. + $state['mfaSetupUrl'] = $this->mfaSetupUrl; + + if (self::shouldPromptForMfa($mfa)) { + if (self::hasMfaOptions($mfa)) { + $this->redirectToMfaPrompt($state, $employeeId, $mfa['options']); + return; + } + + if (! $isHeadedToMfaSetupUrl) { + $this->redirectToMfaNeededMessage($state, $employeeId, $this->mfaSetupUrl); + return; + } + } + + unset($state['Attributes']['manager_email']); + } + + /** + * Redirect the user to a page telling them they must set up MFA. + * + * @param array $state The state data. + * @param string $employeeId The Employee ID of the user account. + * @param string $mfaSetupUrl URL to MFA setup process + */ + protected function redirectToMfaNeededMessage(&$state, $employeeId, $mfaSetupUrl) + { + assert('is_array($state)'); + + $this->logger->info(sprintf( + 'mfa: Redirecting Employee ID %s to must-set-up-MFA message.', + var_export($employeeId, true) + )); + + /* Save state and redirect. */ + $state['employeeId'] = $employeeId; + $state['mfaSetupUrl'] = $mfaSetupUrl; + + $stateId = State::saveState($state, self::STAGE_SENT_TO_MFA_NEEDED_MESSAGE); + $url = Module::getModuleURL('mfa/must-set-up-mfa.php'); + + HTTP::redirectTrustedURL($url, ['StateId' => $stateId]); + } + + /** + * Redirect the user to the appropriate MFA-prompt page. + * + * @param array $state The state data. + * @param string $employeeId The Employee ID of the user account. + * @param array $mfaOptions Array of MFA options + * @throws \Exception + */ + protected function redirectToMfaPrompt(&$state, $employeeId, $mfaOptions) + { + assert('is_array($state)'); + + /** @todo Check for valid remember-me cookies here rather doing a redirect first. */ + + $state['mfaOptions'] = $mfaOptions; + $state['managerEmail'] = self::getManagerEmail($state); + $state['idBrokerConfig'] = [ + 'accessToken' => $this->idBrokerAccessToken, + 'assertValidIp' => $this->idBrokerAssertValidIp, + 'baseUri' => $this->idBrokerBaseUri, + 'clientClass' => $this->idBrokerClientClass, + 'trustedIpRanges' => $this->idBrokerTrustedIpRanges, + ]; + + $this->logger->info(sprintf( + 'mfa: Redirecting Employee ID %s to MFA prompt.', + var_export($employeeId, true) + )); + + /* Save state and redirect. */ + $state['employeeId'] = $employeeId; + $state['rpOrigin'] = 'https://' . $this->idpDomainName; + + $id = State::saveState($state, self::STAGE_SENT_TO_MFA_PROMPT); + $url = Module::getModuleURL('mfa/prompt-for-mfa.php'); + + $mfaOption = self::getMfaOptionToUse($mfaOptions, LoginBrowser::getUserAgent()); + + HTTP::redirectTrustedURL($url, [ + 'mfaId' => $mfaOption['id'], + 'StateId' => $id, + ]); + } + + /** + * Validate that remember me cookie values are legit and valid + * @param string $cookieHash + * @param string $expireDate + * @param $mfaOptions + * @param $state + * @return bool + * @throws \Sil\PhpEnv\EnvVarNotFoundException + */ + public static function isRememberMeCookieValid( + string $cookieHash, + string $expireDate, + $mfaOptions, + $state + ): bool { + $rememberSecret = Env::requireEnv('REMEMBER_ME_SECRET'); + if (! empty($cookieHash) && ! empty($expireDate) && is_numeric($expireDate)) { + // Check if value of expireDate is in future + if ((int)$expireDate > time()) { + $expectedString = self::generateRememberMeCookieString($rememberSecret, $state['employeeId'], $expireDate, $mfaOptions); + return password_verify($expectedString, $cookieHash); + } + } + + return false; + } + + /** + * Generate and return a string to be hashed for remember me cookie + * @param string $rememberSecret + * @param string $employeeId + * @param int $expireDate + * @param array $mfaOptions + * @return string + */ + public static function generateRememberMeCookieString( + string $rememberSecret, + string $employeeId, + int $expireDate, + array $mfaOptions + ): string { + $allMfaIds = ''; + foreach ($mfaOptions as $opt) { + if ($opt['type'] !== 'manager') { + $allMfaIds .= $opt['id']; + } + } + + $string = $rememberSecret . $employeeId . $expireDate . $allMfaIds; + return $string; + } + + /** + * Redirect the user to a page telling them they are running low on backup + * codes and encouraging them to create more now. + * + * NOTE: This function never returns. + * + * @param array $state The state data. + * @param string $employeeId The Employee ID of the user account. + * @param int $numBackupCodesRemaining The number of backup codes that the + * user has left (now that they have used up one for this login). + */ + protected static function redirectToLowOnBackupCodesNag( + array &$state, + $employeeId, + $numBackupCodesRemaining + ) { + $state['employeeId'] = $employeeId; + $state['numBackupCodesRemaining'] = $numBackupCodesRemaining; + + $stateId = State::saveState($state, self::STAGE_SENT_TO_LOW_ON_BACKUP_CODES_NAG); + $url = Module::getModuleURL('mfa/low-on-backup-codes.php'); + + HTTP::redirectTrustedURL($url, ['StateId' => $stateId]); + } + + /** + * Redirect the user to a page telling them they just used up their last + * backup code. + * + * NOTE: This function never returns. + * + * @param array $state The state data. + * @param string $employeeId The Employee ID of the user account. + */ + protected static function redirectToOutOfBackupCodesMessage(array &$state, $employeeId) + { + $state['employeeId'] = $employeeId; + + $stateId = State::saveState($state, self::STAGE_SENT_TO_OUT_OF_BACKUP_CODES_MESSAGE); + $url = Module::getModuleURL('mfa/out-of-backup-codes.php'); + + HTTP::redirectTrustedURL($url, ['StateId' => $stateId]); + } + + /** + * Set cookies c1 and c2 + * @param string $employeeId + * @param array $mfaOptions + * @param string $rememberDuration + * @throws \Sil\PhpEnv\EnvVarNotFoundException + */ + public static function setRememberMeCookies( + string $employeeId, + array $mfaOptions, + string $rememberDuration = '+30 days' + ) { + $rememberSecret = Env::requireEnv('REMEMBER_ME_SECRET'); + $secureCookie = Env::get('SECURE_COOKIE', true); + $expireDate = strtotime($rememberDuration); + $cookieString = self::generateRememberMeCookieString($rememberSecret, $employeeId, $expireDate, $mfaOptions); + $cookieHash = password_hash($cookieString, PASSWORD_DEFAULT); + setcookie('c1', base64_encode($cookieHash), $expireDate, '/', null, $secureCookie, true); + setcookie('c2', $expireDate, $expireDate, '/', null, $secureCookie, true); + } + + protected static function shouldPromptForMfa($mfa) + { + return (strtolower($mfa['prompt']) !== 'no'); + } + + /** + * Send a rescue code to the manager, then redirect the user to a page where they + * can enter the code. + * + * NOTE: This function never returns. + * + * @param array $state The state data. + * @param LoggerInterface $logger A PSR-3 compatible logger. + */ + public static function sendManagerCode(array &$state, $logger) + { + try { + $idBrokerClient = self::getIdBrokerClient($state['idBrokerConfig']); + $mfaOption = $idBrokerClient->mfaCreate($state['employeeId'], 'manager'); + $mfaOption['type'] = 'manager'; + + $logger->warning(json_encode([ + 'event' => 'Manager rescue code sent', + 'employeeId' => $state['employeeId'], + ])); + } catch (\Throwable $t) { + $logger->error(json_encode([ + 'event' => 'Manager rescue code: failed', + 'employeeId' => $state['employeeId'], + 'error' => $t->getCode() . ': ' . $t->getMessage(), + ])); + } + + $mfaOptions = $state['mfaOptions']; + + /* + * Add this option into the list, giving it a key so `mfaOptions` doesn't get multiple entries + * if the user tries multiple times. + */ + $mfaOptions['manager'] = $mfaOption; + $state['mfaOptions'] = $mfaOptions; + $state['managerEmail'] = self::getManagerEmail($state); + $stateId = State::saveState($state, self::STAGE_SENT_TO_MFA_PROMPT); + + $url = Module::getModuleURL('mfa/prompt-for-mfa.php'); + + HTTP::redirectTrustedURL($url, ['mfaId' => $mfaOption['id'], 'StateId' => $stateId]); + } + + /** + * Get masked copy of manager_email, or null if it isn't available. + * + * @param array $state + * @return string|null + */ + public static function getManagerEmail($state) + { + $managerEmail = $state['Attributes']['manager_email'] ?? ['']; + if (empty($managerEmail[0])) { + return null; + } + return self::maskEmail($managerEmail[0]); + } + + /** + * Get the manager MFA, if it exists. Otherwise, return null. + * + * @param array[] $mfaOptions The available MFA options. + * @return array The manager MFA. + * @throws \InvalidArgumentException + */ + public static function getManagerMfa($mfaOptions) + { + foreach ($mfaOptions as $mfaOption) { + if ($mfaOption['type'] === 'manager') { + return $mfaOption; + } + } + + return null; + } + + /** + * @param string $email an email address + * @return string with most letters changed to asterisks + */ + public static function maskEmail($email) + { + list($part1, $domain) = explode('@', $email); + $newEmail = ''; + $useRealChar = true; + + /* + * Replace all characters with '*', except + * the first one, the last one, underscores and each + * character that follows and underscore. + */ + foreach (str_split($part1) as $nextChar) { + if ($useRealChar) { + $newEmail .= $nextChar; + $useRealChar = false; + } else if ($nextChar === '_') { + $newEmail .= $nextChar; + $useRealChar = true; + } else { + $newEmail .= '*'; + } + } + + // replace the last * with the last real character + $newEmail = substr($newEmail, 0, -1); + $newEmail .= substr($part1, -1); + $newEmail .= '@'; + + /* + * Add an '*' for each of the characters of the domain, except + * for the first character of each part and the . + */ + list($domainA, $domainB) = explode('.', $domain); + + $newEmail .= substr($domainA, 0, 1); + $newEmail .= str_repeat('*', strlen($domainA) - 1); + $newEmail .= '.'; + + $newEmail .= substr($domainB, 0, 1); + $newEmail .= str_repeat('*', strlen($domainB) - 1); + return $newEmail; + } + + /** + * @param array $state + * @param LoggerInterface $logger + */ + protected static function updateStateWithNewMfaData(&$state, $logger) + { + $idBrokerClient = self::getIdBrokerClient($state['idBrokerConfig']); + + $log = [ + 'event' => 'Update state with new mfa data', + ]; + + try { + $newMfaOptions = $idBrokerClient->mfaList($state['employeeId']); + } catch (\Exception $e) { + $log['status'] = 'failed: id-broker exception'; + $logger->error(json_encode($log)); + return; + } + + if (empty($newMfaOptions)) { + $log['status'] = 'failed: no data provided'; + $logger->warning(json_encode($log)); + return; + } + + $state['Attributes']['mfa']['options'] = $newMfaOptions; + + $log['data'] = $newMfaOptions; + $log['status'] = 'updated'; + $logger->warning(json_encode($log)); + } +} diff --git a/modules/mfa/src/Assert.php b/modules/mfa/src/Assert.php new file mode 100644 index 00000000..eadbbb20 --- /dev/null +++ b/modules/mfa/src/Assert.php @@ -0,0 +1,57 @@ +getName(); + + // For now, simply set these to approximate the results shown on caniuse: + // https://caniuse.com/?search=webauthn + return in_array( + $browserName, + [ + Browser::CHROME, + Browser::SAFARI, + Browser::EDGE, + Browser::FIREFOX, + Browser::OPERA, + ], + true + ); + } +} diff --git a/modules/mfa/templates/low-on-backup-codes.php b/modules/mfa/templates/low-on-backup-codes.php new file mode 100644 index 00000000..bf1f7a37 --- /dev/null +++ b/modules/mfa/templates/low-on-backup-codes.php @@ -0,0 +1,21 @@ +data['header'] = 'Almost out of Printable Backup Codes'; +$this->includeAtTemplateBase('includes/header.php'); + +$numBackupCodesRemaining = $this->data['numBackupCodesRemaining']; +?> +

+ You are almost out of Printable Backup Codes. + You only have remaining. +

+
+ + + +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/must-set-up-mfa.php b/modules/mfa/templates/must-set-up-mfa.php new file mode 100644 index 00000000..dbb5c4bd --- /dev/null +++ b/modules/mfa/templates/must-set-up-mfa.php @@ -0,0 +1,16 @@ +data['header'] = 'Set up 2-Step Verification'; +$this->includeAtTemplateBase('includes/header.php'); + +?> +

+ Your account requires additional security. + You must set up 2-step verification at this time. +

+
+ +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/new-backup-codes.php b/modules/mfa/templates/new-backup-codes.php new file mode 100644 index 00000000..5624a681 --- /dev/null +++ b/modules/mfa/templates/new-backup-codes.php @@ -0,0 +1,40 @@ +data['header'] = 'New Printable Backup Codes'; +$this->includeAtTemplateBase('includes/header.php'); + +$newBackupCodes = $this->data['newBackupCodes']; +$mfaSetupUrl = $this->data['mfaSetupUrl']; +?> + + +

+ Something went wrong while we were trying to get more Printable Backup Codes for you. +

+

+ We are sorry for the inconvenience. After you finish logging in, please + check your 2-Step Verification methods here:
+ +

+ +

+ Here are your new Printable Backup Codes. Remember to keep them + secret (like a password) and store them somewhere safe. +

+

+

+ Once you have stored them somewhere safe, you are welcome to click the + button below to continue to where you were going. +

+ + +
+ +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/out-of-backup-codes.php b/modules/mfa/templates/out-of-backup-codes.php new file mode 100644 index 00000000..27005578 --- /dev/null +++ b/modules/mfa/templates/out-of-backup-codes.php @@ -0,0 +1,37 @@ +data['header'] = 'Last Printable Backup Code'; +$this->includeAtTemplateBase('includes/header.php'); + +$hasOtherMfaOptions = $this->data['hasOtherMfaOptions']; +?> +

+ You just used your last Printable Backup Code. +

+ + +

+ We recommend you get more now so that you will have some next time we ask + you for one. Otherwise, you will need to use a different option (such as a + Security Key or Smartphone App) the next time we ask you for 2-Step Verification. +

+ +

+ Since you do not have any other 2-Step Verification options set up yet, + you need to get more Printable Backup Codes now so that you will have some + next time we ask you for one. +

+ + +
+ + + + + +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/prompt-for-mfa-backupcode.php b/modules/mfa/templates/prompt-for-mfa-backupcode.php new file mode 100644 index 00000000..3fb79ef5 --- /dev/null +++ b/modules/mfa/templates/prompt-for-mfa-backupcode.php @@ -0,0 +1,57 @@ +data['header'] = '2-Step Verification'; +$this->includeAtTemplateBase('includes/header.php'); + +if (! empty($this->data['errorMessage'])) { + ?> +

+ Oops!
+ data['errorMessage']); ?> +

+ +
+

Printable Backup Code

+

+ Each code can only be used once, so the code you enter this time will be + used up and will not be available again. +

+

+ Enter code: +
+ + +
+ +

+ data['mfaOptions']) > 1): ?> +

+ Don't have your printable backup codes handy? You may also use: +

+
    + data['mfaOptions'] as $mfaOpt) { + if ($mfaOpt['type'] != 'backupcode') { + ?> +
  • + +
+ + data['managerEmail'])): ?> +

+ Can't use any of your 2-Step Verification options? + + Click here for assistance. +

+ +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/prompt-for-mfa-manager.php b/modules/mfa/templates/prompt-for-mfa-manager.php new file mode 100644 index 00000000..643f9631 --- /dev/null +++ b/modules/mfa/templates/prompt-for-mfa-manager.php @@ -0,0 +1,49 @@ +data['header'] = '2-Step Verification'; +$this->includeAtTemplateBase('includes/header.php'); + +if (! empty($this->data['errorMessage'])) { + ?> +

+ Oops!
+ data['errorMessage']); ?> +

+ +
+

Manager Rescue Code

+

+ When you receive your code from your manager, enter it here. +

+

+ Enter code: +
+ + +
+ +

+ data['mfaOptions']) > 1): ?> +

+ You may also use: +

+
    + data['mfaOptions'] as $mfaOpt) { + if ($mfaOpt['type'] != 'manager') { + ?> +
  • + +
+ +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/prompt-for-mfa-totp.php b/modules/mfa/templates/prompt-for-mfa-totp.php new file mode 100644 index 00000000..3603c8e9 --- /dev/null +++ b/modules/mfa/templates/prompt-for-mfa-totp.php @@ -0,0 +1,53 @@ +data['header'] = '2-Step Verification'; +$this->includeAtTemplateBase('includes/header.php'); + +if (! empty($this->data['errorMessage'])) { + ?> +

+ Oops!
+ data['errorMessage']); ?> +

+ +
+

Smartphone App

+

+ Enter 6-digit code: +
+ + +
+ +

+ data['mfaOptions']) > 1): ?> +

+ Don't have your smartphone app handy? You may also use: +

+
    + data['mfaOptions'] as $mfaOpt) { + if ($mfaOpt['type'] != 'totp') { + ?> +
  • + +
+ + data['managerEmail'])): ?> +

+ Can't use any of your 2-Step Verification options? + + Click here for assistance. +

+ +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/prompt-for-mfa-webauthn.php b/modules/mfa/templates/prompt-for-mfa-webauthn.php new file mode 100644 index 00000000..12478937 --- /dev/null +++ b/modules/mfa/templates/prompt-for-mfa-webauthn.php @@ -0,0 +1,79 @@ +data['header'] = '2-Step Verification'; +$this->includeAtTemplateBase('includes/header.php'); +?> +data['supportsWebAuthn']): ?> + + + + +
+

USB Security Key

+ data['supportsWebAuthn']): ?> +

Please insert your security key and press its button.

+

+ + +
+ + + +

+ +

+ USB Security Keys are not supported in your current browser. + Please consider a more secure browser like + Google Chrome. +

+ + + data['mfaOptions']) > 1): ?> +

+ Don't have your security key handy? You may also use: +

+
    + data['mfaOptions'] as $mfaOpt) { + if ($mfaOpt['type'] != 'webauthn') { + ?> +
  • + +
+ + data['managerEmail'])): ?> +

+ Can't use any of your 2-Step Verification options? + + Click here for assistance. +

+ +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/templates/send-manager-mfa.php b/modules/mfa/templates/send-manager-mfa.php new file mode 100644 index 00000000..4a274ac6 --- /dev/null +++ b/modules/mfa/templates/send-manager-mfa.php @@ -0,0 +1,20 @@ +data['header'] = 'Send manager backup code'; +$this->includeAtTemplateBase('includes/header.php'); + +?> +

+ You can send a backup code to your manager to serve as an + additional 2-Step Verification option. + The email address on file (masked for privacy) is data['managerEmail'] ?> +

+
+ + +
+includeAtTemplateBase('includes/footer.php'); diff --git a/modules/mfa/www/low-on-backup-codes.php b/modules/mfa/www/low-on-backup-codes.php new file mode 100644 index 00000000..d8511ae5 --- /dev/null +++ b/modules/mfa/www/low-on-backup-codes.php @@ -0,0 +1,40 @@ +data['numBackupCodesRemaining'] = $state['numBackupCodesRemaining']; +$t->show(); + +$logger->info(sprintf( + 'mfa: Told Employee ID %s they are low on backup codes.', + $state['employeeId'] +)); diff --git a/modules/mfa/www/must-set-up-mfa.php b/modules/mfa/www/must-set-up-mfa.php new file mode 100644 index 00000000..a351c9bc --- /dev/null +++ b/modules/mfa/www/must-set-up-mfa.php @@ -0,0 +1,32 @@ +show(); + +$logger->info(sprintf( + 'mfa: Told Employee ID %s they they must set up MFA.', + $state['employeeId'] +)); diff --git a/modules/mfa/www/new-backup-codes.php b/modules/mfa/www/new-backup-codes.php new file mode 100644 index 00000000..01a4d01c --- /dev/null +++ b/modules/mfa/www/new-backup-codes.php @@ -0,0 +1,39 @@ +data['mfaSetupUrl'] = $state['mfaSetupUrl']; +$t->data['newBackupCodes'] = $state['newBackupCodes'] ?? []; +$t->show(); diff --git a/modules/mfa/www/out-of-backup-codes.php b/modules/mfa/www/out-of-backup-codes.php new file mode 100644 index 00000000..dc9e172c --- /dev/null +++ b/modules/mfa/www/out-of-backup-codes.php @@ -0,0 +1,42 @@ +data['hasOtherMfaOptions'] = $hasOtherMfaOptions; +$t->show(); + +$logger->info(sprintf( + 'mfa: Told Employee ID %s they are out of backup codes%s.', + $state['employeeId'], + $hasOtherMfaOptions ? '' : ' and must set up more' +)); diff --git a/modules/mfa/www/prompt-for-mfa.php b/modules/mfa/www/prompt-for-mfa.php new file mode 100644 index 00000000..d5f1cae3 --- /dev/null +++ b/modules/mfa/www/prompt-for-mfa.php @@ -0,0 +1,114 @@ +warning(json_encode([ + 'event' => 'MFA skipped due to valid remember-me cookie', + 'employeeId' => $state['employeeId'], + ])); + + unset($state['Attributes']['manager_email']); + + // This condition should never return + ProcessingChain::resumeProcessing($state); + throw new \Exception('Failed to resume processing auth proc chain.'); +} + +$mfaId = filter_input(INPUT_GET, 'mfaId'); +$userAgent = LoginBrowser::getUserAgent(); + +if (empty($mfaId)) { + $logger->critical(json_encode([ + 'event' => 'MFA ID missing in URL. Choosing one and doing a redirect.', + 'employeeId' => $state['employeeId'], + ])); + + // Pick an MFA ID and do a redirect to put that into the URL. + $mfaOption = Mfa::getMfaOptionToUse($mfaOptions, $userAgent); + $moduleUrl = SimpleSAML\Module::getModuleURL('mfa/prompt-for-mfa.php', [ + 'mfaId' => $mfaOption['id'], + 'StateId' => $stateId, + ]); + HTTP::redirectTrustedURL($moduleUrl); + return; +} +$mfaOption = Mfa::getMfaOptionById($mfaOptions, $mfaId); + +// If the user has submitted their MFA value... +if (filter_has_var(INPUT_POST, 'submitMfa')) { + $mfaSubmission = filter_input(INPUT_POST, 'mfaSubmission'); + if (substr($mfaSubmission, 0, 1) == '{') { + $mfaSubmission = json_decode($mfaSubmission, true); + } + + $rememberMe = filter_input(INPUT_POST, 'rememberMe') ?? false; + + // NOTE: This will only return if validation fails. + $errorMessage = Mfa::validateMfaSubmission( + $mfaId, + $state['employeeId'], + $mfaSubmission, + $state, + $rememberMe, + $logger, + $mfaOption['type'], + $state['rpOrigin'] + ); + + $logger->warning(json_encode([ + 'event' => 'MFA validation result: failed', + 'employeeId' => $state['employeeId'], + 'mfaType' => $mfaOption['type'], + 'error' => $errorMessage, + ])); +} + +$globalConfig = Configuration::getInstance(); + +$mfaTemplateToUse = Mfa::getTemplateFor($mfaOption['type']); + +$t = new Template($globalConfig, $mfaTemplateToUse); +$t->data['errorMessage'] = $errorMessage ?? null; +$t->data['mfaOption'] = $mfaOption; +$t->data['mfaOptions'] = $mfaOptions; +$t->data['stateId'] = $stateId; +$t->data['supportsWebAuthn'] = LoginBrowser::supportsWebAuthn($userAgent); +$t->data['managerEmail'] = $state['managerEmail']; +$t->show(); + +$logger->info(json_encode([ + 'event' => 'Prompted user for MFA', + 'employeeId' => $state['employeeId'], + 'mfaType' => $mfaOption['type'], +])); diff --git a/modules/mfa/www/send-manager-mfa.php b/modules/mfa/www/send-manager-mfa.php new file mode 100644 index 00000000..4eae0906 --- /dev/null +++ b/modules/mfa/www/send-manager-mfa.php @@ -0,0 +1,45 @@ + $stateId, + ]); + HTTP::redirectTrustedURL($moduleUrl); +} + +$globalConfig = Configuration::getInstance(); + +$t = new Template($globalConfig, 'mfa:send-manager-mfa.php'); +$t->data['stateId'] = $stateId; +$t->data['managerEmail'] = $state['managerEmail']; +$t->show(); + +$logger->info(json_encode([ + 'event' => 'offer to send manager code', + 'employeeId' => $state['employeeId'], +])); diff --git a/modules/mfa/www/simplewebauthn/LICENSE.md b/modules/mfa/www/simplewebauthn/LICENSE.md new file mode 100644 index 00000000..70730ac2 --- /dev/null +++ b/modules/mfa/www/simplewebauthn/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Matthew Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/mfa/www/simplewebauthn/browser.js b/modules/mfa/www/simplewebauthn/browser.js new file mode 100644 index 00000000..8b1de96e --- /dev/null +++ b/modules/mfa/www/simplewebauthn/browser.js @@ -0,0 +1,2 @@ +/* [@simplewebauthn/browser] Version: 4.1.0 - Wednesday, September 1st, 2021, 9:11:50 AM */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let n="";for(const e of t)n+=String.fromCharCode(e);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function n(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),n=(4-t.length%4)%4,r=t.padEnd(t.length+n,"="),o=atob(r),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;e Date: Tue, 7 May 2024 15:57:28 +0800 Subject: [PATCH 02/18] use the local mfa module code and remove external module from composer --- composer.json | 1 - composer.lock | 110 +-------------------------------------------- docker-compose.yml | 7 +++ 3 files changed, 8 insertions(+), 110 deletions(-) diff --git a/composer.json b/composer.json index fa3b6c49..2f282785 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "simplesamlphp/simplesamlphp": "^1.19.6", "simplesamlphp/composer-module-installer": "1.1.8", "silinternational/simplesamlphp-module-silauth": "^7.1.1", - "silinternational/simplesamlphp-module-mfa": "^5.2.1", "silinternational/ssp-utilities": "^1.1.0", "silinternational/simplesamlphp-module-material": "^8.1.1", "silinternational/simplesamlphp-module-sildisco": "^4.0.0", diff --git a/composer.lock b/composer.lock index 2ac04f9b..8fd70763 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b4f4532a5a284c0b780aaa48860974ae", + "content-hash": "b7ba67875da2080d961345975daa92cf", "packages": [ { "name": "aws/aws-crt-php", @@ -2984,60 +2984,6 @@ }, "time": "2023-06-12T17:37:14+00:00" }, - { - "name": "silinternational/simplesamlphp-module-mfa", - "version": "5.2.1", - "source": { - "type": "git", - "url": "https://github.com/silinternational/simplesamlphp-module-mfa.git", - "reference": "2179f28e5e72e1f14e27d10025cdac5e44b45398" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/silinternational/simplesamlphp-module-mfa/zipball/2179f28e5e72e1f14e27d10025cdac5e44b45398", - "reference": "2179f28e5e72e1f14e27d10025cdac5e44b45398", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": ">=7.2", - "silinternational/idp-id-broker-php-client": "^4.0.0", - "silinternational/php-env": "^2.1 || ^3.0", - "silinternational/psr3-adapters": "^1.1 || ^2.0 || ^3.0", - "simplesamlphp/simplesamlphp": "~1.17.7 || ~1.18.5 || ~1.19.0", - "sinergi/browser-detector": "^6.1" - }, - "require-dev": { - "behat/behat": "^3.3", - "behat/mink": "^1.7", - "behat/mink-goutte-driver": "^1.2", - "phpunit/phpunit": "^8.4", - "roave/security-advisories": "dev-master" - }, - "type": "simplesamlphp-module", - "autoload": { - "psr-4": { - "Sil\\SspMfa\\": "src/", - "Sil\\SspMfa\\Behat\\": "features/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-2.1-or-later" - ], - "authors": [ - { - "name": "Matt Henderson", - "email": "matt_henderson@sil.org" - } - ], - "description": "A simpleSAMLphp module for prompting the user for MFA credentials (such as a TOTP code, etc.).", - "support": { - "issues": "https://github.com/silinternational/simplesamlphp-module-mfa/issues", - "source": "https://github.com/silinternational/simplesamlphp-module-mfa/tree/5.2.1" - }, - "time": "2023-06-15T13:38:51+00:00" - }, { "name": "silinternational/simplesamlphp-module-silauth", "version": "7.1.1", @@ -5040,60 +4986,6 @@ "abandoned": true, "time": "2022-11-28T16:34:29+00:00" }, - { - "name": "sinergi/browser-detector", - "version": "6.1.4", - "source": { - "type": "git", - "url": "https://github.com/sinergi/php-browser-detector.git", - "reference": "4927f7c2bedc48b68f183bd420aa3549c59e133b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sinergi/php-browser-detector/zipball/4927f7c2bedc48b68f183bd420aa3549c59e133b", - "reference": "4927f7c2bedc48b68f183bd420aa3549c59e133b", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.0 || ^9.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Sinergi\\BrowserDetector\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gabriel Bull", - "email": "me@gabrielbull.com" - }, - { - "name": "Chris Schuld" - } - ], - "description": "Detecting the user's browser, operating system and language.", - "keywords": [ - "browser", - "detection", - "language", - "operating system", - "os" - ], - "support": { - "issues": "https://github.com/sinergi/php-browser-detector/issues", - "source": "https://github.com/sinergi/php-browser-detector/tree/6.1.4" - }, - "abandoned": true, - "time": "2021-09-23T13:51:44+00:00" - }, { "name": "symfony/cache", "version": "v5.4.25", diff --git a/docker-compose.yml b/docker-compose.yml index cecf2a4b..e6508f7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - ./development/ssp/run-debug.sh:/data/run-debug.sh # Local modules + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview @@ -54,6 +55,7 @@ services: - ./features:/data/features - ./behat.yml:/data/behat.yml - ./tests:/data/tests + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview command: ["/data/run-tests.sh"] @@ -101,6 +103,7 @@ services: - ./dockerbuild/run-metadata-tests.sh:/data/run-metadata-tests.sh # Local modules + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview command: /data/run-debug.sh @@ -144,6 +147,7 @@ services: - ./dockerbuild/run-metadata-tests.sh:/data/run-metadata-tests.sh # Local modules + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview command: 'bash -c "/data/enable-exampleauth-module.sh && /data/run.sh"' @@ -180,6 +184,7 @@ services: - ./development/idp2-local/metadata/saml20-sp-remote.php:/data/vendor/simplesamlphp/simplesamlphp/metadata/saml20-sp-remote.php # Local modules + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview command: /data/run.sh @@ -211,6 +216,7 @@ services: - ./dockerbuild/run-metadata-tests.sh:/data/run-metadata-tests.sh # Local modules + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview ports: @@ -238,6 +244,7 @@ services: - ./development/sp2-local/metadata/saml20-idp-remote.php:/data/vendor/simplesamlphp/simplesamlphp/metadata/saml20-idp-remote.php # Local modules + - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker - ./modules/profilereview:/data/vendor/simplesamlphp/simplesamlphp/modules/profilereview ports: From 8262e5f84f15a2ad363f5c2bf94989b89939f0bd Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Tue, 7 May 2024 16:01:56 +0800 Subject: [PATCH 03/18] composer require silinternational/idp-id-broker-php-client --- composer.json | 3 ++- composer.lock | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 2f282785..0be4dad7 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "silinternational/simplesamlphp-module-sildisco": "^4.0.0", "silinternational/php-env": "^3.1.0", "silinternational/psr3-adapters": "^3.1", - "gettext/gettext": "^4.8@dev" + "gettext/gettext": "^4.8@dev", + "silinternational/idp-id-broker-php-client": "^4.3" }, "require-dev": { "behat/behat": "^3.8", diff --git a/composer.lock b/composer.lock index 8fd70763..8be4527f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7ba67875da2080d961345975daa92cf", + "content-hash": "f08b96212b20738531457507ce05ffe0", "packages": [ { "name": "aws/aws-crt-php", @@ -2794,16 +2794,16 @@ }, { "name": "silinternational/idp-id-broker-php-client", - "version": "4.3.1", + "version": "4.3.2", "source": { "type": "git", "url": "https://github.com/silinternational/idp-id-broker-php-client.git", - "reference": "c05d01c0ed0666056249bdabd97c0392c99e9790" + "reference": "425955b2699110d6ff9a5d7cf1a15751cc1b8ee9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/silinternational/idp-id-broker-php-client/zipball/c05d01c0ed0666056249bdabd97c0392c99e9790", - "reference": "c05d01c0ed0666056249bdabd97c0392c99e9790", + "url": "https://api.github.com/repos/silinternational/idp-id-broker-php-client/zipball/425955b2699110d6ff9a5d7cf1a15751cc1b8ee9", + "reference": "425955b2699110d6ff9a5d7cf1a15751cc1b8ee9", "shasum": "" }, "require": { @@ -2847,9 +2847,9 @@ ], "support": { "issues": "https://github.com/silinternational/idp-id-broker-php-client/issues", - "source": "https://github.com/silinternational/idp-id-broker-php-client/tree/4.3.1" + "source": "https://github.com/silinternational/idp-id-broker-php-client/tree/4.3.2" }, - "time": "2023-06-19T12:59:34+00:00" + "time": "2024-02-28T19:30:43+00:00" }, { "name": "silinternational/php-env", From 294ff16fabe59c284a4f1c21656237e3e65220e9 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Tue, 7 May 2024 16:07:41 +0800 Subject: [PATCH 04/18] composer require sinergi/browser-detector --- composer.json | 3 ++- composer.lock | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 0be4dad7..6fee2e44 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "silinternational/php-env": "^3.1.0", "silinternational/psr3-adapters": "^3.1", "gettext/gettext": "^4.8@dev", - "silinternational/idp-id-broker-php-client": "^4.3" + "silinternational/idp-id-broker-php-client": "^4.3", + "sinergi/browser-detector": "^6.1" }, "require-dev": { "behat/behat": "^3.8", diff --git a/composer.lock b/composer.lock index 8be4527f..e03bcdd0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f08b96212b20738531457507ce05ffe0", + "content-hash": "69b009959cbdf313f49a8d5029e8bffe", "packages": [ { "name": "aws/aws-crt-php", @@ -4986,6 +4986,60 @@ "abandoned": true, "time": "2022-11-28T16:34:29+00:00" }, + { + "name": "sinergi/browser-detector", + "version": "6.1.4", + "source": { + "type": "git", + "url": "https://github.com/sinergi/php-browser-detector.git", + "reference": "4927f7c2bedc48b68f183bd420aa3549c59e133b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sinergi/php-browser-detector/zipball/4927f7c2bedc48b68f183bd420aa3549c59e133b", + "reference": "4927f7c2bedc48b68f183bd420aa3549c59e133b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.0 || ^9.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sinergi\\BrowserDetector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gabriel Bull", + "email": "me@gabrielbull.com" + }, + { + "name": "Chris Schuld" + } + ], + "description": "Detecting the user's browser, operating system and language.", + "keywords": [ + "browser", + "detection", + "language", + "operating system", + "os" + ], + "support": { + "issues": "https://github.com/sinergi/php-browser-detector/issues", + "source": "https://github.com/sinergi/php-browser-detector/tree/6.1.4" + }, + "abandoned": true, + "time": "2021-09-23T13:51:44+00:00" + }, { "name": "symfony/cache", "version": "v5.4.25", From b87c9193df151fbb6b8ad331ac99755430586e8e Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Tue, 7 May 2024 16:08:37 +0800 Subject: [PATCH 05/18] move Assert, LoggerFactory, LoginBrowser to lib directory --- features/bootstrap/context/MfaContext.php | 6 +++--- features/fakes/FakeIdBrokerClient.php | 2 +- modules/mfa/{src => lib}/Assert.php | 2 +- modules/mfa/lib/Auth/Process/Mfa.php | 4 ++-- modules/mfa/{src => lib}/LoggerFactory.php | 4 ++-- modules/mfa/{src => lib}/LoginBrowser.php | 2 +- modules/mfa/www/low-on-backup-codes.php | 2 +- modules/mfa/www/must-set-up-mfa.php | 2 +- modules/mfa/www/new-backup-codes.php | 2 +- modules/mfa/www/out-of-backup-codes.php | 2 +- modules/mfa/www/prompt-for-mfa.php | 4 ++-- modules/mfa/www/send-manager-mfa.php | 2 +- 12 files changed, 17 insertions(+), 17 deletions(-) rename modules/mfa/{src => lib}/Assert.php (98%) rename modules/mfa/{src => lib}/LoggerFactory.php (94%) rename modules/mfa/{src => lib}/LoginBrowser.php (96%) diff --git a/features/bootstrap/context/MfaContext.php b/features/bootstrap/context/MfaContext.php index 020c49e8..3bc31f6c 100644 --- a/features/bootstrap/context/MfaContext.php +++ b/features/bootstrap/context/MfaContext.php @@ -1,5 +1,5 @@ Date: Tue, 7 May 2024 16:10:30 +0800 Subject: [PATCH 06/18] set up MfaContext for behat --- behat.yml | 2 +- .../bootstrap/{context => }/MfaContext.php | 37 +------------------ 2 files changed, 3 insertions(+), 36 deletions(-) rename features/bootstrap/{context => }/MfaContext.php (96%) diff --git a/behat.yml b/behat.yml index 053d4d3c..a5ecc748 100644 --- a/behat.yml +++ b/behat.yml @@ -11,7 +11,7 @@ default: contexts: [ 'FeatureContext' ] mfa_features: paths: [ '%paths.base%//features//mfa.feature' ] - contexts: [ 'FeatureContext' ] + contexts: [ 'MfaContext' ] profilereview_features: paths: [ '%paths.base%//features//profilereview.feature' ] contexts: [ 'ProfileReviewContext' ] diff --git a/features/bootstrap/context/MfaContext.php b/features/bootstrap/MfaContext.php similarity index 96% rename from features/bootstrap/context/MfaContext.php rename to features/bootstrap/MfaContext.php index 3bc31f6c..e8e2fa1a 100644 --- a/features/bootstrap/context/MfaContext.php +++ b/features/bootstrap/MfaContext.php @@ -1,12 +1,7 @@ driver = new GoutteDriver(); - $this->session = new Session($this->driver); - $this->session->start(); - } - + /** * Assert that the given page has a form that contains the given text. * From de593ea9467ed05d68050059f593dae46de902ba Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Thu, 9 May 2024 10:27:57 +0800 Subject: [PATCH 07/18] fixed (some of) the mfa tests --- composer.json | 5 +- composer.lock | 73 +- development/idp-local/config/authsources.php | 921 +++++++++++++++++- .../idp-local/metadata/saml20-idp-hosted.php | 14 + docker-compose.yml | 16 + features/bootstrap/MfaContext.php | 14 +- features/fakes/FakeIdBrokerClient.php | 2 +- features/mfa.feature | 64 +- modules/mfa/lib/Auth/Process/Mfa.php | 21 +- 9 files changed, 1046 insertions(+), 84 deletions(-) diff --git a/composer.json b/composer.json index 6fee2e44..b5a9031d 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,10 @@ "autoload": { "files": [ "vendor/yiisoft/yii2/Yii.php" - ] + ], + "psr-4": { + "Sil\\SspMfa\\Behat\\": "features/" + } }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index e03bcdd0..027ae04b 100644 --- a/composer.lock +++ b/composer.lock @@ -6690,16 +6690,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.27.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", "shasum": "" }, "require": { @@ -6707,9 +6707,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.27-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -6753,7 +6750,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" }, "funding": [ { @@ -6769,7 +6766,7 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-php81", @@ -7795,16 +7792,16 @@ "packages-dev": [ { "name": "behat/behat", - "version": "v3.13.0", + "version": "v3.14.0", "source": { "type": "git", "url": "https://github.com/Behat/Behat.git", - "reference": "9dd7cdb309e464ddeab095cd1a5151c2dccba4ab" + "reference": "2a3832d9cb853a794af3a576f9e524ae460f3340" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Behat/zipball/9dd7cdb309e464ddeab095cd1a5151c2dccba4ab", - "reference": "9dd7cdb309e464ddeab095cd1a5151c2dccba4ab", + "url": "https://api.github.com/repos/Behat/Behat/zipball/2a3832d9cb853a794af3a576f9e524ae460f3340", + "reference": "2a3832d9cb853a794af3a576f9e524ae460f3340", "shasum": "" }, "require": { @@ -7813,18 +7810,18 @@ "ext-mbstring": "*", "php": "^7.2 || ^8.0", "psr/container": "^1.0 || ^2.0", - "symfony/config": "^4.4 || ^5.0 || ^6.0", - "symfony/console": "^4.4 || ^5.0 || ^6.0", - "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0", - "symfony/event-dispatcher": "^4.4 || ^5.0 || ^6.0", - "symfony/translation": "^4.4 || ^5.0 || ^6.0", - "symfony/yaml": "^4.4 || ^5.0 || ^6.0" + "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/console": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/event-dispatcher": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/translation": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/yaml": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { "herrera-io/box": "~1.6.1", "phpspec/prophecy": "^1.15", "phpunit/phpunit": "^8.5 || ^9.0", - "symfony/process": "^4.4 || ^5.0 || ^6.0", + "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0", "vimeo/psalm": "^4.8" }, "suggest": { @@ -7876,9 +7873,9 @@ ], "support": { "issues": "https://github.com/Behat/Behat/issues", - "source": "https://github.com/Behat/Behat/tree/v3.13.0" + "source": "https://github.com/Behat/Behat/tree/v3.14.0" }, - "time": "2023-04-18T15:40:53+00:00" + "time": "2023-12-09T13:55:02+00:00" }, { "name": "behat/gherkin", @@ -7945,26 +7942,28 @@ }, { "name": "behat/mink", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/minkphp/Mink.git", - "reference": "19e58905632e7cfdc5b2bafb9b950a3521af32c5" + "reference": "d8527fdf8785aad38455fb426af457ab9937aece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/minkphp/Mink/zipball/19e58905632e7cfdc5b2bafb9b950a3521af32c5", - "reference": "19e58905632e7cfdc5b2bafb9b950a3521af32c5", + "url": "https://api.github.com/repos/minkphp/Mink/zipball/d8527fdf8785aad38455fb426af457ab9937aece", + "reference": "d8527fdf8785aad38455fb426af457ab9937aece", "shasum": "" }, "require": { "php": ">=7.2", - "symfony/css-selector": "^4.4 || ^5.0 || ^6.0" + "symfony/css-selector": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", "phpunit/phpunit": "^8.5.22 || ^9.5.11", - "symfony/error-handler": "^4.4 || ^5.0 || ^6.0", - "symfony/phpunit-bridge": "^5.4 || ^6.0" + "symfony/error-handler": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" }, "suggest": { "behat/mink-browserkit-driver": "fast headless driver for any app without JS emulation", @@ -8003,9 +8002,9 @@ ], "support": { "issues": "https://github.com/minkphp/Mink/issues", - "source": "https://github.com/minkphp/Mink/tree/v1.10.0" + "source": "https://github.com/minkphp/Mink/tree/v1.11.0" }, - "time": "2022-03-28T14:22:43+00:00" + "time": "2023-12-09T11:23:23+00:00" }, { "name": "behat/mink-extension", @@ -9533,16 +9532,16 @@ }, { "name": "symfony/css-selector", - "version": "v6.3.2", + "version": "v6.4.7", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "883d961421ab1709877c10ac99451632a3d6fa57" + "reference": "1c5d5c2103c3762aff27a27e1e2409e30a79083b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/883d961421ab1709877c10ac99451632a3d6fa57", - "reference": "883d961421ab1709877c10ac99451632a3d6fa57", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/1c5d5c2103c3762aff27a27e1e2409e30a79083b", + "reference": "1c5d5c2103c3762aff27a27e1e2409e30a79083b", "shasum": "" }, "require": { @@ -9578,7 +9577,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.3.2" + "source": "https://github.com/symfony/css-selector/tree/v6.4.7" }, "funding": [ { @@ -9594,7 +9593,7 @@ "type": "tidelift" } ], - "time": "2023-07-12T16:00:22+00:00" + "time": "2024-04-18T09:22:46+00:00" }, { "name": "symfony/translation", diff --git a/development/idp-local/config/authsources.php b/development/idp-local/config/authsources.php index f9fa9ee5..3af3dac7 100644 --- a/development/idp-local/config/authsources.php +++ b/development/idp-local/config/authsources.php @@ -1,5 +1,7 @@ ['distant_future@example.com'], 'employeeNumber' => ['11111'], 'cn' => ['DISTANT_FUTURE'], + 'mfa' => [ + 'prompt' => 'no', + ], 'schacExpiryDate' => [ gmdate('YmdHis\Z', strtotime('+6 months')), // Distant future ], @@ -31,6 +36,9 @@ 'mail' => ['near_future@example.com'], 'employeeNumber' => ['22222'], 'cn' => ['NEAR_FUTURE'], + 'mfa' => [ + 'prompt' => 'no', + ], 'schacExpiryDate' => [ gmdate('YmdHis\Z', strtotime('+1 day')), // Very soon ], @@ -42,6 +50,9 @@ 'mail' => ['already_past@example.com'], 'employeeNumber' => ['33333'], 'cn' => ['ALREADY_PAST'], + 'mfa' => [ + 'prompt' => 'no', + ], 'schacExpiryDate' => [ gmdate('YmdHis\Z', strtotime('-1 day')), // In the past ], @@ -61,6 +72,9 @@ 'mail' => ['invalid_exp@example.com'], 'employeeNumber' => ['55555'], 'cn' => ['INVALID_EXP'], + 'mfa' => [ + 'prompt' => 'no', + ], 'schacExpiryDate' => [ 'invalid' ], @@ -77,7 +91,7 @@ gmdate('YmdHis\Z', strtotime('+6 months')), ], 'mfa' => [ - 'prompt' => 'yes', + 'prompt' => 'no', 'add' => 'no', 'options' => [ [ @@ -130,7 +144,7 @@ gmdate('YmdHis\Z', strtotime('+6 months')), ], 'mfa' => [ - 'prompt' => 'yes', + 'prompt' => 'no', 'add' => 'no', 'options' => [ [ @@ -162,7 +176,7 @@ gmdate('YmdHis\Z', strtotime('+6 months')), ], 'mfa' => [ - 'prompt' => 'yes', + 'prompt' => 'no', 'add' => 'no', 'options' => [ [ @@ -199,5 +213,906 @@ ], 'profile_review' => 'yes' ], + 'no_mfa_needed:a' => [ + 'eduPersonPrincipalName' => ['NO_MFA_NEEDED@mfaidp'], + 'eduPersonTargetID' => ['11111111-1111-1111-1111-111111111111'], + 'sn' => ['Needed'], + 'givenName' => ['No MFA'], + 'mail' => ['no_mfa_needed@example.com'], + 'employeeNumber' => ['11111'], + 'cn' => ['NO_MFA_NEEDED'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'no', + 'add' => 'no', + 'options' => [], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'must_set_up_mfa:a' => [ + 'eduPersonPrincipalName' => ['MUST_SET_UP_MFA@mfaidp'], + 'eduPersonTargetID' => ['22222222-2222-2222-2222-222222222222'], + 'sn' => ['Set Up MFA'], + 'givenName' => ['Must'], + 'mail' => ['must_set_up_mfa@example.com'], + 'employeeNumber' => ['22222'], + 'cn' => ['MUST_SET_UP_MFA'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_backupcode:a' => [ + 'eduPersonPrincipalName' => ['HAS_BACKUPCODE@mfaidp'], + 'eduPersonTargetID' => ['33333333-3333-3333-3333-333333333333'], + 'sn' => ['Backupcode'], + 'givenName' => ['Has'], + 'mail' => ['has_backupcode@example.com'], + 'employeeNumber' => ['33333'], + 'cn' => ['HAS_BACKUPCODE'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '7', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_backupcode_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['HAS_BACKUPCODE@mfaidp'], + 'eduPersonTargetID' => ['33333333-3333-3333-3333-333333333333'], + 'sn' => ['Backupcode'], + 'givenName' => ['Has'], + 'mail' => ['has_backupcode@example.com'], + 'employeeNumber' => ['33333'], + 'cn' => ['HAS_BACKUPCODE'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '7', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_totp:a' => [ + 'eduPersonPrincipalName' => ['HAS_TOTP@mfaidp'], + 'eduPersonTargetID' => ['44444444-4444-4444-4444-444444444444'], + 'sn' => ['TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_totp@example.com'], + 'employeeNumber' => ['44444'], + 'cn' => ['HAS_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '2', + 'type' => 'totp', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_totp_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['HAS_TOTP@mfaidp'], + 'eduPersonTargetID' => ['44444444-4444-4444-4444-444444444444'], + 'sn' => ['TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_totp@example.com'], + 'employeeNumber' => ['44444'], + 'cn' => ['HAS_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '2', + 'type' => 'totp', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_webauthn:a' => [ + 'eduPersonPrincipalName' => ['HAS_WEBAUTHN@mfaidp'], + 'eduPersonTargetID' => ['55555555-5555-5555-5555-555555555555'], + 'sn' => ['WebAuthn'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn@example.com'], + 'employeeNumber' => ['55555'], + 'cn' => ['HAS_WEBAUTHN'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '3', + 'type' => 'webauthn', + 'label' => 'Blue security key (work)', + 'created_utc' => '2017-10-24T20:40:57Z', + 'last_used_utc' => null, + 'data' => [ + // Response from "POST /webauthn/login" MFA API call. + ], + ], + ] + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_webauthn_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['HAS_WEBAUTHN@mfaidp'], + 'eduPersonTargetID' => ['55555555-5555-5555-5555-555555555555'], + 'sn' => ['WebAuthn'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn@example.com'], + 'employeeNumber' => ['55555'], + 'cn' => ['HAS_WEBAUTHN'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '3', + 'type' => 'webauthn', + 'data' => '', + ], + ] + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_all:a' => [ + 'eduPersonPrincipalName' => ['has_all@mfaidp'], + 'eduPersonTargetID' => ['77777777-7777-7777-7777-777777777777'], + 'sn' => ['All'], + 'givenName' => ['Has'], + 'mail' => ['has_all@example.com'], + 'employeeNumber' => ['777777'], + 'cn' => ['HAS_ALL'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '1', + 'type' => 'backupcode', + 'data' => [ + 'count' => 8, + ], + ], + [ + 'id' => '2', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '3', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_rate_limited_mfa:a' => [ + 'eduPersonPrincipalName' => ['HAS_RATE_LIMITED_MFA@mfaidp'], + 'eduPersonTargetID' => ['88888888-8888-8888-8888-888888888888'], + 'sn' => ['Rate-Limited MFA'], + 'givenName' => ['Has'], + 'mail' => ['has_rate_limited_mfa@example.com'], + 'employeeNumber' => ['88888'], + 'cn' => ['HAS_RATE_LIMITED_MFA'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => 987, //FakeIdBrokerClient::RATE_LIMITED_MFA_ID, + 'type' => 'backupcode', + 'data' => [ + 'count' => 5, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_4_backupcodes:a' => [ + 'eduPersonPrincipalName' => ['HAS_4_BACKUPCODES@mfaidp'], + 'eduPersonTargetID' => ['99999999-9999-9999-9999-999999999999'], + 'sn' => ['Backupcodes'], + 'givenName' => ['Has 4'], + 'mail' => ['has_4_backupcodes@example.com'], + 'employeeNumber' => ['99999'], + 'cn' => ['HAS_4_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '90', + 'type' => 'backupcode', + 'data' => [ + 'count' => 4, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_1_backupcode_only:a' => [ + 'eduPersonPrincipalName' => ['HAS_1_BACKUPCODE_ONLY@mfaidp'], + 'eduPersonTargetID' => ['00000010-0010-0010-0010-000000000010'], + 'sn' => ['Only, And No Other MFA'], + 'givenName' => ['Has 1 Backupcode'], + 'mail' => ['has_1_backupcode_only@example.com'], + 'employeeNumber' => ['00010'], + 'cn' => ['HAS_1_BACKUPCODE_ONLY'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '100', + 'type' => 'backupcode', + 'data' => [ + 'count' => 1, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_1_backupcode_plus:a' => [ + 'eduPersonPrincipalName' => ['HAS_1_BACKUPCODE_PLUS@mfaidp'], + 'eduPersonTargetID' => ['00000011-0011-0011-0011-000000000011'], + 'sn' => ['Plus Other MFA'], + 'givenName' => ['Has 1 Backupcode'], + 'mail' => ['has_1_backupcode_plus@example.com'], + 'employeeNumber' => ['00011'], + 'cn' => ['HAS_1_BACKUPCODE_PLUS'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '110', + 'type' => 'backupcode', + 'data' => [ + 'count' => 1, + ], + ], + [ + 'id' => '112', + 'type' => 'totp', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_webauthn_totp:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_totp@mfaidp'], + 'eduPersonTargetID' => ['00000012-0012-0012-0012-000000000012'], + 'sn' => ['WebAuthn And TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_totp@example.com'], + 'employeeNumber' => ['00012'], + 'cn' => ['HAS_WEBAUTHN_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '120', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '121', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_webauthn_totp_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_totp@mfaidp'], + 'eduPersonTargetID' => ['00000012-0012-0012-0012-000000000012'], + 'sn' => ['WebAuthn And TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_totp@example.com'], + 'employeeNumber' => ['00012'], + 'cn' => ['HAS_WEBAUTHN_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '120', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '121', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_webauthn_backupcodes:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_backupcodes@mfaidp'], + 'eduPersonTargetID' => ['00000013-0013-0013-0013-000000000013'], + 'sn' => ['WebAuthn And Backup Codes'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_backupcodes@example.com'], + 'employeeNumber' => ['00013'], + 'cn' => ['HAS_WEBAUTHN_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '130', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + [ + 'id' => '131', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_webauthn_backupcodes_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_backupcodes@mfaidp'], + 'eduPersonTargetID' => ['00000013-0013-0013-0013-000000000013'], + 'sn' => ['WebAuthn And Backup Codes'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_backupcodes@example.com'], + 'employeeNumber' => ['00013'], + 'cn' => ['HAS_WEBAUTHN_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '130', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + [ + 'id' => '131', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_webauthn_totp_backupcodes:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_totp_backupcodes@mfaidp'], + 'eduPersonTargetID' => ['00000014-0014-0014-0014-000000000014'], + 'sn' => ['WebAuthn, TOTP, And Backup Codes'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_totp_backupcodes@example.com'], + 'employeeNumber' => ['00014'], + 'cn' => ['HAS_WEBAUTHN_TOTP_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '140', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '141', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + [ + 'id' => '142', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_webauthn_totp_backupcodes_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_totp_backupcodes@mfaidp'], + 'eduPersonTargetID' => ['00000014-0014-0014-0014-000000000014'], + 'sn' => ['WebAuthn, TOTP, And Backup Codes'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_totp_backupcodes@example.com'], + 'employeeNumber' => ['00014'], + 'cn' => ['HAS_WEBAUTHN_TOTP_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '140', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '141', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + [ + 'id' => '142', + 'type' => 'webauthn', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_mgr_code_webauthn_and_more_recently_used_totp:a' => [ + 'eduPersonPrincipalName' => ['has_mgr_code_webauthn_and_more_recently_used_totp@mfaidp'], + 'eduPersonTargetID' => ['00000114-0014-0014-0014-000000000014'], + 'sn' => ['Manager Code, WebAuthn, More Recently Used TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_mgr_code_webauthn_and_more_recently_used_totp@example.com'], + 'employeeNumber' => ['00114'], + 'cn' => ['HAS_MGR_CODE_WEBAUTHN_AND_MORE_RECENTLY_USED_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '1140', + 'type' => 'totp', + 'last_used_utc' => '2011-01-01T00:00:00Z', + 'data' => '', + ], + [ + 'id' => '1141', + 'type' => 'webauthn', + 'last_used_utc' => '2000-01-01T00:00:00Z', + 'data' => '', + ], + [ + 'id' => '1142', + 'type' => 'manager', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_webauthn_and_more_recently_used_totp:a' => [ + 'eduPersonPrincipalName' => ['has_webauthn_and_more_recently_used_totp@mfaidp'], + 'eduPersonTargetID' => ['00000214-0014-0014-0014-000000000014'], + 'sn' => ['WebAuthn And More Recently Used TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_webauthn_and_more_recently_used_totp@example.com'], + 'employeeNumber' => ['00214'], + 'cn' => ['HAS_WEBAUTHN_AND_MORE_RECENTLY_USED_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '2140', + 'type' => 'totp', + 'last_used_utc' => '2011-01-01T00:00:00Z', + 'data' => '', + ], + [ + 'id' => '2141', + 'type' => 'webauthn', + 'last_used_utc' => '2000-01-01T00:00:00Z', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_totp_and_more_recently_used_webauthn:a' => [ + 'eduPersonPrincipalName' => ['has_totp_and_more_recently_used_webauthn@mfaidp'], + 'eduPersonTargetID' => ['00000314-0014-0014-0014-000000000014'], + 'sn' => ['TOTP And More Recently Used Webauthn'], + 'givenName' => ['Has'], + 'mail' => ['has_totp_and_more_recently_used_webauthn@example.com'], + 'employeeNumber' => ['00314'], + 'cn' => ['HAS_TOTP_AND_MORE_RECENTLY_USED_WEBAUTHN'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '3140', + 'type' => 'totp', + 'last_used_utc' => '2000-01-01T00:00:00Z', + 'data' => '', + ], + [ + 'id' => '3141', + 'type' => 'webauthn', + 'last_used_utc' => '2011-01-01T00:00:00Z', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_totp_and_more_recently_used_backup_code:a' => [ + 'eduPersonPrincipalName' => ['has_totp_and_more_recently_used_backup_code@mfaidp'], + 'eduPersonTargetID' => ['00000414-0014-0014-0014-000000000014'], + 'sn' => ['TOTP And More Recently Used Backup Code'], + 'givenName' => ['Has'], + 'mail' => ['has_totp_and_more_recently_used_backup_code@example.com'], + 'employeeNumber' => ['00414'], + 'cn' => ['HAS_TOTP_AND_MORE_RECENTLY_USED_BACKUP_CODE'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '4140', + 'type' => 'totp', + 'last_used_utc' => '2000-01-01T00:00:00Z', + 'data' => '', + ], + [ + 'id' => '4141', + 'type' => 'backupcode', + 'last_used_utc' => '2011-01-01T00:00:00Z', + 'data' => [ + 'count' => 10, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_backup_code_and_more_recently_used_totp:a' => [ + 'eduPersonPrincipalName' => ['has_backup_code_and_more_recently_used_totp@mfaidp'], + 'eduPersonTargetID' => ['00000514-0014-0014-0014-000000000014'], + 'sn' => ['Backup Code And More Recently Used TOTP'], + 'givenName' => ['Has'], + 'mail' => ['has_backup_code_and_more_recently_used_totp@example.com'], + 'employeeNumber' => ['00514'], + 'cn' => ['HAS_BACKUP_CODE_AND_MORE_RECENTLY_USED_TOTP'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '5140', + 'type' => 'backupcode', + 'last_used_utc' => '2000-01-01T00:00:00Z', + 'data' => [ + 'count' => 10, + ], + ], + [ + 'id' => '5141', + 'type' => 'totp', + 'last_used_utc' => '2011-01-01T00:00:00Z', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_totp_backupcodes:a' => [ + 'eduPersonPrincipalName' => ['has_totp_backupcodes@mfaidp'], + 'eduPersonTargetID' => ['00000015-0015-0015-0015-000000000015'], + 'sn' => ['TOTP And Backup Codes'], + 'givenName' => ['Has'], + 'mail' => ['has_totp_backupcodes@example.com'], + 'employeeNumber' => ['00015'], + 'cn' => ['HAS_TOTP_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '150', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '151', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + ], + 'has_totp_backupcodes_and_mgr:a' => [ + 'eduPersonPrincipalName' => ['has_totp_backupcodes@mfaidp'], + 'eduPersonTargetID' => ['00000015-0015-0015-0015-000000000015'], + 'sn' => ['TOTP And Backup Codes'], + 'givenName' => ['Has'], + 'mail' => ['has_totp_backupcodes@example.com'], + 'employeeNumber' => ['00015'], + 'cn' => ['HAS_TOTP_BACKUPCODES'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '150', + 'type' => 'totp', + 'data' => '', + ], + [ + 'id' => '151', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], + 'has_mgr_code:a' => [ + 'eduPersonPrincipalName' => ['has_mgr_code@mfaidp'], + 'eduPersonTargetID' => ['00000015-0015-0015-0015-000000000015'], + 'sn' => ['Manager Code'], + 'givenName' => ['Has'], + 'mail' => ['has_mgr_code@example.com'], + 'employeeNumber' => ['00015'], + 'cn' => ['HAS_MGR_CODE'], + 'schacExpiryDate' => [ + gmdate('YmdHis\Z', strtotime('+6 months')), + ], + 'profile_review' => 'no', + 'mfa' => [ + 'prompt' => 'yes', + 'add' => 'no', + 'options' => [ + [ + 'id' => '151', + 'type' => 'backupcode', + 'data' => [ + 'count' => 10, + ], + ], + [ + 'id' => '152', + 'type' => 'manager', + 'data' => '', + ], + ], + ], + 'method' => [ + 'add' => 'no', + 'options' => [], + ], + 'manager_email' => ['manager@example.com'], + ], ], ]; diff --git a/development/idp-local/metadata/saml20-idp-hosted.php b/development/idp-local/metadata/saml20-idp-hosted.php index 588232b3..892df6cd 100644 --- a/development/idp-local/metadata/saml20-idp-hosted.php +++ b/development/idp-local/metadata/saml20-idp-hosted.php @@ -10,6 +10,7 @@ */ use Sil\Psr3Adapters\Psr3StdOutLogger; +use Sil\SspMfa\Behat\fakes\FakeIdBrokerClient; $metadata['http://ssp-idp1.local:8085'] = [ /* @@ -32,6 +33,18 @@ 'auth' => 'example-userpass', 'authproc' => [ + 10 => [ + 'class' => 'mfa:Mfa', + 'employeeIdAttr' => 'employeeNumber', + 'idBrokerAccessToken' => Env::get('ID_BROKER_ACCESS_TOKEN'), + 'idBrokerAssertValidIp' => Env::get('ID_BROKER_ASSERT_VALID_IP'), + 'idBrokerBaseUri' => Env::get('ID_BROKER_BASE_URI'), + 'idBrokerClientClass' => FakeIdBrokerClient::class, + 'idBrokerTrustedIpRanges' => Env::get('ID_BROKER_TRUSTED_IP_RANGES'), + 'idpDomainName' => Env::get('IDP_DOMAIN_NAME'), + 'mfaSetupUrl' => Env::get('MFA_SETUP_URL'), + 'loggerClass' => Psr3SamlLogger::class, + ], 15 => [ 'class' => 'expirychecker:ExpiryDate', 'accountNameAttr' => 'cn', @@ -54,4 +67,5 @@ // Copy configuration for port 80 and modify host and profileUrl. $metadata['http://ssp-idp1.local'] = $metadata['http://ssp-idp1.local:8085']; $metadata['http://ssp-idp1.local']['host'] = 'ssp-idp1.local'; +$metadata['http://ssp-idp1.local']['authproc'][10]['mfaSetupUrl'] = Env::get('MFA_SETUP_URL_FOR_TESTS'); $metadata['http://ssp-idp1.local']['authproc'][30]['profileUrl'] = Env::get('PROFILE_URL_FOR_TESTS'); diff --git a/docker-compose.yml b/docker-compose.yml index e6508f7a..1a0f6eef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,11 @@ services: environment: - COMPOSER_CACHE_DIR=/composer - PROFILE_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - MFA_SETUP_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - ADMIN_EMAIL=john_doe@there.com + - ADMIN_PASS=b + - SECRET_SALT=abc123 + - IDP_NAME=x volumes: - ./composer.json:/data/composer.json - ./composer.lock:/data/composer.lock @@ -146,6 +151,9 @@ services: # Enable checking our test metadata - ./dockerbuild/run-metadata-tests.sh:/data/run-metadata-tests.sh + # Include the features folder (for the FakeIdBrokerClient class) + - ./features:/data/features + # Local modules - ./modules/mfa:/data/vendor/simplesamlphp/simplesamlphp/modules/mfa - ./modules/expirychecker:/data/vendor/simplesamlphp/simplesamlphp/modules/expirychecker @@ -158,6 +166,14 @@ services: ADMIN_PASS: "a" SECRET_SALT: "h57fjemb&dn^nsJFGNjweJ" IDP_NAME: "IDP 1" + IDP_DOMAIN_NAME: "mfaidp" + ID_BROKER_ACCESS_TOKEN: "dummy" + ID_BROKER_ASSERT_VALID_IP: "false" + ID_BROKER_BASE_URI: "dummy" + ID_BROKER_TRUSTED_IP_RANGES: "192.168.0.1/8" + MFA_SETUP_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" + MFA_SETUP_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" + REMEMBER_ME_SECRET: "12345" PROFILE_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" PROFILE_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" SECURE_COOKIE: "false" diff --git a/features/bootstrap/MfaContext.php b/features/bootstrap/MfaContext.php index e8e2fa1a..29fc5b71 100644 --- a/features/bootstrap/MfaContext.php +++ b/features/bootstrap/MfaContext.php @@ -4,7 +4,7 @@ use Behat\Mink\Exception\ElementNotFoundException; use PHPUnit\Framework\Assert; use Sil\PhpEnv\Env; -use SimpleSAML\Module\mfa\Behat\fakes\FakeIdBrokerClient; +use Sil\SspMfa\Behat\fakes\FakeIdBrokerClient; use SimpleSAML\Module\mfa\LoginBrowser; /** @@ -12,8 +12,6 @@ */ class MfaContext extends FeatureContext { - protected $nonPwManagerUrl = 'http://mfasp/module.php/core/authenticate.php?as=mfa-idp-no-port'; - protected $username = null; protected $password = null; @@ -93,7 +91,6 @@ protected function getSubmitMfaButton($page) */ public function iLogin() { - $this->session->visit($this->nonPwManagerUrl); $page = $this->session->getPage(); try { $page->fillField('username', $this->username); @@ -327,11 +324,6 @@ protected function pageContainsElementWithText($cssSelector, $text) } return false; } - - protected function clickLink($text) - { - $this->session->getPage()->clickLink($text); - } /** * @When I submit an incorrect backup code @@ -549,7 +541,7 @@ public function theUsersBrowserSupportsUf() 'Update USER_AGENT_WITH_WEBAUTHN_SUPPORT to a User Agent with WebAuthn support' ); - $this->driver->getClient()->setServerParameter('HTTP_USER_AGENT', $userAgentWithWebAuthn); +// $this->driver->getClient()->setServerParameter('HTTP_USER_AGENT', $userAgentWithWebAuthn); } /** @@ -683,7 +675,7 @@ public function theUsersBrowserDoesNotSupportUf() 'Update USER_AGENT_WITHOUT_WEBAUTHN_SUPPORT to a User Agent without WebAuthn support' ); - $this->driver->getClient()->setServerParameter('HTTP_USER_AGENT', $userAgentWithoutWebAuthn); +// $this->driver->getClient()->setServerParameter('HTTP_USER_AGENT', $userAgentWithoutWebAuthn); } /** diff --git a/features/fakes/FakeIdBrokerClient.php b/features/fakes/FakeIdBrokerClient.php index cb113e83..3f339b7c 100644 --- a/features/fakes/FakeIdBrokerClient.php +++ b/features/fakes/FakeIdBrokerClient.php @@ -1,5 +1,5 @@ @@ -208,20 +212,22 @@ Feature: Prompt for MFA credentials When I click the Request Assistance link Then there should be a way to request a manager code - Scenario: Submit a code sent to my manager at an earlier time - Given I provide credentials that have a manager code - And I login - When I submit the correct manager code - Then I should end up at my intended destination - - Scenario: Submit a correct manager code - Given I provide credentials that have backup codes - And the user has a manager email - And I login - And I click the Request Assistance link - And I click the Send a code link - When I submit the correct manager code - Then I should end up at my intended destination +# Scenario: Submit a code sent to my manager at an earlier time +# Given I provide credentials that have a manager code +# And I login +# When I submit the correct manager code +## TODO: add a step here because using a manager code forces profile review +# Then I should end up at my intended destination + +# Scenario: Submit a correct manager code +# Given I provide credentials that have backup codes +# And the user has a manager email +# And I login +# And I click the Request Assistance link +# And I click the Send a code link +# When I submit the correct manager code +## TODO: add a step here because using a manager code forces profile review +# Then I should end up at my intended destination Scenario: Submit an incorrect manager code Given I provide credentials that have backup codes diff --git a/modules/mfa/lib/Auth/Process/Mfa.php b/modules/mfa/lib/Auth/Process/Mfa.php index d207a8da..ecf95aa8 100644 --- a/modules/mfa/lib/Auth/Process/Mfa.php +++ b/modules/mfa/lib/Auth/Process/Mfa.php @@ -582,7 +582,16 @@ public function process(&$state) $state, $this->mfaSetupUrl ); - + + $this->logger->debug(json_encode([ + 'module' => 'mfa', + 'event' => 'process', + 'mfa' => $mfa, + 'isHeadedToMfaSetupUrl' => $isHeadedToMfaSetupUrl, + 'employeeId' => $employeeId, + ])); + + // Record to the state what logger class to use. $state['loggerClass'] = $this->loggerClass; @@ -666,8 +675,16 @@ protected function redirectToMfaPrompt(&$state, $employeeId, $mfaOptions) $id = State::saveState($state, self::STAGE_SENT_TO_MFA_PROMPT); $url = Module::getModuleURL('mfa/prompt-for-mfa.php'); + $userAgent = LoginBrowser::getUserAgent(); + $webauthnSupport = LoginBrowser::supportsWebAuthn($userAgent); + + $this->logger->debug(json_encode([ + 'event' => 'check browser', + 'user_agent' => $userAgent, + 'webauthn_support' => $webauthnSupport, + ])); - $mfaOption = self::getMfaOptionToUse($mfaOptions, LoginBrowser::getUserAgent()); + $mfaOption = self::getMfaOptionToUse($mfaOptions, $userAgent); HTTP::redirectTrustedURL($url, [ 'mfaId' => $mfaOption['id'], From f9ed4503c6f951366ac2cceeb6952852fbec0819 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Fri, 10 May 2024 12:18:04 +0800 Subject: [PATCH 08/18] copy docker-compose.yml changes into actions-services.yml --- actions-services.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/actions-services.yml b/actions-services.yml index 0fc10d60..d5f9985d 100644 --- a/actions-services.yml +++ b/actions-services.yml @@ -10,6 +10,11 @@ services: - test-browser environment: - PROFILE_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - MFA_SETUP_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - ADMIN_EMAIL=john_doe@there.com + - ADMIN_PASS=b + - SECRET_SALT=abc123 + - IDP_NAME=x volumes: - ./dockerbuild/run-integration-tests.sh:/data/run-integration-tests.sh - ./dockerbuild/run-metadata-tests.sh:/data/run-metadata-tests.sh @@ -75,12 +80,23 @@ services: # Enable checking our test metadata - ./dockerbuild/run-metadata-tests.sh:/data/run-metadata-tests.sh + + # Include the features folder (for the FakeIdBrokerClient class) + - ./features:/data/features command: 'bash -c "/data/enable-exampleauth-module.sh && /data/run.sh"' environment: ADMIN_EMAIL: "john_doe@there.com" ADMIN_PASS: "a" SECRET_SALT: "not-secret-h57fjemb&dn^nsJFGNjweJ" IDP_NAME: "IDP 1" + IDP_DOMAIN_NAME: "mfaidp" + ID_BROKER_ACCESS_TOKEN: "dummy" + ID_BROKER_ASSERT_VALID_IP: "false" + ID_BROKER_BASE_URI: "dummy" + ID_BROKER_TRUSTED_IP_RANGES: "192.168.0.1/8" + MFA_SETUP_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" + MFA_SETUP_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" + REMEMBER_ME_SECRET: "12345" PROFILE_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" PROFILE_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" SECURE_COOKIE: "false" From e8077326796147d406286bf8369f664f12fb82ab Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Fri, 10 May 2024 15:34:54 +0800 Subject: [PATCH 09/18] fixed mfa.feature:19 test scenario by defining a new pwmanager service --- actions-services.yml | 29 ++++++++++++++-- .../sp-local/config/authsources-pwmanager.php | 28 ++++++++++++++++ docker-compose.yml | 33 ++++++++++++++++--- features/bootstrap/MfaContext.php | 4 +-- features/mfa.feature | 12 +++---- 5 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 development/sp-local/config/authsources-pwmanager.php diff --git a/actions-services.yml b/actions-services.yml index d5f9985d..f8a8d562 100644 --- a/actions-services.yml +++ b/actions-services.yml @@ -7,10 +7,11 @@ services: - ssp-idp1.local - ssp-idp2.local - ssp-sp1.local + - pwmanager.local - test-browser environment: - PROFILE_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub - - MFA_SETUP_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - MFA_SETUP_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - ADMIN_EMAIL=john_doe@there.com - ADMIN_PASS=b - SECRET_SALT=abc123 @@ -94,8 +95,8 @@ services: ID_BROKER_ASSERT_VALID_IP: "false" ID_BROKER_BASE_URI: "dummy" ID_BROKER_TRUSTED_IP_RANGES: "192.168.0.1/8" - MFA_SETUP_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" - MFA_SETUP_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" + MFA_SETUP_URL: "http://pwmanager.local:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" + MFA_SETUP_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" REMEMBER_ME_SECRET: "12345" PROFILE_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" PROFILE_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" @@ -155,3 +156,25 @@ services: SHOW_SAML_ERRORS: "true" SAML20_IDP_ENABLE: "false" ADMIN_PROTECT_INDEX_PAGE: "false" + + pwmanager.local: + image: silintl/ssp-base:develop + volumes: + # Utilize custom certs + - ./development/sp-local/cert:/data/vendor/simplesamlphp/simplesamlphp/cert + + # Utilize custom configs + - ./development/sp-local/config/authsources-pwmanager.php:/data/vendor/simplesamlphp/simplesamlphp/config/authsources.php + + # Utilize custom metadata + - ./development/sp-local/metadata/saml20-idp-remote.php:/data/vendor/simplesamlphp/simplesamlphp/metadata/saml20-idp-remote.php + environment: + - ADMIN_EMAIL=john_doe@there.com + - ADMIN_PASS=sp1 + - IDP_NAME=THIS VARIABLE IS REQUIRED BUT PROBABLY NOT USED + - SECRET_SALT=NOT-a-secret-k49fjfkw73hjf9t87wjiw + - SECURE_COOKIE=false + - SHOW_SAML_ERRORS=true + - SAML20_IDP_ENABLE=false + - ADMIN_PROTECT_INDEX_PAGE=false + - THEME_USE=default diff --git a/development/sp-local/config/authsources-pwmanager.php b/development/sp-local/config/authsources-pwmanager.php new file mode 100644 index 00000000..ea9c8ab0 --- /dev/null +++ b/development/sp-local/config/authsources-pwmanager.php @@ -0,0 +1,28 @@ + [ + // The default is to use core:AdminPassword, but it can be replaced with + // any authentication source. + + 'core:AdminPassword', + ], + + 'mfa-idp' => [ + 'saml:SP', + 'entityID' => 'http://pwmanager.local:8083', + 'idp' => 'http://ssp-idp1.local:8085', + 'discoURL' => null, + 'NameIDPolicy' => "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ], + + 'mfa-idp-no-port' => [ + 'saml:SP', + 'entityID' => 'http://pwmanager.local', + 'idp' => 'http://ssp-idp1.local', + 'discoURL' => null, + 'NameIDPolicy' => "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml index 1a0f6eef..af547cf8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,11 +40,12 @@ services: - ssp-idp1.local - ssp-idp2.local - ssp-sp1.local + - pwmanager.local - test-browser environment: - COMPOSER_CACHE_DIR=/composer - PROFILE_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub - - MFA_SETUP_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - MFA_SETUP_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - ADMIN_EMAIL=john_doe@there.com - ADMIN_PASS=b - SECRET_SALT=abc123 @@ -171,8 +172,8 @@ services: ID_BROKER_ASSERT_VALID_IP: "false" ID_BROKER_BASE_URI: "dummy" ID_BROKER_TRUSTED_IP_RANGES: "192.168.0.1/8" - MFA_SETUP_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" - MFA_SETUP_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" + MFA_SETUP_URL: "http://pwmanager.local:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" + MFA_SETUP_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" REMEMBER_ME_SECRET: "12345" PROFILE_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" PROFILE_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" @@ -273,7 +274,31 @@ services: SHOW_SAML_ERRORS: "true" SAML20_IDP_ENABLE: "false" ADMIN_PROTECT_INDEX_PAGE: "false" - + + pwmanager.local: + image: silintl/ssp-base:develop + volumes: + # Utilize custom certs + - ./development/sp-local/cert:/data/vendor/simplesamlphp/simplesamlphp/cert + + # Utilize custom configs + - ./development/sp-local/config/authsources-pwmanager.php:/data/vendor/simplesamlphp/simplesamlphp/config/authsources.php + + # Utilize custom metadata + - ./development/sp-local/metadata/saml20-idp-remote.php:/data/vendor/simplesamlphp/simplesamlphp/metadata/saml20-idp-remote.php + ports: + - "8083:80" + environment: + - ADMIN_EMAIL=john_doe@there.com + - ADMIN_PASS=sp1 + - IDP_NAME=THIS VARIABLE IS REQUIRED BUT PROBABLY NOT USED + - SECRET_SALT=NOT-a-secret-k49fjfkw73hjf9t87wjiw + - SECURE_COOKIE=false + - SHOW_SAML_ERRORS=true + - SAML20_IDP_ENABLE=false + - ADMIN_PROTECT_INDEX_PAGE=false + - THEME_USE=default + networks: default: driver: bridge diff --git a/features/bootstrap/MfaContext.php b/features/bootstrap/MfaContext.php index 29fc5b71..b2a80652 100644 --- a/features/bootstrap/MfaContext.php +++ b/features/bootstrap/MfaContext.php @@ -419,9 +419,9 @@ public function thereShouldNotBeAWayToContinueToMyIntendedDestination() */ public function iShouldNotBeAbleToGetToMyIntendedDestination() { - $this->session->visit($this->nonPwManagerUrl); + $this->session->visit(self::SP1_LOGIN_PAGE); Assert::assertStringStartsNotWith( - $this->nonPwManagerUrl, + self::SP1_LOGIN_PAGE, $this->session->getCurrentUrl(), 'Failed to prevent me from getting to SPs other than the MFA setup URL' ); diff --git a/features/mfa.feature b/features/mfa.feature index f194122c..e4a90706 100644 --- a/features/mfa.feature +++ b/features/mfa.feature @@ -16,12 +16,12 @@ Feature: Prompt for MFA credentials And there should be a way to go set up MFA now And there should NOT be a way to continue to my intended destination -# Scenario: Following the requirement to go set up MFA -# Given I provide credentials that need MFA but have no MFA options available -# And I login -# When I click the set-up-MFA button -# Then I should end up at the mfa-setup URL -# And I should NOT be able to get to my intended destination + Scenario: Following the requirement to go set up MFA + Given I provide credentials that need MFA but have no MFA options available + And I login + When I click the set-up-MFA button + Then I should end up at the mfa-setup URL + And I should NOT be able to get to my intended destination Scenario: Needs MFA, has backup code option available Given I provide credentials that need MFA and have backup codes available From 53067785dd26492dde7da47e7f40573983fa6ab4 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Fri, 10 May 2024 15:46:54 +0800 Subject: [PATCH 10/18] fixed profilereview.feature:24 test scenario using pwmanager service --- actions-services.yml | 6 +++--- docker-compose.yml | 6 +++--- dockerbuild/run-integration-tests.sh | 6 +++--- features/profilereview.feature | 3 --- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/actions-services.yml b/actions-services.yml index f8a8d562..85f6bae3 100644 --- a/actions-services.yml +++ b/actions-services.yml @@ -10,7 +10,7 @@ services: - pwmanager.local - test-browser environment: - - PROFILE_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - PROFILE_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - MFA_SETUP_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - ADMIN_EMAIL=john_doe@there.com - ADMIN_PASS=b @@ -98,8 +98,8 @@ services: MFA_SETUP_URL: "http://pwmanager.local:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" MFA_SETUP_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" REMEMBER_ME_SECRET: "12345" - PROFILE_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" - PROFILE_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" + PROFILE_URL: "http://pwmanager:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" + PROFILE_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" SECURE_COOKIE: "false" SHOW_SAML_ERRORS: "true" THEME_USE: "default" diff --git a/docker-compose.yml b/docker-compose.yml index af547cf8..32727eee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: - test-browser environment: - COMPOSER_CACHE_DIR=/composer - - PROFILE_URL_FOR_TESTS=http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub + - PROFILE_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - MFA_SETUP_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - ADMIN_EMAIL=john_doe@there.com - ADMIN_PASS=b @@ -175,8 +175,8 @@ services: MFA_SETUP_URL: "http://pwmanager.local:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" MFA_SETUP_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" REMEMBER_ME_SECRET: "12345" - PROFILE_URL: "http://ssp-hub-sp1:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" - PROFILE_URL_FOR_TESTS: "http://ssp-sp1.local/module.php/core/authenticate.php?as=ssp-hub" + PROFILE_URL: "http://pwmanager:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" + PROFILE_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" SECURE_COOKIE: "false" SHOW_SAML_ERRORS: "true" THEME_USE: "default" diff --git a/dockerbuild/run-integration-tests.sh b/dockerbuild/run-integration-tests.sh index c46b02c5..71d8c006 100755 --- a/dockerbuild/run-integration-tests.sh +++ b/dockerbuild/run-integration-tests.sh @@ -6,9 +6,9 @@ set -x cd /data export COMPOSER_ALLOW_SUPERUSER=1; composer install -whenavail "ssp-hub.local" 80 10 echo Hub ready -whenavail "ssp-idp1.local" 80 10 echo IDP 1 ready -whenavail "ssp-sp1.local" 80 10 echo SP 1 ready +whenavail "ssp-hub.local" 80 15 echo Hub ready +whenavail "ssp-idp1.local" 80 5 echo IDP 1 ready +whenavail "ssp-sp1.local" 80 5 echo SP 1 ready ./vendor/bin/behat \ --no-interaction \ diff --git a/features/profilereview.feature b/features/profilereview.feature index 56643e9d..b378e712 100644 --- a/features/profilereview.feature +++ b/features/profilereview.feature @@ -25,9 +25,6 @@ Feature: Prompt to review profile information Given I provide credentials that are due for a reminder And I have logged in When I click the update profile button - # FIXME: It is currently required to login again, but it shouldn't be necessary. - And I click on the "IDP 1" tile - And I login Then I should end up at the update profile URL Examples: From 7ef8d2184c35b7e5942c3039402972684222e26a Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Fri, 10 May 2024 15:50:57 +0800 Subject: [PATCH 11/18] remove MFA_SETUP_URL_FOR_TESTS and use PROFILE_URL_FOR_TESTS instead --- actions-services.yml | 2 -- development/idp-local/metadata/saml20-idp-hosted.php | 2 +- docker-compose.yml | 2 -- features/bootstrap/MfaContext.php | 4 ++-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/actions-services.yml b/actions-services.yml index 85f6bae3..32bfe9d2 100644 --- a/actions-services.yml +++ b/actions-services.yml @@ -11,7 +11,6 @@ services: - test-browser environment: - PROFILE_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - - MFA_SETUP_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - ADMIN_EMAIL=john_doe@there.com - ADMIN_PASS=b - SECRET_SALT=abc123 @@ -96,7 +95,6 @@ services: ID_BROKER_BASE_URI: "dummy" ID_BROKER_TRUSTED_IP_RANGES: "192.168.0.1/8" MFA_SETUP_URL: "http://pwmanager.local:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" - MFA_SETUP_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" REMEMBER_ME_SECRET: "12345" PROFILE_URL: "http://pwmanager:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" PROFILE_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" diff --git a/development/idp-local/metadata/saml20-idp-hosted.php b/development/idp-local/metadata/saml20-idp-hosted.php index 892df6cd..e9b6b14a 100644 --- a/development/idp-local/metadata/saml20-idp-hosted.php +++ b/development/idp-local/metadata/saml20-idp-hosted.php @@ -67,5 +67,5 @@ // Copy configuration for port 80 and modify host and profileUrl. $metadata['http://ssp-idp1.local'] = $metadata['http://ssp-idp1.local:8085']; $metadata['http://ssp-idp1.local']['host'] = 'ssp-idp1.local'; -$metadata['http://ssp-idp1.local']['authproc'][10]['mfaSetupUrl'] = Env::get('MFA_SETUP_URL_FOR_TESTS'); +$metadata['http://ssp-idp1.local']['authproc'][10]['mfaSetupUrl'] = Env::get('PROFILE_URL_FOR_TESTS'); $metadata['http://ssp-idp1.local']['authproc'][30]['profileUrl'] = Env::get('PROFILE_URL_FOR_TESTS'); diff --git a/docker-compose.yml b/docker-compose.yml index 32727eee..178786d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,7 +45,6 @@ services: environment: - COMPOSER_CACHE_DIR=/composer - PROFILE_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - - MFA_SETUP_URL_FOR_TESTS=http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub - ADMIN_EMAIL=john_doe@there.com - ADMIN_PASS=b - SECRET_SALT=abc123 @@ -173,7 +172,6 @@ services: ID_BROKER_BASE_URI: "dummy" ID_BROKER_TRUSTED_IP_RANGES: "192.168.0.1/8" MFA_SETUP_URL: "http://pwmanager.local:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" - MFA_SETUP_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" REMEMBER_ME_SECRET: "12345" PROFILE_URL: "http://pwmanager:8083/module.php/core/authenticate.php?as=ssp-hub-custom-port" PROFILE_URL_FOR_TESTS: "http://pwmanager.local/module.php/core/authenticate.php?as=ssp-hub" diff --git a/features/bootstrap/MfaContext.php b/features/bootstrap/MfaContext.php index b2a80652..ced88bce 100644 --- a/features/bootstrap/MfaContext.php +++ b/features/bootstrap/MfaContext.php @@ -394,8 +394,8 @@ public function iClickTheSetUpMfaButton() */ public function iShouldEndUpAtTheMfaSetupUrl() { - $mfaSetupUrl = Env::get('MFA_SETUP_URL_FOR_TESTS'); - Assert::assertNotEmpty($mfaSetupUrl, 'No MFA_SETUP_URL_FOR_TESTS provided'); + $mfaSetupUrl = Env::get('PROFILE_URL_FOR_TESTS'); + Assert::assertNotEmpty($mfaSetupUrl, 'No PROFILE_URL_FOR_TESTS provided'); $currentUrl = $this->session->getCurrentUrl(); Assert::assertStringStartsWith( $mfaSetupUrl, From d72cd8b322d5fce79f8c5af56118de9800e021f8 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Fri, 10 May 2024 18:40:46 +0800 Subject: [PATCH 12/18] add missing step to mfa scenarios that prompt a review --- features/mfa.feature | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/features/mfa.feature b/features/mfa.feature index e4a90706..a61a61ab 100644 --- a/features/mfa.feature +++ b/features/mfa.feature @@ -212,22 +212,22 @@ Feature: Prompt for MFA credentials When I click the Request Assistance link Then there should be a way to request a manager code -# Scenario: Submit a code sent to my manager at an earlier time -# Given I provide credentials that have a manager code -# And I login -# When I submit the correct manager code -## TODO: add a step here because using a manager code forces profile review -# Then I should end up at my intended destination - -# Scenario: Submit a correct manager code -# Given I provide credentials that have backup codes -# And the user has a manager email -# And I login -# And I click the Request Assistance link -# And I click the Send a code link -# When I submit the correct manager code -## TODO: add a step here because using a manager code forces profile review -# Then I should end up at my intended destination + Scenario: Submit a code sent to my manager at an earlier time + Given I provide credentials that have a manager code + And I login + When I submit the correct manager code + And I click the remind-me-later button + Then I should end up at my intended destination + + Scenario: Submit a correct manager code + Given I provide credentials that have backup codes + And the user has a manager email + And I login + And I click the Request Assistance link + And I click the Send a code link + When I submit the correct manager code + And I click the remind-me-later button + Then I should end up at my intended destination Scenario: Submit an incorrect manager code Given I provide credentials that have backup codes From c13c98c91bb119c4cc9e6bd002c988996abe9c73 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Mon, 13 May 2024 10:34:36 +0800 Subject: [PATCH 13/18] copy relevant README paragraphs from the simplesamlphp-module-mfa repo --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/README.md b/README.md index 4986cd25..8523efe3 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,78 @@ can be autoloaded, to use as the logger within ExpiryDate. This is adapted from the `ssp-iidp-expirycheck` and `expirycheck` modules. Thanks to Alex Mihičinac, Steve Moitozo, and Steve Bagwell for the initial work they did on those two modules. + +### Multi-Factor Authentication (MFA) simpleSAMLphp Module +A simpleSAMLphp module for prompting the user for MFA credentials (such as a +TOTP code, etc.). + +This mfa module is implemented as an Authentication Processing Filter, +or AuthProc. That means it can be configured in the global config.php file or +the SP remote or IdP hosted metadata. + +It is recommended to run the mfa module at the IdP, and configure the +filter to run before all the other filters you may have enabled. + +#### How to use the module + +You will need to set filter parameters in your config. We recommend adding +them to the `'authproc'` array in your `metadata/saml20-idp-hosted.php` file. + +Example (for `metadata/saml20-idp-hosted.php`): + + use Sil\PhpEnv\Env; + use Sil\Psr3Adapters\Psr3SamlLogger; + + // ... + + 'authproc' => [ + 10 => [ + // Required: + 'class' => 'mfa:Mfa', + 'employeeIdAttr' => 'employeeNumber', + 'idBrokerAccessToken' => Env::get('ID_BROKER_ACCESS_TOKEN'), + 'idBrokerAssertValidIp' => Env::get('ID_BROKER_ASSERT_VALID_IP'), + 'idBrokerBaseUri' => Env::get('ID_BROKER_BASE_URI'), + 'idBrokerTrustedIpRanges' => Env::get('ID_BROKER_TRUSTED_IP_RANGES'), + 'idpDomainName' => Env::get('IDP_DOMAIN_NAME'), + 'mfaSetupUrl' => Env::get('MFA_SETUP_URL'), + + // Optional: + 'loggerClass' => Psr3SamlLogger::class, + ], + + // ... + ], + +The `employeeIdAttr` parameter represents the SAML attribute name which has +the user's Employee ID stored in it. In certain situations, this may be +displayed to the user, as well as being used in log messages. + +The `loggerClass` parameter specifies the name of a PSR-3 compatible class that +can be autoloaded, to use as the logger within ExpiryDate. + +The `mfaSetupUrl` parameter is for the URL of where to send the user if they +want/need to set up MFA. + +The `idpDomainName` parameter is used to assemble the Relying Party Origin +(RP Origin) for WebAuthn MFA options. + +#### Why use an AuthProc for MFA? +Based on... + +- the existence of multiple other simpleSAMLphp modules used for MFA and + implemented as AuthProcs, +- implementing my solution as an AuthProc and having a number of tests that all + confirm that it is working as desired, and +- a discussion in the SimpleSAMLphp mailing list about this: + https://groups.google.com/d/msg/simplesamlphp/ocQols0NCZ8/RL_WAcryBwAJ + +... it seems sufficiently safe to implement MFA using a simpleSAMLphp AuthProc. + +For more of the details, please see this Stack Overflow Q&A: +https://stackoverflow.com/q/46566014/3813891 + +#### Acknowledgements +This is adapted from the `silinternational/simplesamlphp-module-mfa` +module, which itself is adapted from other modules. Thanks to all those who +contributed to that work. From a8939e5c8f9152fb261c7ae238a4092c24731f36 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Wed, 15 May 2024 15:32:20 +0800 Subject: [PATCH 14/18] changed namespace on FakeIdBrokerClient to match the repo name and path --- composer.json | 2 +- development/idp-local/config/authsources.php | 2 +- development/idp-local/metadata/saml20-idp-hosted.php | 2 +- features/bootstrap/MfaContext.php | 2 +- features/fakes/FakeIdBrokerClient.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index b5a9031d..a28cafb2 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "vendor/yiisoft/yii2/Yii.php" ], "psr-4": { - "Sil\\SspMfa\\Behat\\": "features/" + "SilInternational\\SspBase\\Features\\": "features/" } }, "config": { diff --git a/development/idp-local/config/authsources.php b/development/idp-local/config/authsources.php index 3af3dac7..99069ecd 100644 --- a/development/idp-local/config/authsources.php +++ b/development/idp-local/config/authsources.php @@ -1,6 +1,6 @@ Date: Wed, 15 May 2024 17:09:34 +0800 Subject: [PATCH 15/18] comments to identify which users are for which module's tests [skip ci] --- development/idp-local/config/authsources.php | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/development/idp-local/config/authsources.php b/development/idp-local/config/authsources.php index 99069ecd..54cadf20 100644 --- a/development/idp-local/config/authsources.php +++ b/development/idp-local/config/authsources.php @@ -15,6 +15,8 @@ // Set up example users for testing expirychecker module. 'example-userpass' => [ 'exampleauth:UserPass', + + // expirychecker test user whose password expires in the distant future 'distant_future:a' => [ 'eduPersonPrincipalName' => ['DISTANT_FUTURE@ssp-idp1.local'], 'sn' => ['Future'], @@ -29,6 +31,8 @@ gmdate('YmdHis\Z', strtotime('+6 months')), // Distant future ], ], + + // expirychecker test user whose password expires in the near future 'near_future:b' => [ 'eduPersonPrincipalName' => ['NEAR_FUTURE@ssp-idp1.local'], 'sn' => ['Future'], @@ -43,6 +47,8 @@ gmdate('YmdHis\Z', strtotime('+1 day')), // Very soon ], ], + + // expirychecker test user whose password expires in the past 'already_past:c' => [ 'eduPersonPrincipalName' => ['ALREADY_PAST@ssp-idp1.local'], 'sn' => ['Past'], @@ -57,6 +63,8 @@ gmdate('YmdHis\Z', strtotime('-1 day')), // In the past ], ], + + // expirychecker test user whose password expiry is missing 'missing_exp:d' => [ 'eduPersonPrincipalName' => ['MISSING_EXP@ssp-idp-1.local'], 'sn' => ['Expiration'], @@ -65,6 +73,8 @@ 'employeeNumber' => ['44444'], 'cn' => ['MISSING_EXP'], ], + + // expirychecker test user whose password expiry is invalid 'invalid_exp:e' => [ 'eduPersonPrincipalName' => ['INVALID_EXP@ssp-idp-1.local'], 'sn' => ['Expiration'], @@ -79,6 +89,8 @@ 'invalid' ], ], + + // profilereview test user whose profile is not due for review 'no_review:e' => [ 'eduPersonPrincipalName' => ['NO_REVIEW@idp'], 'eduPersonTargetID' => ['11111111-1111-1111-1111-111111111111'], @@ -111,6 +123,8 @@ ], 'profile_review' => 'no' ], + + // profilereview test user whose profile is flagged for mfa_add review 'mfa_add:f' => [ 'eduPersonPrincipalName' => ['MFA_ADD@idp'], 'eduPersonTargetID' => ['22222222-2222-2222-2222-222222222222'], @@ -132,6 +146,8 @@ ], 'profile_review' => 'no' ], + + // profilereview test user whose profile is flagged for method_add review 'method_add:g' => [ 'eduPersonPrincipalName' => ['METHOD_ADD@methodidp'], 'eduPersonTargetID' => ['44444444-4444-4444-4444-444444444444'], @@ -164,6 +180,8 @@ ], 'profile_review' => 'no' ], + + // profilereview test user whose profile is flagged for profile review 'profile_review:h' => [ 'eduPersonPrincipalName' => ['METHOD_REVIEW@methodidp'], 'eduPersonTargetID' => ['55555555-5555-5555-5555-555555555555'], @@ -213,6 +231,8 @@ ], 'profile_review' => 'yes' ], + + // mfa test user who does not require mfa 'no_mfa_needed:a' => [ 'eduPersonPrincipalName' => ['NO_MFA_NEEDED@mfaidp'], 'eduPersonTargetID' => ['11111111-1111-1111-1111-111111111111'], @@ -235,6 +255,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa to be set up 'must_set_up_mfa:a' => [ 'eduPersonPrincipalName' => ['MUST_SET_UP_MFA@mfaidp'], 'eduPersonTargetID' => ['22222222-2222-2222-2222-222222222222'], @@ -257,6 +279,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has backup codes 'has_backupcode:a' => [ 'eduPersonPrincipalName' => ['HAS_BACKUPCODE@mfaidp'], 'eduPersonTargetID' => ['33333333-3333-3333-3333-333333333333'], @@ -287,6 +311,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has backup codes and a manager email 'has_backupcode_and_mgr:a' => [ 'eduPersonPrincipalName' => ['HAS_BACKUPCODE@mfaidp'], 'eduPersonTargetID' => ['33333333-3333-3333-3333-333333333333'], @@ -318,6 +344,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has totp 'has_totp:a' => [ 'eduPersonPrincipalName' => ['HAS_TOTP@mfaidp'], 'eduPersonTargetID' => ['44444444-4444-4444-4444-444444444444'], @@ -346,6 +374,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has totp and a manager email 'has_totp_and_mgr:a' => [ 'eduPersonPrincipalName' => ['HAS_TOTP@mfaidp'], 'eduPersonTargetID' => ['44444444-4444-4444-4444-444444444444'], @@ -375,6 +405,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has a webauthn 'has_webauthn:a' => [ 'eduPersonPrincipalName' => ['HAS_WEBAUTHN@mfaidp'], 'eduPersonTargetID' => ['55555555-5555-5555-5555-555555555555'], @@ -408,6 +440,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has webauthn and a manager email 'has_webauthn_and_mgr:a' => [ 'eduPersonPrincipalName' => ['HAS_WEBAUTHN@mfaidp'], 'eduPersonTargetID' => ['55555555-5555-5555-5555-555555555555'], @@ -437,6 +471,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has all forms of mfa 'has_all:a' => [ 'eduPersonPrincipalName' => ['has_all@mfaidp'], 'eduPersonTargetID' => ['77777777-7777-7777-7777-777777777777'], @@ -478,6 +514,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who has a rate-limited mfa 'has_rate_limited_mfa:a' => [ 'eduPersonPrincipalName' => ['HAS_RATE_LIMITED_MFA@mfaidp'], 'eduPersonTargetID' => ['88888888-8888-8888-8888-888888888888'], @@ -508,6 +546,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has 4 backup codes 'has_4_backupcodes:a' => [ 'eduPersonPrincipalName' => ['HAS_4_BACKUPCODES@mfaidp'], 'eduPersonTargetID' => ['99999999-9999-9999-9999-999999999999'], @@ -538,6 +578,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has 1 backup code remaining 'has_1_backupcode_only:a' => [ 'eduPersonPrincipalName' => ['HAS_1_BACKUPCODE_ONLY@mfaidp'], 'eduPersonTargetID' => ['00000010-0010-0010-0010-000000000010'], @@ -568,6 +610,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has one backup code plus another option 'has_1_backupcode_plus:a' => [ 'eduPersonPrincipalName' => ['HAS_1_BACKUPCODE_PLUS@mfaidp'], 'eduPersonTargetID' => ['00000011-0011-0011-0011-000000000011'], @@ -603,6 +647,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has webauthn and totp 'has_webauthn_totp:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_totp@mfaidp'], 'eduPersonTargetID' => ['00000012-0012-0012-0012-000000000012'], @@ -636,6 +682,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has webauthn, totp and a manager email 'has_webauthn_totp_and_mgr:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_totp@mfaidp'], 'eduPersonTargetID' => ['00000012-0012-0012-0012-000000000012'], @@ -670,6 +718,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has webauthn and backup codes 'has_webauthn_backupcodes:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_backupcodes@mfaidp'], 'eduPersonTargetID' => ['00000013-0013-0013-0013-000000000013'], @@ -705,6 +755,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has backup codes and a manager email 'has_webauthn_backupcodes_and_mgr:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_backupcodes@mfaidp'], 'eduPersonTargetID' => ['00000013-0013-0013-0013-000000000013'], @@ -741,6 +793,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has totp and backup codes 'has_webauthn_totp_backupcodes:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_totp_backupcodes@mfaidp'], 'eduPersonTargetID' => ['00000014-0014-0014-0014-000000000014'], @@ -781,6 +835,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has backup codes, totp, and a manager email 'has_webauthn_totp_backupcodes_and_mgr:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_totp_backupcodes@mfaidp'], 'eduPersonTargetID' => ['00000014-0014-0014-0014-000000000014'], @@ -822,6 +878,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has manager code, webauthn, and a more-recently used totp 'has_mgr_code_webauthn_and_more_recently_used_totp:a' => [ 'eduPersonPrincipalName' => ['has_mgr_code_webauthn_and_more_recently_used_totp@mfaidp'], 'eduPersonTargetID' => ['00000114-0014-0014-0014-000000000014'], @@ -863,6 +921,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has webauthn and more recently used totp 'has_webauthn_and_more_recently_used_totp:a' => [ 'eduPersonPrincipalName' => ['has_webauthn_and_more_recently_used_totp@mfaidp'], 'eduPersonTargetID' => ['00000214-0014-0014-0014-000000000014'], @@ -898,6 +958,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has totp and more recently used webauthn 'has_totp_and_more_recently_used_webauthn:a' => [ 'eduPersonPrincipalName' => ['has_totp_and_more_recently_used_webauthn@mfaidp'], 'eduPersonTargetID' => ['00000314-0014-0014-0014-000000000014'], @@ -933,6 +995,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has totp and more recently-used backup code 'has_totp_and_more_recently_used_backup_code:a' => [ 'eduPersonPrincipalName' => ['has_totp_and_more_recently_used_backup_code@mfaidp'], 'eduPersonTargetID' => ['00000414-0014-0014-0014-000000000014'], @@ -970,6 +1034,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has backup code and a more recently used totp 'has_backup_code_and_more_recently_used_totp:a' => [ 'eduPersonPrincipalName' => ['has_backup_code_and_more_recently_used_totp@mfaidp'], 'eduPersonTargetID' => ['00000514-0014-0014-0014-000000000014'], @@ -1007,6 +1073,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has totp and backup codes 'has_totp_backupcodes:a' => [ 'eduPersonPrincipalName' => ['has_totp_backupcodes@mfaidp'], 'eduPersonTargetID' => ['00000015-0015-0015-0015-000000000015'], @@ -1042,6 +1110,8 @@ 'options' => [], ], ], + + // mfa test user who requires mfa and has totp, backup codes, and manager email 'has_totp_backupcodes_and_mgr:a' => [ 'eduPersonPrincipalName' => ['has_totp_backupcodes@mfaidp'], 'eduPersonTargetID' => ['00000015-0015-0015-0015-000000000015'], @@ -1078,6 +1148,8 @@ ], 'manager_email' => ['manager@example.com'], ], + + // mfa test user who requires mfa and has backup codes and manager code 'has_mgr_code:a' => [ 'eduPersonPrincipalName' => ['has_mgr_code@mfaidp'], 'eduPersonTargetID' => ['00000015-0015-0015-0015-000000000015'], From 25f01d1018588ff2630e9b665aab53e0be85528a Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Wed, 15 May 2024 17:11:12 +0800 Subject: [PATCH 16/18] add comments to describe the reason for disabled test cases [skip ci] --- features/mfa.feature | 3 +++ 1 file changed, 3 insertions(+) diff --git a/features/mfa.feature b/features/mfa.feature index a61a61ab..3a0f477c 100644 --- a/features/mfa.feature +++ b/features/mfa.feature @@ -141,6 +141,7 @@ Feature: Prompt for MFA credentials | | TOTP | | supports WebAuthn | TOTP | | | TOTP | , backup codes | supports WebAuthn | TOTP | | | | backup codes | supports WebAuthn | backup code | +# The following cases are disabled due to lack of test support for changing web client user agent # | WebAuthn | | | does not support WebAuthn | WebAuthn | # | WebAuthn | , TOTP | | does not support WebAuthn | TOTP | # | WebAuthn | | , backup codes | does not support WebAuthn | backup code | @@ -163,6 +164,7 @@ Feature: Prompt for MFA credentials | TOTP | WebAuthn | supports WebAuthn | WebAuthn | | TOTP | backup code | supports WebAuthn | backup code | | backup code | TOTP | supports WebAuthn | TOTP | +# The following case is disabled due to lack of test support for changing web client user agent # | TOTP | WebAuthn | does not support WebAuthn | TOTP | Scenario: Defaulting to the manager code despite having a used mfa @@ -180,6 +182,7 @@ Feature: Prompt for MFA credentials Examples: | supports WebAuthn or not | should or not | | supports WebAuthn | should not | +# The following case is disabled due to lack of test support for changing web client user agent # | does not support WebAuthn | should | Scenario Outline: When to show the link to send a manager rescue code From 41e3bfbb6e3c20396b17fa2fc0453ef14ec27793 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Wed, 15 May 2024 17:13:01 +0800 Subject: [PATCH 17/18] add comment to explain why a profile review is required --- features/mfa.feature | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/mfa.feature b/features/mfa.feature index 3a0f477c..1ccdba3a 100644 --- a/features/mfa.feature +++ b/features/mfa.feature @@ -219,6 +219,7 @@ Feature: Prompt for MFA credentials Given I provide credentials that have a manager code And I login When I submit the correct manager code + # because profile review is required after using a manager code: And I click the remind-me-later button Then I should end up at my intended destination @@ -229,6 +230,7 @@ Feature: Prompt for MFA credentials And I click the Request Assistance link And I click the Send a code link When I submit the correct manager code + # because profile review is required after using a manager code: And I click the remind-me-later button Then I should end up at my intended destination From e0941ca5f8a1c62de01a4971c2f0aa824926aa75 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Fri, 17 May 2024 11:44:10 +0800 Subject: [PATCH 18/18] in namespace, change SilInternational to Sil --- composer.json | 2 +- development/idp-local/config/authsources.php | 2 +- development/idp-local/metadata/saml20-idp-hosted.php | 2 +- features/bootstrap/MfaContext.php | 2 +- features/fakes/FakeIdBrokerClient.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index a28cafb2..804b5b82 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "vendor/yiisoft/yii2/Yii.php" ], "psr-4": { - "SilInternational\\SspBase\\Features\\": "features/" + "Sil\\SspBase\\Features\\": "features/" } }, "config": { diff --git a/development/idp-local/config/authsources.php b/development/idp-local/config/authsources.php index 54cadf20..28c8124a 100644 --- a/development/idp-local/config/authsources.php +++ b/development/idp-local/config/authsources.php @@ -1,6 +1,6 @@