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 = (int)$numBackupCodesRemaining ?> 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:
+ = htmlentities($mfaSetupUrl); ?>
+
+
+
+ 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!
+ = htmlentities($this->data['errorMessage']); ?>
+
+
+
+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!
+ = htmlentities($this->data['errorMessage']); ?>
+
+
+
+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!
+ = htmlentities($this->data['errorMessage']); ?>
+
+
+
+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']): ?>
+
+
+
+
+
+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 = $this->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 @@