Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/matomo-org/matomo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Steur <tsteur@users.noreply.github.com>2018-12-03 06:27:29 +0300
committerGitHub <noreply@github.com>2018-12-03 06:27:29 +0300
commit284bdc0816dd2eff4010e4be42812ff3cc7e25e1 (patch)
tree88c60d0e72bae97b5467c5ad7693a64dd477bf3e /plugins/TwoFactorAuth
parente679e0383496383b00f95fd5fd0e42eed4ca49fe (diff)
Implement Two Factor Authentication (#13670)
Diffstat (limited to 'plugins/TwoFactorAuth')
-rw-r--r--plugins/TwoFactorAuth/API.php31
-rw-r--r--plugins/TwoFactorAuth/Activity/TwoFactorDisabled.php34
-rw-r--r--plugins/TwoFactorAuth/Activity/TwoFactorEnabled.php31
-rw-r--r--plugins/TwoFactorAuth/Commands/Disable2FAForUser.php37
-rw-r--r--plugins/TwoFactorAuth/Controller.php325
-rw-r--r--plugins/TwoFactorAuth/Dao/RecoveryCodeDao.php87
-rw-r--r--plugins/TwoFactorAuth/Dao/RecoveryCodeRandomGenerator.php19
-rw-r--r--plugins/TwoFactorAuth/Dao/RecoveryCodeStaticGenerator.php20
-rw-r--r--plugins/TwoFactorAuth/Dao/TwoFaSecretRandomGenerator.php20
-rw-r--r--plugins/TwoFactorAuth/Dao/TwoFaSecretStaticGenerator.php17
-rw-r--r--plugins/TwoFactorAuth/FormTwoFactorAuthCode.php33
-rw-r--r--plugins/TwoFactorAuth/SystemSettings.php51
-rw-r--r--plugins/TwoFactorAuth/TwoFactorAuth.php218
-rw-r--r--plugins/TwoFactorAuth/TwoFactorAuthentication.php136
-rw-r--r--plugins/TwoFactorAuth/Validator.php88
-rw-r--r--plugins/TwoFactorAuth/angularjs/setuptwofactor/setuptwofactor.controller.js54
-rw-r--r--plugins/TwoFactorAuth/config/test.php69
-rw-r--r--plugins/TwoFactorAuth/javascripts/twofactorauth.js12
-rw-r--r--plugins/TwoFactorAuth/lang/en.json52
-rw-r--r--plugins/TwoFactorAuth/stylesheets/twofactorauth.less16
-rw-r--r--plugins/TwoFactorAuth/templates/_setupTwoFactorAuth.twig73
-rw-r--r--plugins/TwoFactorAuth/templates/_showRecoveryCodes.twig38
-rw-r--r--plugins/TwoFactorAuth/templates/loginTwoFactorAuth.twig56
-rw-r--r--plugins/TwoFactorAuth/templates/setupFinished.twig11
-rw-r--r--plugins/TwoFactorAuth/templates/setupTwoFactorAuth.twig8
-rw-r--r--plugins/TwoFactorAuth/templates/setupTwoFactorAuthStandalone.twig9
-rw-r--r--plugins/TwoFactorAuth/templates/showRecoveryCodes.twig42
-rw-r--r--plugins/TwoFactorAuth/templates/userSettings.twig41
-rw-r--r--plugins/TwoFactorAuth/tests/Fixtures/TwoFactorFixture.php102
-rw-r--r--plugins/TwoFactorAuth/tests/Fixtures/TwoFactorUsersManagerFixture.php14
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/APITest.php99
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeDaoTest.php166
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeRandomGeneratorTest.php44
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeStaticGeneratorTest.php49
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretRandomGeneratorTest.php44
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretStaticGeneratorTest.php42
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/SystemSettingsTest.php44
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthTest.php144
-rw-r--r--plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthenticationTest.php192
-rw-r--r--plugins/TwoFactorAuth/tests/UI/.gitignore2
-rw-r--r--plugins/TwoFactorAuth/tests/UI/TwoFactorAuthUsersManager_spec.js76
-rw-r--r--plugins/TwoFactorAuth/tests/UI/TwoFactorAuth_spec.js232
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/.gitkeep0
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_list.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified_wrong_code.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_verified.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step1.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step2.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step1.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step2.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step3.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step4.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step1.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step2.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step3.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step4.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step1.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step2.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step3.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled_required.png3
-rw-r--r--plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_widgetized_no_verify.png3
66 files changed, 2947 insertions, 0 deletions
diff --git a/plugins/TwoFactorAuth/API.php b/plugins/TwoFactorAuth/API.php
new file mode 100644
index 0000000000..d0ef5024f1
--- /dev/null
+++ b/plugins/TwoFactorAuth/API.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Piwik\Piwik;
+
+class API extends \Piwik\Plugin\API
+{
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ public function __construct(TwoFactorAuthentication $twoFa)
+ {
+ $this->twoFa = $twoFa;
+ }
+
+ public function resetTwoFactorAuth($userLogin)
+ {
+ Piwik::checkUserHasSuperUserAccess();
+
+ $this->twoFa->disable2FAforUser($userLogin);
+ }
+}
diff --git a/plugins/TwoFactorAuth/Activity/TwoFactorDisabled.php b/plugins/TwoFactorAuth/Activity/TwoFactorDisabled.php
new file mode 100644
index 0000000000..db47e564fc
--- /dev/null
+++ b/plugins/TwoFactorAuth/Activity/TwoFactorDisabled.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\Activity;
+
+use Piwik\Piwik;
+use Piwik\Plugins\ActivityLog\Activity\Activity;
+
+class TwoFactorDisabled extends Activity
+{
+ protected $eventName = 'TwoFactorAuth.disabled';
+
+ public function extractParams($eventData)
+ {
+ list($userLogin) = $eventData;
+
+ return [
+ 'login' => $userLogin
+ ];
+ }
+
+ public function getTranslatedDescription($activityData, $performingUser)
+ {
+ if (!empty($activityData['login']) && $performingUser !== $activityData['login']) {
+ return Piwik::translate('TwoFactorAuth_ActivityDisabledTwoFactorAuthForUser', '"' . $activityData['login'] . '"');
+ }
+ return Piwik::translate('TwoFactorAuth_ActivityDisabledTwoFactorAuth');
+ }
+}
diff --git a/plugins/TwoFactorAuth/Activity/TwoFactorEnabled.php b/plugins/TwoFactorAuth/Activity/TwoFactorEnabled.php
new file mode 100644
index 0000000000..daf1da0022
--- /dev/null
+++ b/plugins/TwoFactorAuth/Activity/TwoFactorEnabled.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\Activity;
+
+use Piwik\Piwik;
+use Piwik\Plugins\ActivityLog\Activity\Activity;
+
+class TwoFactorEnabled extends Activity
+{
+ protected $eventName = 'TwoFactorAuth.enabled';
+
+ public function extractParams($eventData)
+ {
+ list($userLogin) = $eventData;
+
+ return [
+ 'login' => $userLogin
+ ];
+ }
+
+ public function getTranslatedDescription($activityData, $performingUser)
+ {
+ return Piwik::translate('TwoFactorAuth_ActivityEnabledTwoFactorAuth');
+ }
+}
diff --git a/plugins/TwoFactorAuth/Commands/Disable2FAForUser.php b/plugins/TwoFactorAuth/Commands/Disable2FAForUser.php
new file mode 100644
index 0000000000..20d8d421a9
--- /dev/null
+++ b/plugins/TwoFactorAuth/Commands/Disable2FAForUser.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\Commands;
+
+use Piwik\API\Request;
+use Piwik\Plugin\ConsoleCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Disable2FAForUser extends ConsoleCommand
+{
+ protected function configure()
+ {
+ $this->setName('twofactorauth:disable-2fa-for-user');
+ $this->setDescription('Disable two-factor authentication for a user. Useful if a user loses the device that was used for two-factor authentication. After it was disabled, the user will be able to set it up again.');
+ $this->addOption('login', null, InputOption::VALUE_REQUIRED, 'Login of an existing user');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $this->checkAllRequiredOptionsAreNotEmpty($input);
+ $login = $input->getOption('login');
+
+ Request::processRequest('TwoFactorAuth.resetTwoFactorAuth', array(
+ 'userLogin' => $login
+ ));
+ $message = sprintf('<info>Disabled two-factor authentication for user: %s</info>', $login);
+ $output->writeln($message);
+ }
+}
diff --git a/plugins/TwoFactorAuth/Controller.php b/plugins/TwoFactorAuth/Controller.php
new file mode 100644
index 0000000000..72bee05c88
--- /dev/null
+++ b/plugins/TwoFactorAuth/Controller.php
@@ -0,0 +1,325 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Endroid\QrCode\QrCode;
+use Piwik\API\Request;
+use Piwik\Common;
+use Piwik\Nonce;
+use Piwik\Piwik;
+use Piwik\Plugins\Login\PasswordVerifier;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Session\SessionFingerprint;
+use Piwik\Session\SessionNamespace;
+use Piwik\Url;
+use Piwik\View;
+use Exception;
+
+class Controller extends \Piwik\Plugin\Controller
+{
+ const AUTH_CODE_NONCE = 'TwoFactorAuth.saveAuthCode';
+ const LOGIN_2FA_NONCE = 'TwoFactorAuth.loginAuthCode';
+ const DISABLE_2FA_NONCE = 'TwoFactorAuth.disableAuthCode';
+ const REGENERATE_CODES_2FA_NONCE = 'TwoFactorAuth.regenerateCodes';
+ const VERIFY_PASSWORD_NONCE = 'TwoFactorAuth.verifyPassword';
+
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $recoveryCodeDao;
+
+ /**
+ * @var PasswordVerifier
+ */
+ private $passwordVerify;
+
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ /**
+ * @var Validator
+ */
+ private $validator;
+
+ public function __construct(SystemSettings $systemSettings, RecoveryCodeDao $recoveryCodeDao, PasswordVerifier $passwordVerify, TwoFactorAuthentication $twoFa, Validator $validator)
+ {
+ $this->settings = $systemSettings;
+ $this->recoveryCodeDao = $recoveryCodeDao;
+ $this->passwordVerify = $passwordVerify;
+ $this->twoFa = $twoFa;
+ $this->validator = $validator;
+
+ parent::__construct();
+ }
+
+ public function loginTwoFactorAuth()
+ {
+ $this->validator->checkCanUseTwoFa();
+ $this->validator->check2FaEnabled();
+ $this->validator->checkNotVerified2FAYet();
+
+ $messageNoAccess = null;
+
+ $view = new View('@TwoFactorAuth/loginTwoFactorAuth');
+ $form = new FormTwoFactorAuthCode();
+ $form->removeAttribute('action'); // remove action attribute, otherwise hash part will be lost
+ if ($form->validate()) {
+ $nonce = $form->getSubmitValue('form_nonce');
+ if ($nonce && Nonce::verifyNonce(self::LOGIN_2FA_NONCE, $nonce) && $form->validate()) {
+ $authCode = $form->getSubmitValue('form_authcode');
+ if ($authCode && is_string($authCode)) {
+ $authCode = str_replace('-', '', $authCode);
+ $authCode = strtoupper($authCode); // recovery codes are stored upper case, app codes are only numbers
+ $authCode = trim($authCode);
+ }
+
+ if ($this->twoFa->validateAuthCode(Piwik::getCurrentUserLogin(), $authCode)) {
+ $sessionFingerprint = new SessionFingerprint();
+ $sessionFingerprint->setTwoFactorAuthenticationVerified();
+ Url::redirectToUrl(Url::getCurrentUrl());
+ } else {
+ $messageNoAccess = Piwik::translate('TwoFactorAuth_InvalidAuthCode');
+ }
+ } else {
+ $messageNoAccess = Piwik::translate('Login_InvalidNonceOrHeadersOrReferrer', array('<a target="_blank" rel="noreferrer noopener" href="https://matomo.org/faq/how-to-install/#faq_98">', '</a>'));
+ }
+ }
+ $superUsers = Request::processRequest('UsersManager.getUsersHavingSuperUserAccess', [], []);
+ $view->superUserEmails = implode(',', array_column($superUsers, 'email'));
+ $view->loginModule = Piwik::getLoginPluginName();
+ $view->AccessErrorString = $messageNoAccess;
+ $view->addForm($form);
+ $this->setBasicVariablesView($view);
+ $view->nonce = Nonce::getNonce(self::LOGIN_2FA_NONCE);
+
+ return $view->render();
+ }
+
+ public function userSettings()
+ {
+ $this->validator->checkCanUseTwoFa();
+
+ return $this->renderTemplate('userSettings', array(
+ 'isEnabled' => $this->twoFa->isUserUsingTwoFactorAuthentication(Piwik::getCurrentUserLogin()),
+ 'isForced' => $this->twoFa->isUserRequiredToHaveTwoFactorEnabled(),
+ 'disableNonce' => Nonce::getNonce(self::DISABLE_2FA_NONCE)
+ ));
+ }
+
+ public function disableTwoFactorAuth()
+ {
+ $this->validator->checkCanUseTwoFa();
+ $this->validator->check2FaEnabled();
+ $this->validator->checkVerified2FA();
+
+ if ($this->twoFa->isUserRequiredToHaveTwoFactorEnabled()) {
+ throw new Exception('Two-factor authentication cannot be disabled as it is enforced');
+ }
+
+ $nonce = Common::getRequestVar('disableNonce', null, 'string');
+ $params = array('module' => 'TwoFactorAuth', 'action' => 'disableTwoFactorAuth', 'disableNonce' => $nonce);
+
+ if ($this->passwordVerify->requirePasswordVerifiedRecently($params)) {
+
+ Nonce::checkNonce(self::DISABLE_2FA_NONCE, $nonce);
+
+ $this->twoFa->disable2FAforUser(Piwik::getCurrentUserLogin());
+ $this->passwordVerify->forgetVerifiedPassword();
+
+ $this->redirectToIndex('UsersManager', 'userSettings', null, null, null, array(
+ 'disableNonce' => false
+ ));
+ }
+ }
+
+ private function make2faSession()
+ {
+ return new SessionNamespace('TwoFactorAuthenticator');
+ }
+
+ public function onLoginSetupTwoFactorAuth()
+ {
+ // called when 2fa is required, but user has not yet set up 2fa
+ $this->validator->checkCanUseTwoFa();
+ $this->validator->check2FaNotEnabled();
+ $this->validator->check2FaIsRequired();
+
+ return $this->setupTwoFactorAuth($standalone = true);
+ }
+
+ /**
+ * Action to setup two factor authentication
+ *
+ * @return string
+ * @throws \Exception
+ */
+ public function setupTwoFactorAuth($standalone = false)
+ {
+ $this->validator->checkCanUseTwoFa();
+
+ if ($standalone) {
+ $view = new View('@TwoFactorAuth/setupTwoFactorAuthStandalone');
+ $this->setBasicVariablesView($view);
+ $view->submitAction = 'onLoginSetupTwoFactorAuth';
+ } else {
+ $view = new View('@TwoFactorAuth/setupTwoFactorAuth');
+ $this->setGeneralVariablesView($view);
+ $view->submitAction = 'setupTwoFactorAuth';
+
+ $redirectParams = array('module' => 'TwoFactorAuth', 'action' => 'setupTwoFactorAuth');
+ if (!$this->passwordVerify->requirePasswordVerified($redirectParams)) {
+ // should usually not go in here but redirect instead
+ throw new Exception('You have to verify your password first.');
+ }
+ }
+
+ $session = $this->make2faSession();
+
+ if (empty($session->secret)) {
+ $session->secret = $this->twoFa->generateSecret();
+ }
+
+ $secret = $session->secret;
+ $session->setExpirationSeconds(60 * 15, 'secret');
+
+ $authCode = Common::getRequestVar('authCode', '', 'string');
+ $authCodeNonce = Common::getRequestVar('authCodeNonce', '', 'string');
+ $hasSubmittedForm = !empty($authCodeNonce) || !empty($authCode);
+ $accessErrorString = '';
+ $login = Piwik::getCurrentUserLogin();
+
+ if (!empty($secret) && !empty($authCode)
+ && Nonce::verifyNonce(self::AUTH_CODE_NONCE, $authCodeNonce)) {
+ if ($this->twoFa->validateAuthCodeDuringSetup(trim($authCode), $secret)) {
+ $this->twoFa->saveSecret($login, $secret);
+ $fingerprint = new SessionFingerprint();
+ $fingerprint->setTwoFactorAuthenticationVerified();
+ unset($session->secret);
+ $this->passwordVerify->forgetVerifiedPassword();
+
+ Piwik::postEvent('TwoFactorAuth.enabled', array($login));
+
+ if ($standalone) {
+ $this->redirectToIndex('CoreHome', 'index');
+ return;
+ }
+
+ $view = new View('@TwoFactorAuth/setupFinished');
+ $this->setGeneralVariablesView($view);
+ return $view->render();
+ } else {
+ $accessErrorString = Piwik::translate('TwoFactorAuth_WrongAuthCodeTryAgain');
+ }
+ } elseif (!$standalone) {
+ // the user has not posted the form... at least not with a valid nonce... we make sure the password verify
+ // is valid for at least another 15 minutes and if not, ask for another password confirmation to avoid
+ // the user may be posting a valid auth code after rendering this screen but the password verify is invalid
+ // by then.
+ $redirectParams = array('module' => 'TwoFactorAuth', 'action' => 'setupTwoFactorAuth');
+ if (!$this->passwordVerify->requirePasswordVerifiedRecently($redirectParams)) {
+ throw new Exception('You have to verify your password first.');
+ }
+ }
+
+ if (!$this->recoveryCodeDao->getAllRecoveryCodesForLogin($login)
+ || (!$hasSubmittedForm && !$this->twoFa->isUserUsingTwoFactorAuthentication($login))) {
+ // we cannot generate new codes after form has been submitted and user is not yet using 2fa cause we would
+ // change recovery codes in the background without the user noticing... we cannot simply do this:
+ // if !getAllRecoveryCodesForLogin => createRecoveryCodesForLogin. Because it could be a security issue that
+ // user might start the setup but never finishes. Before setting up 2fa the first time we have to change
+ // the recovery codes
+ $this->recoveryCodeDao->createRecoveryCodesForLogin($login);
+ }
+
+ $view->title = $this->settings->twoFactorAuthTitle->getValue();
+ $view->description = $login;
+ $view->authCodeNonce = Nonce::getNonce(self::AUTH_CODE_NONCE);
+ $view->AccessErrorString = $accessErrorString;
+ $view->isAlreadyUsing2fa = $this->twoFa->isUserUsingTwoFactorAuthentication($login);
+ $view->newSecret = $secret;
+ $view->authImage = $this->getQRUrl($view->description, $view->gatitle);
+ $view->codes = $this->recoveryCodeDao->getAllRecoveryCodesForLogin($login);
+ $view->standalone = $standalone;
+
+ return $view->render();
+ }
+
+ public function showRecoveryCodes()
+ {
+ $this->validator->checkCanUseTwoFa();
+ $this->validator->checkVerified2FA();
+ $this->validator->check2FaEnabled();
+
+ $regenerateNonce = Common::getRequestVar('regenerateNonce', '', 'string', $_POST);
+ $postedValidNonce = !empty($regenerateNonce) && Nonce::verifyNonce(self::REGENERATE_CODES_2FA_NONCE, $regenerateNonce);
+
+ $regenerateSuccess = false;
+ $regenerateError = false;
+
+ if ($postedValidNonce && $this->passwordVerify->hasBeenVerified()) {
+ $this->passwordVerify->forgetVerifiedPassword();
+ $this->recoveryCodeDao->createRecoveryCodesForLogin(Piwik::getCurrentUserLogin());
+ $regenerateSuccess = true;
+ // no need to redirect as password was verified nonce
+ // if user has posted a valid nonce, we do not need to require password again as nonce must have been generated recent
+ // avoids use case where eg password verify is only valid for one more minute when opening the page but user regenerates 2min later
+ } elseif (!$this->passwordVerify->requirePasswordVerifiedRecently(array('module' => 'TwoFactorAuth', 'action' => 'showRecoveryCodes'))) {
+ // should usually not go in here but redirect instead
+ throw new Exception('You have to verify your password first.');
+ }
+
+ if (!$postedValidNonce && !empty($regenerateNonce)) {
+ $regenerateError = true;
+ }
+
+ $recoveryCodes = $this->recoveryCodeDao->getAllRecoveryCodesForLogin(Piwik::getCurrentUserLogin());
+
+ return $this->renderTemplate('showRecoveryCodes', array(
+ 'codes' => $recoveryCodes,
+ 'regenerateNonce' => Nonce::getNonce(self::REGENERATE_CODES_2FA_NONCE),
+ 'regenerateError' => $regenerateError,
+ 'regenerateSuccess' => $regenerateSuccess
+ ));
+ }
+
+ public function showQrCode()
+ {
+ $this->validator->checkCanUseTwoFa();
+
+ $session = $this->make2faSession();
+ $secret = $session->secret;
+ if (empty($secret)) {
+ throw new Exception('Not available');
+ }
+ $title = $this->settings->twoFactorAuthTitle->getValue();
+ $descr = Piwik::getCurrentUserLogin();
+
+ $url = 'otpauth://totp/'.urlencode($descr).'?secret='.$secret;
+ if(isset($title)) {
+ $url .= '&issuer='.urlencode($title);
+ }
+
+ $qrCode = new QrCode($url);
+
+ header('Content-Type: '.$qrCode->getContentType());
+ echo $qrCode->get();
+ }
+
+ protected function getQRUrl($description, $title)
+ {
+ return sprintf('index.php?module=TwoFactorAuth&action=showQrCode&cb=%s&title=%s&descr=%s', Common::getRandomString(8), urlencode($title), urlencode($description));
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/Dao/RecoveryCodeDao.php b/plugins/TwoFactorAuth/Dao/RecoveryCodeDao.php
new file mode 100644
index 0000000000..4445edec07
--- /dev/null
+++ b/plugins/TwoFactorAuth/Dao/RecoveryCodeDao.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\Dao;
+
+use Piwik\Common;
+use Piwik\Db;
+
+class RecoveryCodeDao
+{
+ protected $table = 'twofactor_recovery_code';
+ protected $tablePrefixed = '';
+
+ /**
+ * @var RecoveryCodeRandomGenerator $generator
+ */
+ private $generator;
+
+ public function __construct(RecoveryCodeRandomGenerator $generator)
+ {
+ $this->tablePrefixed = Common::prefixTable($this->table);
+ $this->generator = $generator;
+ }
+
+ public function getPrefixedTableName()
+ {
+ return $this->tablePrefixed;
+ }
+
+ public function createRecoveryCodesForLogin($login)
+ {
+ $codes = array();
+ $this->deleteAllRecoveryCodesForLogin($login);
+
+ for ($i = 0; $i < 10; $i++) {
+ $code = $this->generator->generateCode();
+ $code = Common::mb_strtoupper($code);
+ $this->insertRecoveryCode($login, $code);
+ $codes[] = $code;
+ }
+ return $codes;
+ }
+
+ public function insertRecoveryCode($login, $recoveryCode)
+ {
+ // we do not really care about duplicates as it is very unlikely to happen, that's why we don't even use a
+ // unique login,recovery_code index
+ $sql = sprintf('INSERT INTO %s (`login`, `recovery_code`) VALUES(?,?)', $this->tablePrefixed);
+ Db::query($sql, array($login, $recoveryCode));
+ }
+
+ public function useRecoveryCode($login, $recoveryCode)
+ {
+ if ($this->deleteRecoveryCode($login, $recoveryCode)) {
+ return true;
+ }
+ return false;
+ }
+
+ public function getAllRecoveryCodesForLogin($login)
+ {
+ $sql = sprintf('SELECT recovery_code FROM %s WHERE login = ?', $this->tablePrefixed);
+ $rows = Db::fetchAll($sql, array($login));
+ $codes = array_column($rows, 'recovery_code');
+ return $codes;
+ }
+
+ public function deleteRecoveryCode($login, $recoveryCode)
+ {
+ $sql = sprintf('DELETE FROM %s WHERE login = ? and recovery_code = ?', $this->tablePrefixed);
+ $query = Db::query($sql, array($login, $recoveryCode));
+ return $query->rowCount();
+ }
+
+ public function deleteAllRecoveryCodesForLogin($login)
+ {
+ $query = sprintf('DELETE FROM %s WHERE login = ?', $this->tablePrefixed);
+
+ Db::query($query, array($login));
+ }
+
+}
+
diff --git a/plugins/TwoFactorAuth/Dao/RecoveryCodeRandomGenerator.php b/plugins/TwoFactorAuth/Dao/RecoveryCodeRandomGenerator.php
new file mode 100644
index 0000000000..8ab5295578
--- /dev/null
+++ b/plugins/TwoFactorAuth/Dao/RecoveryCodeRandomGenerator.php
@@ -0,0 +1,19 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\Dao;
+
+use Piwik\Common;
+
+class RecoveryCodeRandomGenerator
+{
+ public function generateCode()
+ {
+ return Common::getRandomString(16);
+ }
+}
+
diff --git a/plugins/TwoFactorAuth/Dao/RecoveryCodeStaticGenerator.php b/plugins/TwoFactorAuth/Dao/RecoveryCodeStaticGenerator.php
new file mode 100644
index 0000000000..4512656dd2
--- /dev/null
+++ b/plugins/TwoFactorAuth/Dao/RecoveryCodeStaticGenerator.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\Dao;
+
+class RecoveryCodeStaticGenerator extends RecoveryCodeRandomGenerator
+{
+ private $index = 10;
+
+ public function generateCode()
+ {
+ $this->index++;
+ return str_pad($this->index, 16, '0');
+ }
+}
+
diff --git a/plugins/TwoFactorAuth/Dao/TwoFaSecretRandomGenerator.php b/plugins/TwoFactorAuth/Dao/TwoFaSecretRandomGenerator.php
new file mode 100644
index 0000000000..cd6d3220ce
--- /dev/null
+++ b/plugins/TwoFactorAuth/Dao/TwoFaSecretRandomGenerator.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\Dao;
+
+require_once PIWIK_DOCUMENT_ROOT . '/libs/Authenticator/TwoFactorAuthenticator.php';
+
+class TwoFaSecretRandomGenerator
+{
+ public function generateSecret()
+ {
+ $authenticator = new \TwoFactorAuthenticator();
+ return $authenticator->createSecret(16);
+ }
+}
+
diff --git a/plugins/TwoFactorAuth/Dao/TwoFaSecretStaticGenerator.php b/plugins/TwoFactorAuth/Dao/TwoFaSecretStaticGenerator.php
new file mode 100644
index 0000000000..b53ee463d1
--- /dev/null
+++ b/plugins/TwoFactorAuth/Dao/TwoFaSecretStaticGenerator.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\Dao;
+
+class TwoFaSecretStaticGenerator extends TwoFaSecretRandomGenerator
+{
+ public function generateSecret()
+ {
+ return str_pad('1', 16, '1');
+ }
+}
+
diff --git a/plugins/TwoFactorAuth/FormTwoFactorAuthCode.php b/plugins/TwoFactorAuth/FormTwoFactorAuthCode.php
new file mode 100644
index 0000000000..e621d7846e
--- /dev/null
+++ b/plugins/TwoFactorAuth/FormTwoFactorAuthCode.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Piwik\Piwik;
+use Piwik\QuickForm2;
+
+/**
+ *
+ */
+class FormTwoFactorAuthCode extends QuickForm2
+{
+ function __construct($id = 'login_form', $method = 'post', $attributes = null, $trackSubmit = false)
+ {
+ parent::__construct($id, $method, $attributes, $trackSubmit);
+ }
+
+ function init()
+ {
+ $this->addElement('text', 'form_authcode')
+ ->addRule('required',
+ Piwik::translate('General_Required', 'Authentication code'));
+
+ $this->addElement('hidden', 'form_nonce');
+
+ $this->addElement('submit', 'submit');
+ }
+}
diff --git a/plugins/TwoFactorAuth/SystemSettings.php b/plugins/TwoFactorAuth/SystemSettings.php
new file mode 100644
index 0000000000..ee97f27c63
--- /dev/null
+++ b/plugins/TwoFactorAuth/SystemSettings.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Piwik\Plugin;
+use Piwik\Settings\Setting;
+use Piwik\Settings\FieldConfig;
+use Piwik\Url;
+
+class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings
+{
+ /** @var Setting */
+ public $twoFactorAuthRequired;
+
+ /** @var Setting */
+ public $twoFactorAuthTitle;
+
+ protected function init()
+ {
+ $this->twoFactorAuthRequired = $this->createRequire2FA();
+ $this->twoFactorAuthTitle = $this->create2FATitle();
+ }
+
+ private function createRequire2FA()
+ {
+ return $this->makeSetting('twoFactorAuthRequired', $default = false, FieldConfig::TYPE_BOOL, function (FieldConfig $field) {
+ $field->title = 'Require two-factor authentication for everyone';
+ $field->description = 'When enabled, every user has to enable two factor authentication.';
+ $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX;
+ });
+ }
+
+ private function create2FATitle()
+ {
+ $default = 'Analytics - ' . Url::getCurrentHost('');
+ if (Plugin\Manager::getInstance()->isPluginActivated('WhiteLabel')) {
+ $default = 'Matomo ' . $default;
+ }
+ return $this->makeSetting('twoFactorAuthName', $default, FieldConfig::TYPE_STRING, function (FieldConfig $field) {
+ $field->title = 'Two-factor authentication title';
+ $field->uiControl = FieldConfig::UI_CONTROL_TEXT;
+ $field->description = 'The name of the title to display that will be displayed in the Authenticator app.';
+ });
+ }
+}
diff --git a/plugins/TwoFactorAuth/TwoFactorAuth.php b/plugins/TwoFactorAuth/TwoFactorAuth.php
new file mode 100644
index 0000000000..3c8118005e
--- /dev/null
+++ b/plugins/TwoFactorAuth/TwoFactorAuth.php
@@ -0,0 +1,218 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Piwik\API\Request;
+use Piwik\Common;
+use Piwik\Container\StaticContainer;
+use Piwik\FrontController;
+use Piwik\Piwik;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Plugins\UsersManager\Model;
+use Piwik\Session;
+use Piwik\Session\SessionFingerprint;
+use Exception;
+use Piwik\SettingsPiwik;
+
+class TwoFactorAuth extends \Piwik\Plugin
+{
+ /**
+ * @see \Piwik\Plugin::registerEvents
+ */
+ public function registerEvents()
+ {
+ return array(
+ 'Request.dispatch' => array('function' => 'onRequestDispatch', 'after' => true),
+ 'AssetManager.getJavaScriptFiles' => 'getJsFiles',
+ 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles',
+ 'API.UsersManager.deleteUser.end' => 'deleteRecoveryCodes',
+ 'API.UsersManager.getTokenAuth.end' => 'onApiGetTokenAuth',
+ 'Request.dispatch.end' => array('function' => 'onRequestDispatchEnd', 'after' => true),
+ 'Template.userSettings.afterTokenAuth' => 'render2FaUserSettings',
+ 'Login.authenticate.processSuccessfulSession.end' => 'onSuccessfulSession'
+ );
+ }
+
+ public function getStylesheetFiles(&$stylesheets)
+ {
+ $stylesheets[] = "plugins/TwoFactorAuth/stylesheets/twofactorauth.less";
+ }
+
+ public function getJsFiles(&$jsFiles)
+ {
+ $jsFiles[] = "plugins/TwoFactorAuth/javascripts/twofactorauth.js";
+ $jsFiles[] = "plugins/TwoFactorAuth/angularjs/setuptwofactor/setuptwofactor.controller.js";
+ }
+
+ public function deleteRecoveryCodes($returnedValue, $params)
+ {
+ $model = new Model();
+ if (!empty($params['parameters']['userLogin'])
+ && !$model->userExists($params['parameters']['userLogin'])) {
+ // we delete only if the deletion was really successful
+ $dao = StaticContainer::get(RecoveryCodeDao::class);
+ $dao->deleteAllRecoveryCodesForLogin($params['parameters']['userLogin']);
+ }
+ }
+
+ public function render2FaUserSettings(&$out)
+ {
+ $validator = $this->getValidator();
+ if ($validator->canUseTwoFa()) {
+ $content = FrontController::getInstance()->dispatch('TwoFactorAuth', 'userSettings');
+ if (!empty($content)) {
+ $out .= $content;
+ }
+ }
+ }
+
+ public function onSuccessfulSession($login)
+ {
+ if (Piwik::getModule() === 'Login' && Piwik::getAction() === 'logme' && $login) {
+ // we allow user to send an "authCode" along logme to directly log in... if not, user will see the
+ // auth code verification screen after logme
+ $authCode = Common::getRequestVar('authCode', '', 'string');
+ $twoFa = $this->getTwoFa();
+
+ if ($authCode
+ && $twoFa->isUserUsingTwoFactorAuthentication($login)
+ && $twoFa->validateAuthCode($login, $authCode)) {
+ $sessionFingerprint = new SessionFingerprint();
+ $sessionFingerprint->setTwoFactorAuthenticationVerified();
+ }
+ }
+ }
+
+ private function getTwoFa()
+ {
+ return StaticContainer::get(TwoFactorAuthentication::class);
+ }
+
+ private function getValidator()
+ {
+ return StaticContainer::get(Validator::class);
+ }
+
+ private function isValidTokenAuth($tokenAuth)
+ {
+ $model = new Model();
+ $user = $model->getUserByTokenAuth($tokenAuth);
+ return !empty($user);
+ }
+
+ public function onApiGetTokenAuth($returnedValue, $params)
+ {
+ if (!SettingsPiwik::isPiwikInstalled()) {
+ return;
+ }
+
+ if (!empty($returnedValue) && !empty($params['parameters']['userLogin'])) {
+ $login = $params['parameters']['userLogin'];
+ $twoFa = $this->getTwoFa();
+
+ if ($twoFa->isUserUsingTwoFactorAuthentication($login) && $this->isValidTokenAuth($returnedValue)) {
+ $authCode = Common::getRequestVar('authCode', '', 'string');
+ // we only return an error when the login/password combo was correct. otherwise you could brute force
+ // auth tokens
+ if (!$authCode) {
+ http_response_code(401);
+ throw new Exception(Piwik::translate('TwoFactorAuth_MissingAuthCodeAPI'));
+ }
+ if (!$twoFa->validateAuthCode($login, $authCode)) {
+ http_response_code(401);
+ throw new Exception(Piwik::translate('TwoFactorAuth_InvalidAuthCode'));
+ }
+ } else if ($twoFa->isUserRequiredToHaveTwoFactorEnabled()
+ && !$twoFa->isUserUsingTwoFactorAuthentication($login)) {
+ throw new Exception(Piwik::translate('TwoFactorAuth_RequiredAuthCodeNotConfiguredAPI'));
+ }
+ }
+ }
+
+ public function onRequestDispatch(&$module, &$action, $parameters)
+ {
+ $validator = $this->getValidator();
+ if (!$validator->canUseTwoFa()) {
+ return;
+ }
+
+ if ($module === 'Proxy') {
+ return;
+ }
+
+ if ($module === 'TwoFactorAuth' && $action === 'showQrCode') {
+ return;
+ }
+
+ if ($module === Piwik::getLoginPluginName() && $action === 'logout') {
+ return;
+ }
+
+ if (Piwik::getModule() === 'Widgetize') {
+ // we cannot use $module as it would be different when dispatching other requests within the widgetized request
+ $auth = StaticContainer::get('Piwik\Auth');
+ if ($auth && !$auth->getLogin() && method_exists($auth, 'getTokenAuth') && $auth->getTokenAuth()) {
+ // when authenticated by token only, we do not require 2fa
+ // needed eg for rendering exported widgets authenticated by token
+ return;
+ }
+ }
+
+ $requiresAuth = true;
+ Piwik::postEvent('TwoFactorAuth.requiresTwoFactorAuthentication', array(&$requiresAuth, $module, $action, $parameters));
+
+ if (!$requiresAuth) {
+ return;
+ }
+
+ $twoFa = $this->getTwoFa();
+
+ $isUsing2FA = $twoFa->isUserUsingTwoFactorAuthentication(Piwik::getCurrentUserLogin());
+ if ($isUsing2FA && !Request::isRootRequestApiRequest() && Session::isStarted()) {
+ $sessionFingerprint = new SessionFingerprint();
+ if (!$sessionFingerprint->hasVerifiedTwoFactor()) {
+ $module = 'TwoFactorAuth';
+ $action = 'loginTwoFactorAuth';
+ }
+ } elseif (!$isUsing2FA && $twoFa->isUserRequiredToHaveTwoFactorEnabled()) {
+ $module = 'TwoFactorAuth';
+ $action = 'onLoginSetupTwoFactorAuth';
+ }
+ }
+
+ public function onRequestDispatchEnd(&$result, $module, $action, $parameters)
+ {
+ $validator = $this->getValidator();
+ if (!$validator->canUseTwoFa()) {
+ return;
+ }
+
+ $twoFa = $this->getTwoFa();
+
+ $isUsing2FA = $twoFa->isUserUsingTwoFactorAuthentication(Piwik::getCurrentUserLogin());
+ if ($isUsing2FA && !Request::isRootRequestApiRequest()) {
+ $sessionFingerprint = new SessionFingerprint();
+ if (!$sessionFingerprint->hasVerifiedTwoFactor()) {
+ $result = $this->removeTokenFromOutput($result);
+ }
+ } elseif (!$isUsing2FA && $twoFa->isUserRequiredToHaveTwoFactorEnabled()) {
+ $result = $this->removeTokenFromOutput($result);
+ }
+ }
+
+ private function removeTokenFromOutput($output)
+ {
+ $token = Piwik::getCurrentUserTokenAuth();
+ // make sure to not leak the token... otherwise someone could log in using someone's credentials...
+ // and then maybe in the auth screen look into the DOM to find the token... and then bypass the
+ // auth code using API
+ return str_replace($token, md5('') . '2fareplaced', $output);
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/TwoFactorAuthentication.php b/plugins/TwoFactorAuth/TwoFactorAuthentication.php
new file mode 100644
index 0000000000..d7ee1e019f
--- /dev/null
+++ b/plugins/TwoFactorAuth/TwoFactorAuthentication.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Piwik\Piwik;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretRandomGenerator;
+use Piwik\Plugins\UsersManager\Model;
+use Exception;
+
+require_once PIWIK_DOCUMENT_ROOT . '/libs/Authenticator/TwoFactorAuthenticator.php';
+
+class TwoFactorAuthentication
+{
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $recoveryCodeDao;
+
+ /**
+ * @var TwoFaSecretRandomGenerator
+ */
+ private $secretGenerator;
+
+ public function __construct(SystemSettings $systemSettings, RecoveryCodeDao $recoveryCodeDao, TwoFaSecretRandomGenerator $twoFaSecretRandomGenerator)
+ {
+ $this->settings = $systemSettings;
+ $this->recoveryCodeDao = $recoveryCodeDao;
+ $this->secretGenerator = $twoFaSecretRandomGenerator;
+ }
+
+ private function getUserModel()
+ {
+ return new Model();
+ }
+
+ public function generateSecret()
+ {
+ return $this->secretGenerator->generateSecret();
+ }
+
+ public function disable2FAforUser($login)
+ {
+ $this->saveSecret($login, '');
+ $this->recoveryCodeDao->deleteAllRecoveryCodesForLogin($login);
+
+ Piwik::postEvent('TwoFactorAuth.disabled', array($login));
+ }
+
+ private function isAnonymous($login)
+ {
+ return strtolower($login) === 'anonymous';
+ }
+
+ public function saveSecret($login, $secret)
+ {
+ if ($this->isAnonymous($login)) {
+ throw new Exception('Anonymous cannot use two-factor authentication');
+ }
+
+ if (!empty($secret) && !$this->recoveryCodeDao->getAllRecoveryCodesForLogin($login)) {
+ // ensures the user has seen and ideally backuped the recovery codes... we don't create them here on demand
+ throw new Exception('Cannot enable two-factor authentication, no recovery codes have been created');
+ }
+
+ $model = $this->getUserModel();
+ $model->updateUserFields($login, array('twofactor_secret' => $secret));
+ }
+
+ public function isUserRequiredToHaveTwoFactorEnabled()
+ {
+ return $this->settings->twoFactorAuthRequired->getValue();
+ }
+
+ public function isUserUsingTwoFactorAuthentication($login)
+ {
+ if ($this->isAnonymous($login)) {
+ return false; // not possible to use auth code with anonymous
+ }
+
+ $user = $this->getUser($login);
+ return !empty($user['twofactor_secret']);
+ }
+
+ private function getUser($login)
+ {
+ $model = $this->getUserModel();
+ return $model->getUser($login);
+ }
+
+ public function validateAuthCode($login, $authCode)
+ {
+ if (!$this->isUserUsingTwoFactorAuthentication($login)) {
+ return false;
+ }
+
+ $user = $this->getUser($login);
+
+ if (!empty($user['twofactor_secret'])
+ && $this->validateAuthCodeDuringSetup($authCode, $user['twofactor_secret'])) {
+ return true;
+ }
+
+ if ($this->recoveryCodeDao->useRecoveryCode($user['login'], $authCode)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function validateAuthCodeDuringSetup($authCode, $secret)
+ {
+ $twoFactorAuth = $this->makeAuthenticator();
+
+ if (!empty($secret) && $twoFactorAuth->verifyCode($secret, $authCode, 2)) {
+ return true;
+ }
+ return false;
+ }
+
+ private function makeAuthenticator()
+ {
+ return new \TwoFactorAuthenticator();
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/Validator.php b/plugins/TwoFactorAuth/Validator.php
new file mode 100644
index 0000000000..328956e44a
--- /dev/null
+++ b/plugins/TwoFactorAuth/Validator.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth;
+
+use Piwik\Piwik;
+use Piwik\Session\SessionFingerprint;
+use Exception;
+use Piwik\SettingsPiwik;
+
+class Validator
+{
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ public function __construct(TwoFactorAuthentication $twoFactorAuthentication)
+ {
+ $this->twoFa = $twoFactorAuthentication;
+ }
+
+ public function canUseTwoFa()
+ {
+ if (!SettingsPiwik::isPiwikInstalled()) {
+ return false;
+ }
+
+ return !Piwik::isUserIsAnonymous();
+ }
+
+ public function checkCanUseTwoFa()
+ {
+ Piwik::checkUserIsNotAnonymous();
+
+ if (!SettingsPiwik::isPiwikInstalled()) {
+ throw new \Exception('Matomo is not set up yet');
+ }
+ }
+
+ public function check2FaIsRequired()
+ {
+ if (!$this->twoFa->isUserRequiredToHaveTwoFactorEnabled()) {
+ throw new Exception('not available');
+ }
+ }
+
+ public function check2FaEnabled()
+ {
+ if (!$this->twoFa->isUserUsingTwoFactorAuthentication(Piwik::getCurrentUserLogin())) {
+ throw new Exception('not available');
+ }
+ }
+
+ public function check2FaNotEnabled()
+ {
+ if ($this->twoFa->isUserUsingTwoFactorAuthentication(Piwik::getCurrentUserLogin())) {
+ throw new Exception('not available');
+ }
+ }
+
+ public function checkVerified2FA()
+ {
+ $sessionFingerprint = $this->getSessionFingerPrint();
+ if (!$sessionFingerprint->hasVerifiedTwoFactor()) {
+ throw new Exception('not available');
+ }
+ }
+
+ public function checkNotVerified2FAYet()
+ {
+ $sessionFingerprint = $this->getSessionFingerPrint();
+ if ($sessionFingerprint->hasVerifiedTwoFactor()) {
+ throw new Exception('not available');
+ }
+ }
+
+ private function getSessionFingerPrint()
+ {
+ return new SessionFingerprint();
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/angularjs/setuptwofactor/setuptwofactor.controller.js b/plugins/TwoFactorAuth/angularjs/setuptwofactor/setuptwofactor.controller.js
new file mode 100644
index 0000000000..66514e17ad
--- /dev/null
+++ b/plugins/TwoFactorAuth/angularjs/setuptwofactor/setuptwofactor.controller.js
@@ -0,0 +1,54 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+(function () {
+ angular.module('piwikApp').controller('SetupTwoFactorAuthController', SetupTwoFactorAuthController);
+
+ SetupTwoFactorAuthController.$inject = ['$timeout', 'piwik', '$scope'];
+
+ function SetupTwoFactorAuthController($timeout, piwik, $scope) {
+
+ var self = this;
+ this.step = 1;
+ this.hasDownloadedRecoveryCode = false;
+
+ this.scrollToEnd = function () {
+ $timeout(function () {
+ var id = '';
+ if (self.step === 2) {
+ id = '#twoFactorStep2';
+ } else if (self.step === 3) {
+ id = '#twoFactorStep3';
+ }
+ if (id) {
+ piwik.helper.lazyScrollTo(id, 50, true);
+ }
+ }, 50);
+ }
+
+ this.nextStep = function ()
+ {
+ this.step++;
+ this.scrollToEnd();
+ }
+
+ $timeout(function () {
+ angular.element('.backupRecoveryCode').click(function () {
+ self.hasDownloadedRecoveryCode = true;
+ $timeout(function () {
+ $scope.$apply();
+ }, 1);
+ });
+
+ if (angular.element('.setupTwoFactorAuthentication .message_container').length) {
+ // user entered something wrong
+ self.step = 3;
+ self.scrollToEnd();
+ }
+ });
+ }
+})(); \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/config/test.php b/plugins/TwoFactorAuth/config/test.php
new file mode 100644
index 0000000000..7023790fe4
--- /dev/null
+++ b/plugins/TwoFactorAuth/config/test.php
@@ -0,0 +1,69 @@
+<?php
+
+return array(
+ 'Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretRandomGenerator' => DI\object('Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretStaticGenerator'),
+ 'Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeRandomGenerator' => DI\object('Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeStaticGenerator'),
+ 'Piwik\Plugins\TwoFactorAuth\TwoFactorAuthentication' => DI\decorate(function ($previous) {
+ /** @var Piwik\Plugins\TwoFactorAuth\TwoFactorAuthentication $previous */
+
+ if (!\Piwik\SettingsPiwik::isPiwikInstalled()) {
+ return $previous;
+ }
+
+ $fakeCorrectAuthCode = \Piwik\Container\StaticContainer::get('test.vars.fakeCorrectAuthCode');
+ if (!empty($fakeCorrectAuthCode) && !\Piwik\Common::isPhpCliMode()) {
+ $staticSecret = new \Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretStaticGenerator();
+ $secret = $staticSecret->generateSecret();
+
+ require_once PIWIK_DOCUMENT_ROOT . '/libs/Authenticator/TwoFactorAuthenticator.php';
+ $authenticator = new \TwoFactorAuthenticator();
+ $_GET['authcode'] = $authenticator->getCode($secret);
+ $_GET['authCode'] = $_GET['authcode'];
+ $_POST['authCode'] = $_GET['authcode'];
+ $_POST['authcode'] = $_GET['authcode'];
+ $_REQUEST['authcode'] = $_GET['authcode'];
+ $_REQUEST['authCode'] = $_GET['authcode'];
+ }
+
+ return $previous;
+ }),
+ 'Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao' => DI\decorate(function ($previous) {
+ /** @var Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao $previous */
+
+ if (!\Piwik\SettingsPiwik::isPiwikInstalled()) {
+ return $previous;
+ }
+
+ $restoreCodes = \Piwik\Container\StaticContainer::get('test.vars.restoreRecoveryCodes');
+ if (!empty($restoreCodes)) {
+ // we ensure this recovery code always works for those users
+ foreach (array('with2FA', 'with2FADisable') as $user) {
+ $previous->useRecoveryCode($user, '123456'); // we are using it first to make sure there is no duplicate
+ $previous->insertRecoveryCode($user, '123456');
+ }
+ }
+
+ return $previous;
+ }),
+ 'Piwik\Plugins\TwoFactorAuth\SystemSettings' => DI\decorate(function ($previous) {
+ /** @var Piwik\Plugins\TwoFactorAuth\SystemSettings $previous */
+ if (!\Piwik\SettingsPiwik::isPiwikInstalled()) {
+ return $previous;
+ }
+
+ Piwik\Access::doAsSuperUser(function () use ($previous) {
+ $requireTwoFa = \Piwik\Container\StaticContainer::get('test.vars.requireTwoFa');
+ if (!empty($requireTwoFa)) {
+ $previous->twoFactorAuthRequired->setValue(1);
+ } else {
+ try {
+ $previous->twoFactorAuthRequired->setValue(0);
+ } catch (Exception $e) {
+ // may fail when matomo is trying to update or so
+ }
+ }
+ });
+
+ return $previous;
+ })
+);
diff --git a/plugins/TwoFactorAuth/javascripts/twofactorauth.js b/plugins/TwoFactorAuth/javascripts/twofactorauth.js
new file mode 100644
index 0000000000..d6adcc7e7f
--- /dev/null
+++ b/plugins/TwoFactorAuth/javascripts/twofactorauth.js
@@ -0,0 +1,12 @@
+(function ($) {
+ var twoFactorAuth = {};
+ twoFactorAuth.confirmDisable2FA = function (nonce) {
+ piwikHelper.modalConfirm('#confirmDisable2FA',
+ {yes: function () {
+ broadcast.propagateNewPage('module=TwoFactorAuth&action=disableTwoFactorAuth&disableNonce='+ encodeURIComponent(nonce));
+ }
+ })
+ };
+
+ window.twoFactorAuth = twoFactorAuth;
+})(jQuery); \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/lang/en.json b/plugins/TwoFactorAuth/lang/en.json
new file mode 100644
index 0000000000..9e42a42d79
--- /dev/null
+++ b/plugins/TwoFactorAuth/lang/en.json
@@ -0,0 +1,52 @@
+{
+ "TwoFactorAuth": {
+ "TwoFactorAuthentication": "Two-factor authentication",
+ "TwoFAShort": "2FA",
+ "TwoFactorAuthenticationIntro": "%1$sTwo-factor authentication%2$s increase your account security by adding an additional layer of verification when you log in. Each time you log in you will not only be asked to provide your login and password, but also an additional authentication token which changes periodically and is generated for example on your mobile device. This means that even when someone knows your username and password, they still won't be able to log in unless they have access to your mobile device for example.",
+ "TwoFactorAuthenticationIsEnabled": "Two-factor authentication is currently enabled.",
+ "TwoFactorAuthenticationIsDisabled": "Two-factor authentication is currently disabled.",
+ "TwoFactorAuthenticationRequired": "Two-factor authentication is required to be enabled for everyone, you cannot disable it.",
+ "ConfigureDifferentDevice": "Configure a different device",
+ "SetUpTwoFactorAuthentication": "Set up two-factor authentication (2FA)",
+ "RequiredToSetUpTwoFactorAuthentication": "You are required to set up two-factor authentication before you can log in",
+ "AuthenticationCode": "Authentication code",
+ "ActivityDisabledTwoFactorAuthForUser": "disabled two-factor authentication for user %s",
+ "ActivityDisabledTwoFactorAuth": "disabled two-factor authentication",
+ "ActivityEnabledTwoFactorAuth": "has set up two-factor authentication",
+ "Verify": "Verify",
+ "StepX": "Step %s",
+ "MissingAuthCodeAPI": "Please specify two-factor authentication code.",
+ "InvalidAuthCode": "The two-factor authentication code is not correct.",
+ "RequiredAuthCodeNotConfiguredAPI": "You are required to set up two-factor authentication. Please log in to your account.",
+ "VerifyIdentifyExplanation": "Open the two-factor authentication app on your device to view your authentication code and verify your identity.",
+ "DontHaveYourMobileDevice": "Don’t have your mobile device?",
+ "EnterRecoveryCodeInstead": "Enter one of your recovery codes",
+ "AskSuperUserResetAuthenticationCode": "Ask super user to reset your authentication code",
+ "SetupIntroFollowSteps": "Please follow these steps to set up two-factor authentication:",
+ "SetupFinishedTitle": "Congratulations! Your account is now more secure.",
+ "SetupFinishedSubtitle": "You have successfully set up two-factor authentication. Next time you log in, you will need to also enter the authentication code. Make sure you have your mobile device or your backup codes with you.",
+ "WarningChangingConfiguredDevice": "You are about to change the configured two-factor authentication device. This will invalidate any previously configured device.",
+ "ShowRecoveryCodes": "Show recovery codes",
+ "ConfirmSetup": "Confirm setup",
+ "NotPossibleToLogIn": "Cannot log in to Matomo Analytics",
+ "LostAuthenticationDevice": "Hi,%1$sI have two-factor authentication enabled and lost my authentication device. Could you please reset two-factor authentication for my username %5$s? You can find the instructions for this here: %6$s.%2$sThe Matomo URL is %3$s.%4$sThanks",
+ "WrongAuthCodeTryAgain": "Wrong authentication code entered. Please try again.",
+ "DisableTwoFA": "Disable two-factor authentication",
+ "EnableTwoFA": "Enable two-factor authentication",
+ "ConfirmDisableTwoFA": "Are you sure you want to disable two-factor authentication for your account? Having two-factor authentication enabled increases your account security.",
+ "VerifyAuthCodeIntro": "Please enter the six-digit code from your authenticator app below to confirm you have successfully set up on your device.",
+ "VerifyAuthCodeHelp": "Please enter the six-digit code that has been generated on your mobile device after scanning the bar code.",
+ "Your2FaAuthSecret": "Your two-factor authentication secret",
+ "SetupAuthenticatorOnDevice": "Setup authenticator on your device",
+ "SetupAuthenticatorOnDeviceStep1": "Install an authenticator app, for example:",
+ "SetupAuthenticatorOnDeviceStep2": "Next, open the app and scan the below bar code with the two-factor authentication app on your phone. If you can’t scan the barcode, %1$senter this code%2$s instead.",
+ "SetupBackupRecoveryCodes": "Please backup your recovery codes using one of the above methods before continuing the two-factor authentication setup.",
+ "RecoveryCodes": "Recovery codes",
+ "RecoveryCodesExplanation": "You can use recovery codes to access your account when you cannot receive two-factor authentication codes, for example when you don't have your mobile device with you.",
+ "RecoveryCodesSecurity": "Please treat your recovery codes with the same level of security as you would your password!",
+ "RecoveryCodesAllUsed": "All recovery codes have been used, it is highly recommended you regenerate your recovery codes.",
+ "RecoveryCodesRegenerated": "Recovery codes have been regenerated. Make sure to download or print the newly generated codes.",
+ "GenerateNewRecoveryCodes": "Generate new recovery codes",
+ "GenerateNewRecoveryCodesInfo": "When you generate new recovery codes, your old codes won’t work anymore. Make sure to download or print your new codes."
+ }
+} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/stylesheets/twofactorauth.less b/plugins/TwoFactorAuth/stylesheets/twofactorauth.less
new file mode 100644
index 0000000000..8e4465538b
--- /dev/null
+++ b/plugins/TwoFactorAuth/stylesheets/twofactorauth.less
@@ -0,0 +1,16 @@
+.twoFactorRecoveryCodes {
+ li {
+ font-size: 16px;
+ list-style-type: disc;
+ margin-left: 20px;
+ }
+}
+
+.userSettings2FA .twoFaStatusEnabled,
+.twoFactorSetupFinished .successMessage {
+ color:#43a047;
+}
+
+.loginSection .backupRecoveryCodesAlert {
+ margin-top: 16px;
+} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/templates/_setupTwoFactorAuth.twig b/plugins/TwoFactorAuth/templates/_setupTwoFactorAuth.twig
new file mode 100644
index 0000000000..d0e279d23e
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/_setupTwoFactorAuth.twig
@@ -0,0 +1,73 @@
+<div ng-controller="SetupTwoFactorAuthController as setup2fa" class="setupTwoFactorAuthentication">
+ {% if isAlreadyUsing2fa %}
+ <div class="alert alert-warning">{{ 'TwoFactorAuth_WarningChangingConfiguredDevice'|translate }}</div>
+ {% endif %}
+
+ <p>
+ {{ 'TwoFactorAuth_SetupIntroFollowSteps'|translate }}
+ </p>
+
+ <h2>
+ {{ 'TwoFactorAuth_StepX'|translate(1) }} - {{ 'TwoFactorAuth_RecoveryCodes'|translate }}
+ </h2>
+ {% include '@TwoFactorAuth/_showRecoveryCodes.twig' %}
+
+ <div class="alert alert-info backupRecoveryCodesAlert" ng-show="setup2fa.step == 1">{{ 'TwoFactorAuth_SetupBackupRecoveryCodes'|translate }}</div>
+
+ <p><button ng-click="setup2fa.nextStep()" ng-show="setup2fa.step == 1" ng-disabled="!setup2fa.hasDownloadedRecoveryCode" class="btn goToStep2">{{ 'General_Next'|translate }}</button></p>
+
+ <a name="twoFactorStep2" id="twoFactorStep2" style="opacity: 0"></a>
+ <div ng-show="setup2fa.step >= 2">
+ <h2>
+ {{ 'TwoFactorAuth_StepX'|translate(2) }} - {{ 'TwoFactorAuth_SetupAuthenticatorOnDevice'|translate }}
+ </h2>
+ <p>{{ 'TwoFactorAuth_SetupAuthenticatorOnDeviceStep1'|translate }} <a rel="noreferrer noopener" href="https://github.com/andOTP/andOTP#downloads">andOTP</a>, <a rel="noreferrer noopener" href="https://authy.com/guides/github/">Authy</a>, <a rel="noreferrer noopener" href="https://support.1password.com/one-time-passwords/">1Password</a>, <a rel="noreferrer noopener" href="https://helpdesk.lastpass.com/multifactor-authentication-options/lastpass-authenticator/">LastPass Authenticator</a>, {{ 'General_Or'|translate }} <a rel="noreferrer noopener" href="https://support.google.com/accounts/answer/1066447">Google Authenticator</a>.
+ </p>
+ <p>{{ 'TwoFactorAuth_SetupAuthenticatorOnDeviceStep2'|translate('<a href="javascript:void(0)" onclick="piwikHelper.modalConfirm(\'#setupTwoFAsecretConfirm\')">', '</a>')|raw }}<br/>
+ <img src="{{ authImage|raw }}">
+ <br />
+ <button ng-show="setup2fa.step == 2" ng-click="setup2fa.nextStep()" class="btn goToStep3">{{ 'General_Next'|translate }}</button>
+ </p>
+ </div>
+
+ <a name="twoFactorStep3" id="twoFactorStep3" style="opacity: 0"></a>
+ <div ng-show="setup2fa.step >= 3">
+ <h2>{{ 'TwoFactorAuth_StepX'|translate(3) }} - {{ 'TwoFactorAuth_ConfirmSetup'|translate }}</h2>
+ <p>{{ 'TwoFactorAuth_VerifyAuthCodeIntro'|translate }}</p>
+
+ {% if AccessErrorString %}
+ <div class="message_container">
+ <div piwik-notification
+ noclear="true"
+ context="error">
+ <strong>{{ 'General_Error'|translate }}</strong>: {{ AccessErrorString|raw }}<br/>
+ </div>
+ </div>
+ {% endif %}
+
+ <form method="post"
+ action="{{ linkTo({'module': 'TwoFactorAuth', 'action': submitAction}) }}"
+ class="setupConfirmAuthCodeForm"
+ autocorrect="off" autocapitalize="none"
+ autocomplete="off"
+ >
+ <div piwik-field uicontrol="text" name="authCode" maxlength="6"
+ title="{{ 'TwoFactorAuth_AuthenticationCode'|translate|e('html_attr') }}"
+ ng-model="setup2fa.authCode"
+ inline-help="{{ 'TwoFactorAuth_VerifyAuthCodeHelp'|translate|e('html_attr') }}"
+ placeholder="123456">
+ </div>
+
+ <input type="hidden" name="authCodeNonce" value="{{ authCodeNonce|e('html_attr') }}">
+ <input type="submit" ng-disabled="setup2fa.authCode.length != 6"
+ class="btn confirmAuthCode" value="{{ 'General_Confirm'|translate }}">
+ </form>
+ </div>
+
+</div>
+
+<div id="setupTwoFAsecretConfirm" class="ui-confirm">
+ <h2>{{ 'TwoFactorAuth_Your2FaAuthSecret'|translate }}</h2>
+ <p style="text-align: center;"><code piwik-select-on-focus style="font-size: 30px;">{{ newSecret }}</code></p>
+ <input role="ok" type="button" value="{{ 'General_Ok'|translate }}"/>
+</div> \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/templates/_showRecoveryCodes.twig b/plugins/TwoFactorAuth/templates/_showRecoveryCodes.twig
new file mode 100644
index 0000000000..0d819fe804
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/_showRecoveryCodes.twig
@@ -0,0 +1,38 @@
+
+<script type="text/javascript">
+ function copyRecoveryCodesToClipboard()
+ {
+ var textarea = document.createElement('textarea');
+ textarea.value = {{ codes|join("\n")|json_encode|raw }};
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'absolute';
+ textarea.style.left = '-9999px';
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textarea);
+ }
+ function downloadRecoveryCodes()
+ {
+ piwikHelper.sendContentAsDownload('analytics_recovery_codes.txt', {{ codes|join("\n")|json_encode|raw }});
+ }
+</script>
+
+ <p>{{ 'TwoFactorAuth_RecoveryCodesExplanation'|translate }}<br /><br /></p>
+ <div class="alert alert-warning">{{ 'TwoFactorAuth_RecoveryCodesSecurity'|translate }}</div>
+
+ {% if codes|length > 0 %}
+ <ul piwik-select-on-focus class="twoFactorRecoveryCodes">{% for code in codes %}
+ <li>{{ code|upper|split('', 4)|join('-') }}</li>
+ {% endfor %}
+ </ul>
+ {% else %}
+ <div class="alert alert-danger">{{ 'TwoFactorAuth_RecoveryCodesAllUsed'|translate }}</div>
+ {% endif %}
+
+ <p>
+ <br />
+ <input type="button" class="btn backupRecoveryCode" onclick="downloadRecoveryCodes()" value="{{ 'General_Download'|translate }}">
+ <input type="button" class="btn backupRecoveryCode" onclick="window.print()" value="{{ 'General_Print'|translate }}">
+ <input type="button" class="btn backupRecoveryCode" onclick="copyRecoveryCodesToClipboard()" value="{{ 'General_Copy'|translate }}">
+ </p>
diff --git a/plugins/TwoFactorAuth/templates/loginTwoFactorAuth.twig b/plugins/TwoFactorAuth/templates/loginTwoFactorAuth.twig
new file mode 100644
index 0000000000..7169f5dbf1
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/loginTwoFactorAuth.twig
@@ -0,0 +1,56 @@
+{% extends '@Login/loginLayout.twig' %}
+
+{% set title %}{{ 'TwoFactorAuth_TwoFactorAuthentication'|translate }}{% endset %}
+
+{% block loginContent %}
+ {% embed 'contentBlock.twig' with {'title': ('TwoFactorAuth_TwoFactorAuthentication'|translate)} %}
+ {% block content %}
+
+ <div class="message_container">
+
+ {{ include('@Login/_formErrors.twig', {formErrors: form_data.errors } ) }}
+
+ {% if AccessErrorString %}
+ <div piwik-notification
+ noclear="true"
+ context="error">
+ <strong>{{ 'General_Error'|translate }}</strong>: {{ AccessErrorString|raw }}<br/>
+ </div>
+ {% endif %}
+ </div>
+
+ <form {{ form_data.attributes|raw }} ng-non-bindable class="loginTwoFaForm">
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="hidden" name="form_nonce" id="login_form_nonce" value="{{ nonce }}"/>
+ <input type="text" name="form_authcode" placeholder="" id="login_form_authcode" class="input" value="" size="20"
+ autocorrect="off" autocapitalize="none" autocomplete="off"
+ tabindex="10" autofocus="autofocus"/>
+ <label for="login_form_authcode"><i class="icon-user icon"></i> {{ 'TwoFactorAuth_AuthenticationCode'|translate }}</label>
+ </div>
+ </div>
+
+ <div class="row actions">
+ <div class="col s12">
+ <input class="submit btn" id='login_form_submit' type="submit" value="{{ 'TwoFactorAuth_Verify'|translate|e('html_attr') }}"
+ tabindex="100"/>
+ </div>
+ </div>
+
+ </form>
+
+ <p>{{ 'TwoFactorAuth_VerifyIdentifyExplanation'|translate }} {{ 'General_LearnMore'|translate('<a href="https://matomo.org/faq/general/faq_27245" rel="noreferrer noopener">', '</a>')|raw }}
+
+ <br /><br />
+ <strong>{{ 'TwoFactorAuth_DontHaveYourMobileDevice'|translate }}</strong>
+ <br />
+ <a href="https://matomo.org/faq/how-to/faq_27248" rel="noreferrer noopener">{{ 'TwoFactorAuth_EnterRecoveryCodeInstead'|translate }}</a>
+ <br />
+ <a href="mailto:{{ superUserEmails|e('url') }}?subject={{ 'TwoFactorAuth_NotPossibleToLogIn'|translate|e('url') }}&body={{ 'TwoFactorAuth_LostAuthenticationDevice'|translate("\n\n", "\n\n", piwikUrl|default(''), "\n\n", userLogin, "https://matomo.org/faq/how-to/faq_27248")|e('url') }}" rel="noreferrer noopener">{{ 'TwoFactorAuth_AskSuperUserResetAuthenticationCode'|translate }}</a>
+ <br />
+ <a href="{{ linkTo({'module': loginModule, 'action': 'logout'}) }}" rel="noreferrer noopener">{{ 'General_Logout'|translate }}</a>
+ </p>
+
+ {% endblock %}
+ {% endembed %}
+{% endblock %} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/templates/setupFinished.twig b/plugins/TwoFactorAuth/templates/setupFinished.twig
new file mode 100644
index 0000000000..456e8f5189
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/setupFinished.twig
@@ -0,0 +1,11 @@
+{% extends 'admin.twig' %}
+{% block content %}
+ <div piwik-content-block class="twoFactorSetupFinished">
+ <h2 class="successMessage">
+ {{ 'TwoFactorAuth_SetupFinishedTitle'|translate }}
+ </h2>
+ <h3>{{ 'TwoFactorAuth_SetupFinishedSubtitle'|translate }}</h3>
+ <p><br />
+ <a class="btn" href="{{ linkTo({'module': 'UsersManager', 'action': 'userSettings'}) }}">{{ 'General_Continue'|translate }}</a></p>
+ </div>
+{% endblock %}
diff --git a/plugins/TwoFactorAuth/templates/setupTwoFactorAuth.twig b/plugins/TwoFactorAuth/templates/setupTwoFactorAuth.twig
new file mode 100644
index 0000000000..a31c40c9dd
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/setupTwoFactorAuth.twig
@@ -0,0 +1,8 @@
+{% extends 'admin.twig' %}
+
+{% block content %}
+<div piwik-content-block
+ content-title="{{ 'TwoFactorAuth_SetUpTwoFactorAuthentication'|translate }}">
+ {% include '@TwoFactorAuth/_setupTwoFactorAuth.twig' %}
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/templates/setupTwoFactorAuthStandalone.twig b/plugins/TwoFactorAuth/templates/setupTwoFactorAuthStandalone.twig
new file mode 100644
index 0000000000..187f7ef9d0
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/setupTwoFactorAuthStandalone.twig
@@ -0,0 +1,9 @@
+{% extends '@Login/loginLayout.twig' %}
+
+{% block loginContent %}
+<div piwik-content-block
+ content-title="{{ 'TwoFactorAuth_RequiredToSetUpTwoFactorAuthentication'|translate }}">
+ {% include '@TwoFactorAuth/_setupTwoFactorAuth.twig' %}
+</div>
+
+{% endblock %} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/templates/showRecoveryCodes.twig b/plugins/TwoFactorAuth/templates/showRecoveryCodes.twig
new file mode 100644
index 0000000000..907048126a
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/showRecoveryCodes.twig
@@ -0,0 +1,42 @@
+{% extends 'admin.twig' %}
+
+{% block content %}
+
+ <script type="text/javascript">
+ function copyRecoveryCodesToClipboard()
+ {
+ var textarea = document.createElement('textarea');
+ textarea.value = '{{ codes|join("\n")|e('js') }}';
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'absolute';
+ textarea.style.left = '-9999px';
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textarea);
+ }
+ </script>
+
+ <div piwik-content-block
+ content-title="{{ 'TwoFactorAuth_TwoFactorAuthentication'|translate }} - {{ 'TwoFactorAuth_RecoveryCodes'|translate }}">
+
+ {% include '@TwoFactorAuth/_showRecoveryCodes.twig' %}
+
+ <h2>{{ 'TwoFactorAuth_GenerateNewRecoveryCodes'|translate }}</h2>
+ <p>{{ 'TwoFactorAuth_GenerateNewRecoveryCodesInfo'|translate }}<br /><br /></p>
+
+ {% if regenerateSuccess %}
+ <div class="alert alert-success">{{ 'TwoFactorAuth_RecoveryCodesRegenerated'|translate }}</div>
+ {% endif %}
+
+ {% if regenerateError %}
+ <div class="alert alert-danger">{{ 'General_ExceptionNonceMismatch'|translate }}</div>
+ {% endif %}
+
+ <form method="post" action="{{ linkTo({'method': 'TwoFactorAuth', 'action': 'showRecoveryCodes'}) }}" ng-non-bindable>
+ <input type="hidden" name="regenerateNonce" value="{{ regenerateNonce }}">
+ <input type="submit" class="btn" value="{{ 'TwoFactorAuth_GenerateNewRecoveryCodes'|translate }}">
+ </form>
+
+ <div>
+{% endblock %} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/templates/userSettings.twig b/plugins/TwoFactorAuth/templates/userSettings.twig
new file mode 100644
index 0000000000..4ee34fe513
--- /dev/null
+++ b/plugins/TwoFactorAuth/templates/userSettings.twig
@@ -0,0 +1,41 @@
+<div piwik-content-block
+ content-title="{{ 'TwoFactorAuth_TwoFactorAuthentication'|translate }} ({{ 'TwoFactorAuth_TwoFAShort'|translate }})" class="userSettings2FA">
+
+ <p>
+ {{ 'TwoFactorAuth_TwoFactorAuthenticationIntro'|translate('<a href="https://matomo.org/faq/general/faq_27245" rel="noreferrer noopener">', '</a>')|raw }}
+ </p>
+
+ {% if isEnabled %}
+ <p><strong class="twoFaStatusEnabled">{{ 'TwoFactorAuth_TwoFactorAuthenticationIsEnabled'|translate }}</strong></p>
+
+ <p>
+ {% if isForced %}
+ {{ 'TwoFactorAuth_TwoFactorAuthenticationRequired'|translate }}
+ <br />
+ <br />
+ <a class="btn btn-link enable2FaLink" href="{{ linkTo({'module': 'TwoFactorAuth', 'action': 'setupTwoFactorAuth'}) }}">{{ 'TwoFactorAuth_ConfigureDifferentDevice'|translate }}</a>
+ {% else %}
+ <a class="btn btn-link enable2FaLink" href="{{ linkTo({'module': 'TwoFactorAuth', 'action': 'setupTwoFactorAuth'}) }}">{{ 'TwoFactorAuth_ConfigureDifferentDevice'|translate }}</a>
+ <a href="{{ linkTo({'module': 'TwoFactorAuth', 'action': 'disableTwoFactorAuth', 'disableNonce': disableNonce}) }}" style="display:none;" id="disable2fa">disable2fa</a>
+ <input type="button"
+ class="btn btn-link disable2FaLink"
+ onclick="twoFactorAuth.confirmDisable2FA('{{ disableNonce|e('url') }}');"
+ value="{{ 'TwoFactorAuth_DisableTwoFA'|translate }}">
+ {% endif %}
+ <a class="btn btn-link showRecoveryCodesLink" href="{{ linkTo({'module': 'TwoFactorAuth', 'action': 'showRecoveryCodes'}) }}">{{ 'TwoFactorAuth_ShowRecoveryCodes'|translate }}</a>
+ </p>
+ {% else %}
+ <p><strong>{{ 'TwoFactorAuth_TwoFactorAuthenticationIsDisabled'|translate }}</strong>
+ <br />
+ <br />
+ <a class="btn btn-link enable2FaLink" href="{{ linkTo({'module': 'TwoFactorAuth', 'action': 'setupTwoFactorAuth'}) }}">{{ 'TwoFactorAuth_EnableTwoFA'|translate }}</a>
+ </p>
+ {% endif %}
+
+ <div id="confirmDisable2FA" class="ui-confirm">
+ <h2>{{ 'TwoFactorAuth_ConfirmDisableTwoFA'|translate }}</h2>
+ <input role="yes" type="button" value="{{ 'General_Yes'|translate }}"/>
+ <input role="no" type="button" value="{{ 'General_No'|translate }}"/>
+ </div>
+
+</div>
diff --git a/plugins/TwoFactorAuth/tests/Fixtures/TwoFactorFixture.php b/plugins/TwoFactorAuth/tests/Fixtures/TwoFactorFixture.php
new file mode 100644
index 0000000000..37e2777214
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Fixtures/TwoFactorFixture.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\tests\Fixtures;
+
+use Piwik\Container\StaticContainer;
+use Piwik\Date;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Plugins\TwoFactorAuth\TwoFactorAuthentication;
+use Piwik\Plugins\UsersManager\Model;
+use Piwik\Tests\Framework\Fixture;
+use Piwik\Plugins\UsersManager\API as UsersAPI;
+
+class TwoFactorFixture extends Fixture
+{
+ public $dateTime = '2013-01-23 01:23:45';
+ public $idSite = 1;
+ public $idSite2 = 2;
+
+ private $userWith2Fa = 'with2FA';
+ private $userWith2FaDisable = 'with2FADisable'; // we use this user to disable two factor
+ private $userWithout2Fa = 'without2FA';
+ private $userNo2Fa = 'no2FA';
+ private $userPassword = '123abcDk3_l3';
+
+ const USER_2FA_SECRET = '1111111111111111';
+
+
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $dao;
+
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ public function setUp()
+ {
+ $this->dao = StaticContainer::get(RecoveryCodeDao::class);
+ $this->twoFa = StaticContainer::get(TwoFactorAuthentication::class);
+
+ $this->setUpWebsite();
+ $this->setUpUsers();
+ $this->trackFirstVisit();
+ }
+
+ public function tearDown()
+ {
+ // empty
+ }
+
+ public function setUpWebsite()
+ {
+ for ($i = 1; $i <= 2; $i++) {
+ if (!self::siteCreated($i)) {
+ $idSite = self::createWebsite($this->dateTime);
+ // we set type "mobileapp" to avoid the creation of a default container
+ $this->assertSame($i, $idSite);
+ }
+ }
+ }
+
+ public function setUpUsers()
+ {
+ foreach ([$this->userWith2Fa, $this->userWithout2Fa, $this->userWith2FaDisable, $this->userNo2Fa] as $user) {
+ \Piwik\Plugins\UsersManager\API::getInstance()->addUser($user, $this->userPassword, $user . '@matomo.org');
+ // we cannot set superuser as logme won't work for super user
+ UsersAPI::getInstance()->setUserAccess($user, 'admin', [$this->idSite, $this->idSite2]);
+
+ if ($this->userWith2Fa === $user) {
+ $userModel = new Model();
+ $userModel->updateUserTokenAuth($user, 'c4ca4238a0b923820dcc509a6f75849b');
+ }
+ }
+
+ foreach ([$this->userWith2Fa, $this->userWith2FaDisable] as $user) {
+ $this->dao->insertRecoveryCode($user, '123456');
+ $this->dao->insertRecoveryCode($user, '234567');
+ $this->dao->insertRecoveryCode($user, '345678');
+ $this->dao->insertRecoveryCode($user, '456789');
+ $this->dao->insertRecoveryCode($user, '567890');
+ $this->dao->insertRecoveryCode($user, '678901');
+ $this->twoFa->saveSecret($user, self::USER_2FA_SECRET);
+ }
+ }
+
+ protected function trackFirstVisit()
+ {
+ $t = self::getTracker($this->idSite, $this->dateTime, $defaultInit = true);
+
+ $t->setForceVisitDateTime(Date::factory($this->dateTime)->addHour(0.1)->getDatetime());
+ $t->setUrl('http://example.com/');
+ self::checkResponse($t->doTrackPageView('Viewing homepage'));
+ }
+
+} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/tests/Fixtures/TwoFactorUsersManagerFixture.php b/plugins/TwoFactorAuth/tests/Fixtures/TwoFactorUsersManagerFixture.php
new file mode 100644
index 0000000000..a0476fe599
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Fixtures/TwoFactorUsersManagerFixture.php
@@ -0,0 +1,14 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\TwoFactorAuth\tests\Fixtures;
+
+// exists so the DB gets reset when executing the ui test
+class TwoFactorUsersManagerFixture extends TwoFactorFixture
+{
+
+} \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/tests/Integration/APITest.php b/plugins/TwoFactorAuth/tests/Integration/APITest.php
new file mode 100644
index 0000000000..4f8b738138
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/APITest.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration;
+
+use Piwik\Container\StaticContainer;
+use Piwik\Plugins\TwoFactorAuth\API;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Plugins\TwoFactorAuth\TwoFactorAuthentication;
+use Piwik\Plugins\UsersManager\API as UsersAPI;
+use Piwik\Tests\Framework\Fixture;
+use Piwik\Tests\Framework\Mock\FakeAccess;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group APITest
+ * @group Plugins
+ */
+class APITest extends IntegrationTestCase
+{
+ /**
+ * @var API
+ */
+ private $api;
+
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $recoveryCodes;
+
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->api = API::getInstance();
+ $this->recoveryCodes = StaticContainer::get(RecoveryCodeDao::class);
+
+ foreach ([1,2,3] as $idsite) {
+ Fixture::createWebsite('2014-01-02 03:04:05');
+ }
+
+ foreach (['mylogin1', 'mylogin2'] as $user) {
+ UsersAPI::getInstance()->addUser($user, '123abcDk3_l3', $user . '@matomo.org');
+ }
+ $this->twoFa = StaticContainer::get(TwoFactorAuthentication::class);
+ }
+
+ /**
+ * @expectedExceptionMessage checkUserHasSuperUserAccess Fake exception
+ * @expectedException \Exception
+ */
+ public function test_resetTwoFactorAuth_failsWhenNotPermissions()
+ {
+ $this->setAdminUser();
+ $this->api->resetTwoFactorAuth('login');
+ }
+
+ public function test_resetTwoFactorAuth_resetsSecret()
+ {
+ $this->recoveryCodes->createRecoveryCodesForLogin('mylogin1');
+ $this->recoveryCodes->createRecoveryCodesForLogin('mylogin2');
+ $this->twoFa->saveSecret('mylogin1', '1234');
+ $this->twoFa->saveSecret('mylogin2', '1234');
+
+ $this->assertTrue($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin1'));
+ $this->assertTrue($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin2'));
+ $this->api->resetTwoFactorAuth('mylogin1');
+ $this->assertFalse($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin1'));
+ $this->assertTrue($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin2'));
+
+ $this->assertEquals([], $this->recoveryCodes->getAllRecoveryCodesForLogin('mylogin1'));
+ }
+
+ protected function setAdminUser()
+ {
+ FakeAccess::clearAccess(false);
+ FakeAccess::$identity = 'testUser';
+ FakeAccess::$idSitesView = array();
+ FakeAccess::$idSitesAdmin = array(1,2,3);
+ }
+
+ public function provideContainerConfig()
+ {
+ return array(
+ 'Piwik\Access' => new FakeAccess()
+ );
+ }
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeDaoTest.php b/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeDaoTest.php
new file mode 100644
index 0000000000..9eda0e9531
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeDaoTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration\Dao;
+
+use Piwik\Container\StaticContainer;
+use Piwik\DbHelper;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group RecoveryCodeDaoTest
+ * @group Plugins
+ */
+class RecoveryCodeDaoTest extends IntegrationTestCase
+{
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $dao;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->dao = StaticContainer::get(RecoveryCodeDao::class);
+ }
+
+ public function test_shouldInstallTable()
+ {
+ $columns = DbHelper::getTableColumns($this->dao->getPrefixedTableName());
+ $columns = array_keys($columns);
+
+ $this->assertEquals(['idrecoverycode', 'login', 'recovery_code'], $columns);
+ }
+
+ public function test_getAllRecoveryCodesForLogin_emptyByDefault()
+ {
+ $this->assertEquals([], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ }
+
+ public function test_insertRecoveryCode_getAllRecoveryCodesForLogin()
+ {
+ $this->dao->insertRecoveryCode('login1', '123456');
+ $this->dao->insertRecoveryCode('login1', '654321');
+ $this->dao->insertRecoveryCode('login2', '333111');
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['333111'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+ }
+
+ public function test_deleteRecoveryCode()
+ {
+ $this->insertManyCodesDifferentLogins();
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->assertEquals(1, $this->dao->deleteRecoveryCode('login2', '654321')); // this one should be deleted
+ $this->assertEquals(0, $this->dao->deleteRecoveryCode('login2', 'xya123')); // cannot be found
+ $this->assertEquals(0, $this->dao->deleteRecoveryCode('login999', '123456')); // cannot be found
+
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['123456'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->dao->deleteRecoveryCode('login2', '123456'); // delete last code for this login
+ $this->assertEquals([], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->assertEquals(0, $this->dao->deleteRecoveryCode('login2', '654321')); // cannot be deleted again
+
+ }
+
+ public function test_deleteAllRecoveryCodesForLogin()
+ {
+ $this->insertManyCodesDifferentLogins();
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->dao->deleteAllRecoveryCodesForLogin('login2'); // this one should be deleted
+ $this->dao->deleteAllRecoveryCodesForLogin('login999'); // login cannot be found
+
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals([], $this->dao->getAllRecoveryCodesForLogin('login2'));
+ }
+
+ public function test_useRecoveryCode()
+ {
+ $this->insertManyCodesDifferentLogins();
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->assertTrue($this->dao->useRecoveryCode('login2', '654321')); // this one should be used and deleted
+
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['123456'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->assertFalse($this->dao->useRecoveryCode('login2', '654321')); // cannot be used again
+ $this->assertFalse($this->dao->useRecoveryCode('login2', 'xya123')); // cannot be found
+ $this->assertFalse($this->dao->useRecoveryCode('login999', '123456')); // cannot be found
+
+ $this->assertEquals(['123456', '654321'], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->assertEquals(['123456'], $this->dao->getAllRecoveryCodesForLogin('login2'));
+
+ $this->assertTrue($this->dao->useRecoveryCode('login2', '123456')); // cannot be used again
+ $this->assertEquals([], $this->dao->getAllRecoveryCodesForLogin('login2'));
+ }
+
+ public function test_createRecoveryCodesForLogin()
+ {
+ $this->assertEquals([], $this->dao->getAllRecoveryCodesForLogin('login1'));
+ $this->dao->createRecoveryCodesForLogin('login1');
+
+ $codes1 = $this->dao->getAllRecoveryCodesForLogin('login1');
+ $this->assertCount(10, $codes1);
+
+ // generating new codes will remove the old codes
+ $this->dao->createRecoveryCodesForLogin('login1');
+
+ $codes2 = $this->dao->getAllRecoveryCodesForLogin('login1');
+ $this->assertCount(10, $codes2);
+
+ // not the same
+ $this->assertCount(10, array_diff($codes1, $codes2));
+ foreach ($codes1 as $code) {
+ // none of the old codes can be used
+ $this->assertFalse($this->dao->useRecoveryCode('login1', $code));
+ }
+ foreach ($codes2 as $code) {
+ // all new codes can be used
+ $this->assertTrue($this->dao->useRecoveryCode('login1', $code));
+ }
+ }
+
+ public function test_createRecoveryCodesForLogin_DifferentPerLogin()
+ {
+ $this->dao->createRecoveryCodesForLogin('login1');
+ $this->dao->createRecoveryCodesForLogin('login2');
+
+ $codes1 = $this->dao->getAllRecoveryCodesForLogin('login1');
+ $codes2 = $this->dao->getAllRecoveryCodesForLogin('login2');
+
+ // not the same
+ $this->assertCount(10, array_diff($codes1, $codes2));
+
+ foreach ($codes1 as $code) {
+ // all new codes can be used
+ $this->assertTrue($this->dao->useRecoveryCode('login1', $code));
+ }
+ foreach ($codes2 as $code) {
+ // all new codes can be used
+ $this->assertTrue($this->dao->useRecoveryCode('login2', $code));
+ }
+ }
+
+ private function insertManyCodesDifferentLogins()
+ {
+ $this->dao->insertRecoveryCode('login1', '123456');
+ $this->dao->insertRecoveryCode('login1', '654321');
+ $this->dao->insertRecoveryCode('login2', '123456');
+ $this->dao->insertRecoveryCode('login2', '654321');
+ }
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeRandomGeneratorTest.php b/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeRandomGeneratorTest.php
new file mode 100644
index 0000000000..e3449386c1
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeRandomGeneratorTest.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration\Dao;
+
+use Piwik\Common;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeRandomGenerator;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group RecoveryCodeRandomGeneratorTest
+ * @group Plugins
+ */
+class RecoveryCodeRandomGeneratorTest extends IntegrationTestCase
+{
+ /**
+ * @var RecoveryCodeRandomGenerator
+ */
+ private $generator;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->generator = new RecoveryCodeRandomGenerator();
+ }
+
+ public function test_generatorCode_length()
+ {
+ $this->assertSame(16, Common::mb_strlen($this->generator->generateCode()));
+ }
+
+ public function test_generatorCode_alwaysDifferent()
+ {
+ $this->assertNotEquals($this->generator->generateCode(), $this->generator->generateCode());
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeStaticGeneratorTest.php b/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeStaticGeneratorTest.php
new file mode 100644
index 0000000000..3ffc97b7a9
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/Dao/RecoveryCodeStaticGeneratorTest.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration\Dao;
+
+use Piwik\Common;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeStaticGenerator;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group RecoveryCodeStaticGenerator
+ * @group Plugins
+ */
+class RecoveryCodeStaticGeneratorTest extends IntegrationTestCase
+{
+ /**
+ * @var RecoveryCodeStaticGenerator
+ */
+ private $generator;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->generator = new RecoveryCodeStaticGenerator();
+ }
+
+ public function test_generatorCode_length()
+ {
+ $this->assertSame(16, Common::mb_strlen($this->generator->generateCode()));
+ }
+
+ public function test_generatorCode_alwaysDifferent()
+ {
+ $this->assertNotEquals($this->generator->generateCode(), $this->generator->generateCode());
+ }
+
+ public function test_generatorCode_increases()
+ {
+ $this->assertSame('1100000000000000', $this->generator->generateCode());
+ $this->assertSame('1200000000000000', $this->generator->generateCode());
+ }
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretRandomGeneratorTest.php b/plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretRandomGeneratorTest.php
new file mode 100644
index 0000000000..2a0cf55384
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretRandomGeneratorTest.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration\Dao;
+
+use Piwik\Common;
+use Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretRandomGenerator;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group TwoFaSecretRandomGeneratorTest
+ * @group Plugins
+ */
+class TwoFaSecretRandomGeneratorTest extends IntegrationTestCase
+{
+ /**
+ * @var TwoFaSecretRandomGenerator
+ */
+ private $generator;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->generator = new TwoFaSecretRandomGenerator();
+ }
+
+ public function test_generatorCode_length()
+ {
+ $this->assertSame(16, Common::mb_strlen($this->generator->generateSecret()));
+ }
+
+ public function test_generatorCode_alwaysDifferent()
+ {
+ $this->assertNotEquals($this->generator->generateSecret(), $this->generator->generateSecret());
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretStaticGeneratorTest.php b/plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretStaticGeneratorTest.php
new file mode 100644
index 0000000000..e5fa4883c4
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/Dao/TwoFaSecretStaticGeneratorTest.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration\Dao;
+
+use Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretStaticGenerator;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group TwoFaSecretStaticGeneratorTest
+ * @group Plugins
+ */
+class TwoFaSecretStaticGeneratorTest extends IntegrationTestCase
+{
+ /**
+ * @var TwoFaSecretStaticGenerator
+ */
+ private $generator;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->generator = new TwoFaSecretStaticGenerator();
+ }
+
+ public function test_generatorCode_alwaysSame()
+ {
+ $this->assertSame($this->generator->generateSecret(), $this->generator->generateSecret());
+ }
+
+ public function test_generatorCode_increases()
+ {
+ $this->assertSame('1111111111111111', $this->generator->generateSecret());
+ }
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/SystemSettingsTest.php b/plugins/TwoFactorAuth/tests/Integration/SystemSettingsTest.php
new file mode 100644
index 0000000000..fb9d507e26
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/SystemSettingsTest.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration;
+
+use Piwik\Plugins\TwoFactorAuth\SystemSettings;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+use Piwik\Url;
+
+/**
+ * @group TwoFactorAuth
+ * @group SystemSettingsTest
+ * @group Plugins
+ */
+class SystemSettingsTest extends IntegrationTestCase
+{
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->settings = new SystemSettings();
+ }
+
+ public function test_twoFactorAuthRequired_defaultDisabled()
+ {
+ $this->assertFalse($this->settings->twoFactorAuthRequired->getValue());
+ }
+
+ public function test_twoFactorAuthTitle_defaultTitle()
+ {
+ $this->assertEquals('Analytics - '. Url::getCurrentHost(), $this->settings->twoFactorAuthTitle->getValue());
+ }
+
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthTest.php b/plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthTest.php
new file mode 100644
index 0000000000..2df336f491
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthTest.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration;
+
+use Piwik\API\Request;
+use Piwik\Container\StaticContainer;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretRandomGenerator;
+use Piwik\Plugins\TwoFactorAuth\SystemSettings;
+use Piwik\Plugins\TwoFactorAuth\TwoFactorAuthentication;
+use Piwik\Plugins\UsersManager\API;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group Plugins
+ */
+class TwoFactorAuthTest extends IntegrationTestCase
+{
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $dao;
+
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ private $userWith2Fa = 'myloginWith';
+ private $userWithout2Fa = 'myloginWithout';
+ private $userPassword = '123abcDk3_l3';
+ private $user2faSecret = '123456';
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ foreach ([$this->userWith2Fa, $this->userWithout2Fa] as $user) {
+ API::getInstance()->addUser($user, $this->userPassword, $user . '@matomo.org');
+ API::getInstance()->setSuperUserAccess($user, 1);
+ }
+
+ $this->dao = StaticContainer::get(RecoveryCodeDao::class);
+ $this->settings = new SystemSettings();
+ $secretGenerator = new TwoFaSecretRandomGenerator();
+ $this->twoFa = new TwoFactorAuthentication($this->settings, $this->dao, $secretGenerator);
+
+ $this->dao->createRecoveryCodesForLogin($this->userWith2Fa);
+ $this->twoFa->saveSecret($this->userWith2Fa, $this->user2faSecret);
+ unset($_GET['authCode']);
+ }
+
+ public function tearDown()
+ {
+ unset($_GET['authCode']);
+ }
+
+ public function test_onApiGetTokenAuth_canAuthenticateWhenUserNotUsesTwoFA()
+ {
+ $token = Request::processRequest('UsersManager.getTokenAuth', array(
+ 'userLogin' => $this->userWithout2Fa,
+ 'md5Password' => md5($this->userPassword)
+ ));
+ $this->assertEquals(32, strlen($token));
+ }
+
+ public function test_onApiGetTokenAuth_returnsRandomTokenWhenNotAuthenticatedEvenWhen2FAenabled()
+ {
+ $token = Request::processRequest('UsersManager.getTokenAuth', array(
+ 'userLogin' => $this->userWith2Fa,
+ 'md5Password' => md5('invalidPAssword')
+ ));
+ $this->assertEquals(32, strlen($token));
+ }
+
+ /**
+ * @expectedException \Exception
+ * @expectedExceptionMessage TwoFactorAuth_MissingAuthCodeAPI
+ */
+ public function test_onApiGetTokenAuth_throwsErrorWhenMissingTokenWhenUsing2FaAndAuthenticatedCorrectly()
+ {
+ Request::processRequest('UsersManager.getTokenAuth', array(
+ 'userLogin' => $this->userWith2Fa,
+ 'md5Password' => md5($this->userPassword)
+ ));
+ }
+
+ /**
+ * @expectedException \Exception
+ * @expectedExceptionMessage TwoFactorAuth_InvalidAuthCode
+ */
+ public function test_onApiGetTokenAuth_throwsErrorWhenInvalidTokenWhenUsing2FaAndAuthenticatedCorrectly()
+ {
+ $_GET['authCode'] = '111222';
+ Request::processRequest('UsersManager.getTokenAuth', array(
+ 'userLogin' => $this->userWith2Fa,
+ 'md5Password' => md5($this->userPassword)
+ ));
+ }
+
+ public function test_onApiGetTokenAuth_returnsCorrectTokenWhenProvidingCorrectAuthTokenOnAuthentication()
+ {
+ $_GET['authCode'] = $this->generateValidAuthCode($this->user2faSecret);
+ $token = Request::processRequest('UsersManager.getTokenAuth', array(
+ 'userLogin' => $this->userWith2Fa,
+ 'md5Password' => md5($this->userPassword)
+ ));
+ $this->assertEquals(32, strlen($token));
+ }
+
+ public function test_onDeleteUser_RemovesAllRecoveryCodesWhenUsingTwoFa()
+ {
+ $this->assertNotEmpty($this->dao->getAllRecoveryCodesForLogin($this->userWith2Fa));
+ Request::processRequest('UsersManager.deleteUser', array(
+ 'userLogin' => $this->userWith2Fa
+ ));
+ $this->assertEmpty($this->dao->getAllRecoveryCodesForLogin($this->userWith2Fa));
+ }
+
+ public function test_onDeleteUser_DoesNotFailToAddUserNotUsingTwoFa()
+ {
+ Request::processRequest('UsersManager.deleteUser', array(
+ 'userLogin' => $this->userWithout2Fa
+ ));
+ }
+
+ private function generateValidAuthCode($secret)
+ {
+ $code = new \TwoFactorAuthenticator();
+ return $code->getCode($secret);
+ }
+}
diff --git a/plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthenticationTest.php b/plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthenticationTest.php
new file mode 100644
index 0000000000..154e343cce
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/Integration/TwoFactorAuthenticationTest.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\TwoFactorAuth\tests\Integration;
+
+use Piwik\Common;
+use Piwik\Container\StaticContainer;
+use Piwik\Plugins\TwoFactorAuth\Dao\RecoveryCodeDao;
+use Piwik\Plugins\TwoFactorAuth\Dao\TwoFaSecretRandomGenerator;
+use Piwik\Plugins\TwoFactorAuth\SystemSettings;
+use Piwik\Plugins\TwoFactorAuth\TwoFactorAuthentication;
+use Piwik\Plugins\UsersManager\API;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group TwoFactorAuth
+ * @group TwoFactorAuthenticationTest
+ * @group Plugins
+ */
+class TwoFactorAuthenticationTest extends IntegrationTestCase
+{
+ /**
+ * @var RecoveryCodeDao
+ */
+ private $dao;
+
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ /**
+ * @var TwoFactorAuthentication
+ */
+ private $twoFa;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ foreach (['mylogin', 'mylogin1', 'mylogin2'] as $user) {
+ API::getInstance()->addUser($user, '123abcDk3_l3', $user . '@matomo.org');
+ }
+
+ $this->dao = StaticContainer::get(RecoveryCodeDao::class);
+ $this->settings = new SystemSettings();
+ $secretGenerator = new TwoFaSecretRandomGenerator();
+ $this->twoFa = new TwoFactorAuthentication($this->settings, $this->dao, $secretGenerator);
+ }
+
+ public function test_generateSecret()
+ {
+ $this->assertSame(16, Common::mb_strlen($this->twoFa->generateSecret()));
+ }
+
+ public function test_isUserRequiredToHaveTwoFactorEnabled_notByDefault()
+ {
+ $this->assertFalse($this->twoFa->isUserRequiredToHaveTwoFactorEnabled());
+ }
+
+ public function test_isUserRequiredToHaveTwoFactorEnabled()
+ {
+ $this->settings->twoFactorAuthRequired->setValue(1);
+ $this->assertTrue($this->twoFa->isUserRequiredToHaveTwoFactorEnabled());
+ }
+
+ public function test_saveSecret_disable2FAforUser_isUserUsingTwoFactorAuthentication()
+ {
+ $this->dao->createRecoveryCodesForLogin('mylogin');
+
+ $this->assertFalse($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin'));
+ $this->twoFa->saveSecret('mylogin', '123456');
+
+ $this->assertTrue($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin'));
+ $this->assertFalse($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin2'));
+
+ $this->twoFa->disable2FAforUser('mylogin');
+
+ $this->assertFalse($this->twoFa->isUserUsingTwoFactorAuthentication('mylogin'));
+ }
+
+ public function test_disable2FAforUser_removesAllRecoveryCodes()
+ {
+ $this->dao->createRecoveryCodesForLogin('mylogin');
+ $this->assertNotEmpty($this->dao->getAllRecoveryCodesForLogin('mylogin'));
+ $this->twoFa->disable2FAforUser('mylogin');
+ $this->assertEquals([], $this->dao->getAllRecoveryCodesForLogin('mylogin'));
+ }
+
+ /**
+ * @expectedExceptionMessage Anonymous cannot use
+ * @expectedException \Exception
+ */
+ public function test_saveSecret_neverWorksForAnonymous()
+ {
+ $this->twoFa->saveSecret('anonymous', '123456');
+ }
+
+ /**
+ * @expectedExceptionMessage no recovery codes have been created
+ * @expectedException \Exception
+ */
+ public function test_saveSecret_notWorksWhenNoRecoveryCodesCreated()
+ {
+ $this->twoFa->saveSecret('not', '123456');
+ }
+
+ public function test_isUserUsingTwoFactorAuthentication_neverWorksForAnonymous()
+ {
+ $this->assertFalse($this->twoFa->isUserUsingTwoFactorAuthentication('anonymous'));
+ }
+
+ public function test_validateAuthCodeDuringSetup()
+ {
+ $secret = '789123';
+ $this->assertFalse($this->twoFa->validateAuthCodeDuringSetup('123456', $secret));
+
+ $authCode = $this->generateValidAuthCode($secret);
+
+ $this->assertTrue($this->twoFa->validateAuthCodeDuringSetup($authCode, $secret));
+ }
+
+ public function test_validateAuthCode_userIsNotUsingTwoFa()
+ {
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin', '123456'));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin', false));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin', null));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin', ''));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin', 0));
+ }
+
+ public function test_validateAuthCode_userIsUsingTwoFa_authenticatesThroughApp()
+ {
+ $secret1 = '123456';
+ $secret2 = '654321';
+ $this->dao->createRecoveryCodesForLogin('mylogin1');
+ $this->dao->createRecoveryCodesForLogin('mylogin2');
+ $this->twoFa->saveSecret('mylogin1', $secret1);
+ $this->twoFa->saveSecret('mylogin2', $secret2);
+
+ $authCode1 = $this->generateValidAuthCode($secret1);
+ $authCode2 = $this->generateValidAuthCode($secret2);
+
+ $this->assertTrue($this->twoFa->validateAuthCode('mylogin1', $authCode1));
+ $this->assertTrue($this->twoFa->validateAuthCode('mylogin2', $authCode2));
+
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin2', $authCode1));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin1', $authCode2));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin1', false));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin2', null));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin2', ''));
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin1', 0));
+ }
+
+ public function test_validateAuthCode_userIsUsingTwoFa_authenticatesThroughRecoveryCode()
+ {
+ $this->dao->createRecoveryCodesForLogin('mylogin1');
+ $this->dao->createRecoveryCodesForLogin('mylogin2');
+ $this->twoFa->saveSecret('mylogin1', '123456');
+ $this->twoFa->saveSecret('mylogin2', '654321');
+
+ $codesLogin1 = $this->dao->getAllRecoveryCodesForLogin('mylogin1');
+ $codesLogin2 = $this->dao->getAllRecoveryCodesForLogin('mylogin2');
+ $this->assertNotEmpty($codesLogin1);
+ $this->assertNotEmpty($codesLogin2);
+
+ foreach ($codesLogin1 as $code) {
+ // doesn't work cause belong to different user
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin2', $code));
+ }
+
+ foreach ($codesLogin1 as $code) {
+ $this->assertTrue($this->twoFa->validateAuthCode('mylogin1', $code));
+ }
+
+ foreach ($codesLogin1 as $code) {
+ // no code can be used twice
+ $this->assertFalse($this->twoFa->validateAuthCode('mylogin1', $code));
+ }
+ }
+
+ private function generateValidAuthCode($secret)
+ {
+ $code = new \TwoFactorAuthenticator();
+ return $code->getCode($secret);
+ }
+}
diff --git a/plugins/TwoFactorAuth/tests/UI/.gitignore b/plugins/TwoFactorAuth/tests/UI/.gitignore
new file mode 100644
index 0000000000..f39be478e7
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/.gitignore
@@ -0,0 +1,2 @@
+/processed-ui-screenshots
+/screenshot-diffs \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/tests/UI/TwoFactorAuthUsersManager_spec.js b/plugins/TwoFactorAuth/tests/UI/TwoFactorAuthUsersManager_spec.js
new file mode 100644
index 0000000000..e39348caab
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/TwoFactorAuthUsersManager_spec.js
@@ -0,0 +1,76 @@
+/*!
+ * Piwik - free/libre analytics platform
+ *
+ * Screenshot integration tests.
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+describe("TwoFactorAuthUsersManager", function () {
+ this.timeout(0);
+
+ this.fixture = "Piwik\\Plugins\\TwoFactorAuth\\tests\\Fixtures\\TwoFactorUsersManagerFixture";
+
+ var generalParams = 'idSite=1&period=day&date=2010-01-03',
+ usersManager = '?module=UsersManager&action=index&' + generalParams;
+
+ before(function () {
+ testEnvironment.pluginsToLoad = ['TwoFactorAuth'];
+ testEnvironment.save();
+ });
+
+
+ function selectModalButton(page, button)
+ {
+ page.click('.modal.open .modal-footer a:contains('+button+')');
+ }
+
+ function captureModal(done, screenshotName, test, selector) {
+ captureScreen(done, screenshotName, test, '.modal.open');
+ }
+
+ function captureScreen(done, screenshotName, test, selector) {
+ if (!selector) {
+ selector = '#content,#notificationContainer';
+ }
+
+ expect.screenshot(screenshotName).to.be.captureSelector(selector, test, done);
+ }
+
+ function captureModal(done, screenshotName, test, selector) {
+ captureScreen(done, screenshotName, test, '.modal.open');
+ }
+
+ it('shows users with 2fa and not 2fa', function (done) {
+ captureScreen(done, 'list', function (page) {
+ page.load(usersManager);
+ page.evaluate(function () {
+ $('td#last_seen').html(''); // fix random test failure
+ });
+ });
+ });
+
+ it('menu should show 2fa tab', function (done) {
+ captureScreen(done, 'edit_with_2fa', function (page) {
+ page.setViewportSize(1250);
+ page.click('#manageUsersTable #row2 .edituser');
+ page.evaluate(function () {
+ $('.userEditForm .menuUserTwoFa a').click();
+ });
+ });
+ });
+
+ it('should ask for confirmation before resetting 2fa', function (done) {
+ captureModal(done, 'edit_with_2fa_reset_confirm', function (page) {
+ page.click('.userEditForm .twofa-reset .resetTwoFa .btn');
+ });
+ });
+
+ it('should be possible to confirm the reset', function (done) {
+ captureScreen(done, 'edit_with_2fa_reset_confirmed', function (page) {
+ page.click('.twofa-confirm-modal .modal-close:not(.modal-no)');
+ });
+ });
+
+}); \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/tests/UI/TwoFactorAuth_spec.js b/plugins/TwoFactorAuth/tests/UI/TwoFactorAuth_spec.js
new file mode 100644
index 0000000000..b3a76c3987
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/TwoFactorAuth_spec.js
@@ -0,0 +1,232 @@
+/*!
+ * Piwik - free/libre analytics platform
+ *
+ * Screenshot integration tests.
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+describe("TwoFactorAuth", function () {
+ this.timeout(0);
+
+ this.fixture = "Piwik\\Plugins\\TwoFactorAuth\\tests\\Fixtures\\TwoFactorFixture";
+
+ var generalParams = 'idSite=1&period=day&date=2010-01-03',
+ userSettings = '?module=UsersManager&action=userSettings&' + generalParams,
+ logoutUrl = '?module=Login&action=logout&period=day&date=yesterday';
+
+
+ function selectModalButton(page, button)
+ {
+ page.click('.modal.open .modal-footer a:contains('+button+')');
+ }
+
+ function loginUser(page, username, doAuth)
+ {
+ // make sure to log out previous session
+ page.load(logoutUrl);
+
+ if (typeof doAuth === 'undefined') {
+ doAuth = true;
+ }
+ var logMeUrl = '?module=Login&action=logme&login=' + username + '&password=240161a241087c28d92d8d7ff3b6186b'
+ if (doAuth) {
+ logMeUrl += '&authCode=123456'; // we make sure in test config this code always works
+ }
+ page.wait(1000);
+ page.load(logMeUrl);
+ }
+
+ function requireTwoFa() {
+ testEnvironment.requireTwoFa = 1;
+ testEnvironment.save();
+ }
+
+ function fakeCorrectAuthCode() {
+ testEnvironment.fakeCorrectAuthCode = 1;
+ testEnvironment.save();
+ }
+
+ before(function () {
+ testEnvironment.pluginsToLoad = ['TwoFactorAuth'];
+ testEnvironment.save();
+ });
+
+ beforeEach(function () {
+ testEnvironment.testUseMockAuth = 0;
+ testEnvironment.restoreRecoveryCodes = 1;
+ testEnvironment.save();
+ });
+
+ afterEach(function () {
+ delete testEnvironment.requireTwoFa;
+ delete testEnvironment.restoreRecoveryCodes;
+ delete testEnvironment.fakeCorrectAuthCode;
+ testEnvironment.testUseMockAuth = 1;
+ testEnvironment.save();
+ });
+
+ function confirmPassword(page)
+ {
+ page.wait(1000);
+ page.sendKeys('.confirmPasswordForm #login_form_password', '123abcDk3_l3');
+ page.click('.confirmPasswordForm #login_form_submit');
+ }
+
+ function captureScreen(done, screenshotName, test, selector) {
+ if (!selector) {
+ selector = '.loginSection,#content,#notificationContainer';
+ }
+
+ expect.screenshot(screenshotName).to.be.captureSelector(selector, test, done);
+ }
+
+ function captureUserSettings(done, screenshotName, test, selector) {
+ captureScreen(done, screenshotName, test, '.userSettings2FA');
+ }
+
+ function captureModal(done, screenshotName, test, selector) {
+ captureScreen(done, screenshotName, test, '.modal.open');
+ }
+
+ it('a user with 2fa can open the widgetized view by token without needing to verify', function (done) {
+ captureScreen(done, 'widgetized_no_verify', function (page) {
+ page.load('?module=Widgetize&action=iframe&moduleToWidgetize=Actions&actionToWidgetize=getPageUrls&token_auth=c4ca4238a0b923820dcc509a6f75849b&' + generalParams);
+ });
+ });
+
+ it('when logging in through logme and not providing auth code it should show auth code screen', function (done) {
+ captureScreen(done, 'logme_not_verified', function (page) {
+ loginUser(page, 'with2FA', false);
+ });
+ });
+
+ it('when logging in and providing wrong code an error is shown', function (done) {
+ captureScreen(done, 'logme_not_verified_wrong_code', function (page) {
+ page.sendKeys('.loginTwoFaForm #login_form_authcode', '555555');
+ page.click('.loginTwoFaForm #login_form_submit');
+ });
+ });
+
+ it('when logging in through logme and verifying screen it works to access ui', function (done) {
+ captureScreen(done, 'logme_verified', function (page) {
+ page.sendKeys('.loginTwoFaForm #login_form_authcode', '123456');
+ page.click('.loginTwoFaForm #login_form_submit');
+ });
+ });
+
+ it('should show user settings when two-fa enabled', function (done) {
+ captureUserSettings(done, 'usersettings_twofa_enabled', function (page) {
+ loginUser(page, 'with2FA');
+ page.load(userSettings);
+ });
+ });
+
+ it('should be possible to show recovery codes step1 authentication', function (done) {
+ captureScreen(done, 'show_recovery_codes_step1', function (page) {
+ page.click('.showRecoveryCodesLink');
+ });
+ });
+ it('should be possible to show recovery codes step2 done', function (done) {
+ captureScreen(done, 'show_recovery_codes_step2', function (page) {
+ confirmPassword(page);
+ });
+ });
+
+ it('should show user settings when two-fa enabled', function (done) {
+ captureUserSettings(done, 'usersettings_twofa_enabled_required', function (page) {
+ requireTwoFa();
+ page.load(userSettings);
+ });
+ });
+
+ it('should be possible to disable two factor', function (done) {
+ captureModal(done, 'usersettings_twofa_disable_step1', function (page) {
+ loginUser(page, 'with2FADisable');
+ page.load(userSettings);
+ page.click('.disable2FaLink');
+ });
+ });
+
+ it('should be possible to disable two factor step 2 confirmed', function (done) {
+ captureScreen(done, 'usersettings_twofa_disable_step2', function (page) {
+ selectModalButton(page, 'Yes');
+ });
+ });
+
+ it('should be possible to disable two factor step 3 verified', function (done) {
+ captureUserSettings(done, 'usersettings_twofa_disable_step3', function (page) {
+ confirmPassword(page);
+ });
+ });
+
+ it('should show setup screen - step 1', function (done) {
+ captureScreen(done, 'twofa_setup_step1', function (page) {
+ loginUser(page, 'without2FA');
+ page.load(userSettings);
+ page.click('.enable2FaLink');
+ confirmPassword(page);
+ });
+ });
+
+ it('should move to second step in setup - step 2', function (done) {
+ captureScreen(done, 'twofa_setup_step2', function (page) {
+ page.click('.setupTwoFactorAuthentication .backupRecoveryCode:first');
+ page.click('.setupTwoFactorAuthentication .goToStep2');
+ });
+ });
+
+ it('should move to third step in setup - step 3', function (done) {
+ captureScreen(done, 'twofa_setup_step3', function (page) {
+ page.click('.setupTwoFactorAuthentication .goToStep3');
+ });
+ });
+
+ it('should move to third step in setup - step 4 confirm', function (done) {
+ captureScreen(done, 'twofa_setup_step4', function (page) {
+ fakeCorrectAuthCode();
+ page.sendKeys('.setupConfirmAuthCodeForm input[type=text]', '123458');
+ page.evaluate(function () {
+ $('.setupConfirmAuthCodeForm input[type=text]').change();
+ });
+ page.evaluate(function () {
+ $('.setupConfirmAuthCodeForm .confirmAuthCode').click();
+ });
+ });
+ });
+
+ it('should force user to setup 2fa when not set up yet but enforced', function (done) {
+ captureScreen(done, 'twofa_forced_step1', function (page) {
+ requireTwoFa();
+ loginUser(page, 'no2FA', false);
+ });
+ });
+
+ it('should force user to setup 2fa when not set up yet but enforced step 2', function (done) {
+ captureScreen(done, 'twofa_forced_step2', function (page) {
+ page.click('.setupTwoFactorAuthentication .backupRecoveryCode:first');
+ page.click('.setupTwoFactorAuthentication .goToStep2');
+ });
+ });
+
+ it('should force user to setup 2fa when not set up yet but enforced step 3', function (done) {
+ captureScreen(done, 'twofa_forced_step3', function (page) {
+ page.click('.setupTwoFactorAuthentication .goToStep3');
+ });
+ });
+ it('should force user to setup 2fa when not set up yet but enforced confirm code', function (done) {
+ captureScreen(done, 'twofa_forced_step4', function (page) {
+ requireTwoFa();
+ fakeCorrectAuthCode();
+ page.sendKeys('.setupConfirmAuthCodeForm input[type=text]', '123458');
+ page.evaluate(function () {
+ $('.setupConfirmAuthCodeForm input[type=text]').change();
+ });
+ page.evaluate(function () {
+ $('.setupConfirmAuthCodeForm .confirmAuthCode').click();
+ });
+ });
+ });
+
+}); \ No newline at end of file
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/.gitkeep b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/.gitkeep
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa.png
new file mode 100644
index 0000000000..e206eff629
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:75ac9b5d1481c29e222f4714de1749556dc700e16c480f84c3e1a9baec99043a
+size 27617
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
new file mode 100644
index 0000000000..f5a25ae9a1
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ab3a215b8f7038be361dbf648b9a5efbdcadc5dc7bdd0d565b74a0e9f47c0b3b
+size 6127
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
new file mode 100644
index 0000000000..ef22ba811c
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:56d61ea7b78a07318421b35e1dae2233f8ea568dd066c4f6c96b9b3c159610b3
+size 28794
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_list.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_list.png
new file mode 100644
index 0000000000..3d16520962
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuthUsersManager_list.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6a03a40d29de20cabbfda1fef74e9c007977846858fd8dcf54a67f863313865b
+size 53925
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified.png
new file mode 100644
index 0000000000..3f3529b56c
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:64555eaa6653c9ea60b7a7005f918db7bd1ce78f5bc4952e9dfe7a2d6948c48f
+size 43000
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified_wrong_code.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified_wrong_code.png
new file mode 100644
index 0000000000..ea0a73c26f
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_not_verified_wrong_code.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4170d350ffc0074b9c60fe3a8aab6aae70289073cec28edc17ff422ade22b32d
+size 51387
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_verified.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_verified.png
new file mode 100644
index 0000000000..4e1b429108
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_logme_verified.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:57ee17dcaf6be387f1df3bf318208e1b849d9228c6290f64beb03624fe814376
+size 139517
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step1.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step1.png
new file mode 100644
index 0000000000..a98ade64c8
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step1.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9dddb94c30224362115ae13ebd5170c96caa80be88b18583f7920e0fd213117a
+size 15127
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step2.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step2.png
new file mode 100644
index 0000000000..1fe16418ca
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_show_recovery_codes_step2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:80a3c4babfdfbf6040181cdf4d7059502d3af8df0c0572a16f17cda3e852dc22
+size 63846
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step1.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step1.png
new file mode 100644
index 0000000000..40e3aad93f
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step1.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:750bbc270525ea990e3a541b7d4ce5e819aefe1a05e937dd538af5d2cec34178
+size 103348
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step2.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step2.png
new file mode 100644
index 0000000000..15dd7c7e43
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:157b46a7bed191c5d2585366c60d6a01e69f847748fbcb760e4a35ea5627ab3d
+size 136989
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step3.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step3.png
new file mode 100644
index 0000000000..143ce340a8
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step3.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40729fc0b0676c77ba7da2b4ba70884e223a446ed23e6a863e1457a047f643f5
+size 176206
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step4.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step4.png
new file mode 100644
index 0000000000..4e1b429108
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_forced_step4.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:57ee17dcaf6be387f1df3bf318208e1b849d9228c6290f64beb03624fe814376
+size 139517
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step1.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step1.png
new file mode 100644
index 0000000000..2b525ac946
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step1.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d0bc9e697dff2fd9f91451a033ba778c3742e397c2d6f934a8a6b780fa5f02aa
+size 74454
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step2.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step2.png
new file mode 100644
index 0000000000..72b7383319
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e880b4f4fc98cb8fc5a6538f2ff5b2ecdd464a2662ed3ff946087a2f7a15dd3c
+size 95996
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step3.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step3.png
new file mode 100644
index 0000000000..e1e5cac715
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step3.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6c2b1e53058987335b513a461e81eb43c23d89e8f07b4f899f62c4091a10ff10
+size 124487
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step4.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step4.png
new file mode 100644
index 0000000000..77606d2ea7
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_twofa_setup_step4.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b634a5d1dccc7dbdc9a2f172de66428ac52a2f6998546a921b758657dd0a75e
+size 34384
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step1.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step1.png
new file mode 100644
index 0000000000..95fcf8073c
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step1.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:035f9173979d5650c2ae6dbca4a19c80601b62c4f984c2be912b03cdf57e304a
+size 21734
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step2.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step2.png
new file mode 100644
index 0000000000..a98ade64c8
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9dddb94c30224362115ae13ebd5170c96caa80be88b18583f7920e0fd213117a
+size 15127
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step3.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step3.png
new file mode 100644
index 0000000000..0418149865
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_disable_step3.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8af9fbcce1498486fd7f42bf18bed791db6dc49e9fb5e9f94f21ea31ab105383
+size 45375
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled.png
new file mode 100644
index 0000000000..51f254b0ef
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f35c663a2705823ed3cc81f9038928a5691575018fd14802ca83788123a3ce5a
+size 49059
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled_required.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled_required.png
new file mode 100644
index 0000000000..2639572760
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_usersettings_twofa_enabled_required.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9d6bba78762b389d8902269840b1bb590918c7bbfe17ec62f452e19411a268fc
+size 52910
diff --git a/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_widgetized_no_verify.png b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_widgetized_no_verify.png
new file mode 100644
index 0000000000..dbeb7d4a4b
--- /dev/null
+++ b/plugins/TwoFactorAuth/tests/UI/expected-screenshots/TwoFactorAuth_widgetized_no_verify.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9dd3c510f45fec8f5d9fce69b0e89faaa63687e8c8119fa140668fa099406179
+size 10749