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:
-rw-r--r--composer.json3
-rw-r--r--composer.lock117
-rw-r--r--config/global.ini.php1
-rw-r--r--core/Access.php8
-rw-r--r--core/Db/Schema/Mysql.php11
-rw-r--r--core/Session/SessionFingerprint.php17
-rw-r--r--core/Session/SessionInitializer.php5
-rw-r--r--core/Updater/Migration/Plugin/Deactivate.php51
-rw-r--r--core/Updater/Migration/Plugin/Factory.php15
-rw-r--r--core/Updates/3.7.0-b1.php4
-rw-r--r--core/Updates/3.8.0_b3.php60
-rw-r--r--core/Version.php2
-rw-r--r--lang/en.json3
-rw-r--r--libs/Authenticator/LICENSE.md22
-rw-r--r--libs/Authenticator/README.md85
-rw-r--r--libs/Authenticator/TwoFactorAuthenticator.php195
-rw-r--r--plugins/CorePluginsAdmin/tests/UI/expected-screenshots/TagManagerTeaser_admin_page_disable.png4
-rw-r--r--plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_removed.png4
-rw-r--r--plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_invalid_token_auth.png4
-rw-r--r--plugins/Login/Controller.php60
-rw-r--r--plugins/Login/Login.php5
-rw-r--r--plugins/Login/PasswordVerifier.php166
-rw-r--r--plugins/Login/lang/en.json2
-rw-r--r--plugins/Login/templates/confirmPassword.twig43
-rw-r--r--plugins/Login/templates/login.twig258
-rw-r--r--plugins/Login/templates/loginLayout.twig48
-rw-r--r--plugins/Login/tests/Integration/PasswordVerifierTest.php132
-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
-rw-r--r--plugins/UsersManager/API.php70
-rw-r--r--plugins/UsersManager/Controller.php4
-rw-r--r--plugins/UsersManager/Model.php4
-rw-r--r--plugins/UsersManager/UsersManager.php5
-rw-r--r--plugins/UsersManager/angularjs/paged-users-list/paged-users-list.component.html6
-rw-r--r--plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.html26
-rw-r--r--plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.js23
-rw-r--r--plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.less2
-rw-r--r--plugins/UsersManager/lang/en.json5
-rw-r--r--plugins/UsersManager/templates/userSettings.twig2
-rw-r--r--plugins/UsersManager/tests/Integration/APITest.php28
-rw-r--r--plugins/UsersManager/tests/Integration/UsersManagerTest.php7
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login1_when_superuseraccess.xml1
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_adminaccess.xml1
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_superuseraccess.xml1
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_superuseraccess.xml1
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_viewaccess.xml1
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login6_when_superuseraccess.xml1
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUsersWithSiteAccess_3_admin_when_superuseraccess.xml2
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_adminaccess.xml7
-rw-r--r--plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_superuseraccess.xml11
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search_deselected.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_selected.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_remove_access.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_set_access.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_bulk_access.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_single.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_filters.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_load.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_manage_users_back.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_next_click.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_previous.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_role_for.png4
-rw-r--r--plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_rows_selected.png4
-rw-r--r--tests/PHPUnit/Integration/CacheTest.php3
-rw-r--r--tests/PHPUnit/Integration/ReleaseCheckListTest.php2
-rw-r--r--tests/PHPUnit/System/expected/test_ImportLogs__CorePluginsAdmin.getSystemSettings.xml38
-rw-r--r--tests/PHPUnit/System/expected/test_OneVisitorTwoVisits__CorePluginsAdmin.getSystemSettings.xml38
-rw-r--r--tests/PHPUnit/System/expected/test_noVisit_PeriodIsLast__CorePluginsAdmin.getSystemSettings.xml38
-rw-r--r--tests/PHPUnit/System/expected/test_noVisit__CorePluginsAdmin.getSystemSettings.xml38
-rw-r--r--tests/PHPUnit/Unit/Session/SessionFingerprintTest.php12
-rw-r--r--tests/UI/expected-screenshots/EmptySite_emptySiteDashboard_ignored.png4
-rw-r--r--tests/UI/expected-screenshots/Menus_mobile_top.png4
-rw-r--r--tests/UI/expected-screenshots/Theme_home.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_home.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins_no_internet.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_settings_general.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings_asks_confirmation.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_dashboard1.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_dashboard3.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_referrers_overview.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_referrers_socials.png4
-rw-r--r--tests/UI/expected-screenshots/UIIntegrationTest_shortcuts.png4
-rw-r--r--tests/UI/screenshot-diffs/missing-expected.list381
152 files changed, 4871 insertions, 279 deletions
diff --git a/composer.json b/composer.json
index e2414f6f36..00c01da3d3 100644
--- a/composer.json
+++ b/composer.json
@@ -48,7 +48,8 @@
"composer/semver": "~1.3.0",
"szymach/c-pchart": "^2.0",
"geoip2/geoip2": "^2.8",
- "davaxi/sparkline": "^1.1"
+ "davaxi/sparkline": "^1.1",
+ "endroid/qrcode": "^1.9"
},
"require-dev": {
"aws/aws-sdk-php": "2.7.1",
diff --git a/composer.lock b/composer.lock
index 1bc233a31f..7110006f43 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
- "content-hash": "58af620cb0a5851205adfedd960f5c59",
+ "content-hash": "21231a702c65860b3d61873981a9c6b7",
"packages": [
{
"name": "composer/ca-bundle",
@@ -283,6 +283,67 @@
"time": "2017-07-22T12:49:21+00:00"
},
{
+ "name": "endroid/qrcode",
+ "version": "1.9.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/endroid/qr-code.git",
+ "reference": "c9644bec2a9cc9318e98d1437de3c628dcd1ef93"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/endroid/qr-code/zipball/c9644bec2a9cc9318e98d1437de3c628dcd1ef93",
+ "reference": "c9644bec2a9cc9318e98d1437de3c628dcd1ef93",
+ "shasum": ""
+ },
+ "require": {
+ "ext-gd": "*",
+ "php": ">=5.4",
+ "symfony/options-resolver": "^2.3|^3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.0|^5.0",
+ "sensio/framework-extra-bundle": "^3.0",
+ "symfony/browser-kit": "^2.3|^3.0",
+ "symfony/framework-bundle": "^2.3|^3.0",
+ "symfony/http-kernel": "^2.3|^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Endroid\\QrCode\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jeroen van den Enden",
+ "email": "info@endroid.nl",
+ "homepage": "http://endroid.nl/"
+ }
+ ],
+ "description": "Endroid QR Code",
+ "homepage": "https://github.com/endroid/QrCode",
+ "keywords": [
+ "bundle",
+ "code",
+ "endroid",
+ "qr",
+ "qrcode",
+ "symfony"
+ ],
+ "abandoned": "endroid/qr-code",
+ "time": "2017-04-08T09:13:59+00:00"
+ },
+ {
"name": "geoip2/geoip2",
"version": "v2.9.0",
"source": {
@@ -1525,6 +1586,60 @@
"time": "2015-06-25T11:21:15+00:00"
},
{
+ "name": "symfony/options-resolver",
+ "version": "v3.4.19",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/options-resolver.git",
+ "reference": "2cf5aa084338c1f67166013aebe87e2026bbe953"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2cf5aa084338c1f67166013aebe87e2026bbe953",
+ "reference": "2cf5aa084338c1f67166013aebe87e2026bbe953",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.5.9|>=7.0.8"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.4-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\OptionsResolver\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony OptionsResolver Component",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "config",
+ "configuration",
+ "options"
+ ],
+ "time": "2018-11-11T19:48:54+00:00"
+ },
+ {
"name": "symfony/polyfill-ctype",
"version": "v1.10.0",
"source": {
diff --git a/config/global.ini.php b/config/global.ini.php
index b502d56609..4b8fc9d975 100644
--- a/config/global.ini.php
+++ b/config/global.ini.php
@@ -904,6 +904,7 @@ Plugins[] = Feedback
Plugins[] = Monolog
Plugins[] = Login
+Plugins[] = TwoFactorAuth
Plugins[] = UsersManager
Plugins[] = SitesManager
Plugins[] = Installation
diff --git a/core/Access.php b/core/Access.php
index 9d06fc05a4..24d818b61a 100644
--- a/core/Access.php
+++ b/core/Access.php
@@ -620,16 +620,24 @@ class Access
$isSuperUser = self::getInstance()->hasSuperUserAccess();
$access = self::getInstance();
+ $login = $access->getLogin();
+ $shouldResetLogin = empty($login); // make sure to reset login if a login was set by "makeSureLoginNameIsSet()"
$access->setSuperUserAccess(true);
try {
$result = $function();
} catch (Exception $ex) {
$access->setSuperUserAccess($isSuperUser);
+ if ($shouldResetLogin) {
+ $access->login = null;
+ }
throw $ex;
}
+ if ($shouldResetLogin) {
+ $access->login = null;
+ }
$access->setSuperUserAccess($isSuperUser);
return $result;
diff --git a/core/Db/Schema/Mysql.php b/core/Db/Schema/Mysql.php
index b033940118..557ec9795d 100644
--- a/core/Db/Schema/Mysql.php
+++ b/core/Db/Schema/Mysql.php
@@ -43,6 +43,7 @@ class Mysql implements SchemaInterface
password VARCHAR(255) NOT NULL,
alias VARCHAR(45) NOT NULL,
email VARCHAR(100) NOT NULL,
+ twofactor_secret VARCHAR(40) NOT NULL DEFAULT '',
token_auth CHAR(32) NOT NULL,
superuser_access TINYINT(2) unsigned NOT NULL DEFAULT '0',
date_registered TIMESTAMP NULL,
@@ -52,6 +53,14 @@ class Mysql implements SchemaInterface
) ENGINE=$engine DEFAULT CHARSET=utf8
",
+ 'twofactor_recovery_code' => "CREATE TABLE {$prefixTables}twofactor_recovery_code (
+ idrecoverycode BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ login VARCHAR(100) NOT NULL,
+ recovery_code VARCHAR(40) NOT NULL,
+ PRIMARY KEY(idrecoverycode)
+ ) ENGINE=$engine DEFAULT CHARSET=utf8
+ ",
+
'access' => "CREATE TABLE {$prefixTables}access (
idaccess INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT,
login VARCHAR(100) NOT NULL,
@@ -474,7 +483,7 @@ class Mysql implements SchemaInterface
// note that the token_auth value is anonymous, which is assigned by default as well in the Login plugin
$db = $this->getDb();
$db->query("INSERT IGNORE INTO " . Common::prefixTable("user") . "
- VALUES ( 'anonymous', '', 'anonymous', 'anonymous@example.org', 'anonymous', 0, '$now', '$now' );");
+ VALUES ( 'anonymous', '', 'anonymous', 'anonymous@example.org', '', 'anonymous', 0, '$now', '$now' );");
}
/**
diff --git a/core/Session/SessionFingerprint.php b/core/Session/SessionFingerprint.php
index 3aa4d9095d..e886ef9c8c 100644
--- a/core/Session/SessionFingerprint.php
+++ b/core/Session/SessionFingerprint.php
@@ -37,6 +37,7 @@ class SessionFingerprint
{
const USER_NAME_SESSION_VAR_NAME = 'user.name';
const SESSION_INFO_SESSION_VAR_NAME = 'session.info';
+ const SESSION_INFO_TWO_FACTOR_AUTH_VERIFIED = 'twofactorauth.verified';
public function getUser()
{
@@ -56,9 +57,24 @@ class SessionFingerprint
return null;
}
+ public function hasVerifiedTwoFactor()
+ {
+ if (isset($_SESSION[self::SESSION_INFO_TWO_FACTOR_AUTH_VERIFIED])) {
+ return !empty($_SESSION[self::SESSION_INFO_TWO_FACTOR_AUTH_VERIFIED]);
+ }
+
+ return null;
+ }
+
+ public function setTwoFactorAuthenticationVerified()
+ {
+ $_SESSION[self::SESSION_INFO_TWO_FACTOR_AUTH_VERIFIED] = 1;
+ }
+
public function initialize($userName, $isRemembered = false, $time = null)
{
$_SESSION[self::USER_NAME_SESSION_VAR_NAME] = $userName;
+ $_SESSION[self::SESSION_INFO_TWO_FACTOR_AUTH_VERIFIED] = 0;
$_SESSION[self::SESSION_INFO_SESSION_VAR_NAME] = [
'ts' => $time ?: Date::now()->getTimestampUTC(),
'remembered' => $isRemembered,
@@ -69,6 +85,7 @@ class SessionFingerprint
{
unset($_SESSION[self::USER_NAME_SESSION_VAR_NAME]);
unset($_SESSION[self::SESSION_INFO_SESSION_VAR_NAME]);
+ unset($_SESSION[self::SESSION_INFO_TWO_FACTOR_AUTH_VERIFIED]);
}
public function getSessionStartTime()
diff --git a/core/Session/SessionInitializer.php b/core/Session/SessionInitializer.php
index 8f2a86e2d5..4096c7dc5e 100644
--- a/core/Session/SessionInitializer.php
+++ b/core/Session/SessionInitializer.php
@@ -84,6 +84,11 @@ class SessionInitializer
{
$sessionIdentifier = new SessionFingerprint();
$sessionIdentifier->initialize($authResult->getIdentity(), $this->isRemembered());
+
+ /**
+ * @ignore
+ */
+ Piwik::postEvent('Login.authenticate.processSuccessfulSession.end', array($authResult->getIdentity()));
}
protected function regenerateSessionId()
diff --git a/core/Updater/Migration/Plugin/Deactivate.php b/core/Updater/Migration/Plugin/Deactivate.php
new file mode 100644
index 0000000000..e5ced0a5a9
--- /dev/null
+++ b/core/Updater/Migration/Plugin/Deactivate.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Updater\Migration\Plugin;
+
+use Piwik\Plugin;
+use Piwik\Updater\Migration;
+
+/**
+ * Deactivates the given plugin during the update
+ */
+class Deactivate extends Migration
+{
+ /**
+ * @var string
+ */
+ private $pluginName;
+
+ /**
+ * @var Plugin\Manager
+ */
+ private $pluginManager;
+
+ public function __construct(Plugin\Manager $pluginManager, $pluginName)
+ {
+ $this->pluginManager = $pluginManager;
+ $this->pluginName = $pluginName;
+ }
+
+ public function __toString()
+ {
+ return sprintf('Deactivating plugin "%s"', $this->pluginName);
+ }
+
+ public function shouldIgnoreError($exception)
+ {
+ return true;
+ }
+
+ public function exec()
+ {
+ if ($this->pluginManager->isPluginActivated($this->pluginName)) {
+ $this->pluginManager->deactivatePlugin($this->pluginName);
+ }
+ }
+
+}
diff --git a/core/Updater/Migration/Plugin/Factory.php b/core/Updater/Migration/Plugin/Factory.php
index 95ec6c1458..9b36ca9f4c 100644
--- a/core/Updater/Migration/Plugin/Factory.php
+++ b/core/Updater/Migration/Plugin/Factory.php
@@ -43,4 +43,19 @@ class Factory
'pluginName' => $pluginName
));
}
+
+ /**
+ * Deactivates the given plugin during an update.
+ *
+ * If the plugin is already deactivated or if any other error occurs it will be ignored.
+ *
+ * @param string $pluginName
+ * @return Deactivate
+ */
+ public function deactivate($pluginName)
+ {
+ return $this->container->make('Piwik\Updater\Migration\Plugin\Deactivate', array(
+ 'pluginName' => $pluginName
+ ));
+ }
}
diff --git a/core/Updates/3.7.0-b1.php b/core/Updates/3.7.0-b1.php
index 0d0647ee50..7a27d1b045 100644
--- a/core/Updates/3.7.0-b1.php
+++ b/core/Updates/3.7.0-b1.php
@@ -1,8 +1,8 @@
<?php
/**
- * Piwik - free/libre analytics platform
+ * Matomo - free/libre analytics platform
*
- * @link http://piwik.org
+ * @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/
diff --git a/core/Updates/3.8.0_b3.php b/core/Updates/3.8.0_b3.php
new file mode 100644
index 0000000000..83cc13e4fb
--- /dev/null
+++ b/core/Updates/3.8.0_b3.php
@@ -0,0 +1,60 @@
+<?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\Updates;
+
+use Piwik\Common;
+use Piwik\Db;
+use Piwik\Option;
+use Piwik\Updater;
+use Piwik\Updates as PiwikUpdates;
+use Piwik\Updater\Migration\Factory as MigrationFactory;
+use Piwik\Plugins\UsersManager\Model;
+
+class Updates_3_8_0_b3 extends PiwikUpdates
+{
+ /**
+ * @var MigrationFactory
+ */
+ private $migration;
+
+ public function __construct(MigrationFactory $factory)
+ {
+ $this->migration = $factory;
+ }
+
+ public function getMigrations(Updater $updater)
+ {
+ $userColumn = $this->migration->db->addColumn('user', 'twofactor_secret', "VARCHAR(40) NOT NULL DEFAULT ''");
+ $backupCode = $this->migration->db->createTable('twofactor_recovery_code', array(
+ 'idrecoverycode' => 'BIGINT UNSIGNED NOT NULL AUTO_INCREMENT',
+ 'login' => 'VARCHAR(100) NOT NULL',
+ 'recovery_code' => 'VARCHAR(40) NOT NULL',
+ ), array('idrecoverycode'));
+ $twoFactorAuth = $this->migration->plugin->activate('TwoFactorAuth');
+ $googleAuth = $this->migration->plugin->deactivate('GoogleAuthenticator');
+
+ return array($userColumn, $backupCode, $twoFactorAuth, $googleAuth);
+ }
+
+ public function doUpdate(Updater $updater)
+ {
+ $updater->executeMigrations(__FILE__, $this->getMigrations($updater));
+
+ foreach (Option::getLike('GoogleAuthentication.%') as $name => $value) {
+ $value = @unserialize($value);
+ if (!empty($value['isActive']) && !empty($value['secret'])) {
+ $login = str_replace('GoogleAuthentication.', '', $name);
+
+ $table = Common::prefixTable('user');
+ Db::query("UPDATE $table SET twofactor_secret = ? where login = ?", array($value['secret'], $login));
+ }
+ }
+ }
+}
diff --git a/core/Version.php b/core/Version.php
index 87f492e444..d5dfb53b70 100644
--- a/core/Version.php
+++ b/core/Version.php
@@ -20,7 +20,7 @@ final class Version
* The current Matomo version.
* @var string
*/
- const VERSION = '3.8.0-b2';
+ const VERSION = '3.8.0-b3';
public function isStableVersion($version)
{
diff --git a/lang/en.json b/lang/en.json
index e5e4849000..7444d0f1ed 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -35,6 +35,8 @@
"DoubleClickToChangePeriod": "Double click to apply this period.",
"Close": "Close",
"ClickToSearch": "Click to search",
+ "Copy": "Copy",
+ "Confirm": "Confirm",
"ColumnActionsPerVisit": "Actions per Visit",
"ColumnActionsPerVisitDocumentation": "The average number of actions (page views, site searches, downloads or outlinks) that were performed during the visits.",
"ColumnAverageGenerationTime": "Avg. generation time",
@@ -331,6 +333,7 @@
"Password": "Password",
"Period": "Period",
"Piechart": "Piechart",
+ "Print": "Print",
"Profiles": "Profiles",
"MatomoIsACollaborativeProjectYouCanContributeAndDonateNextRelease": "%1$sMatomo%2$s, formerly known as Piwik, is a collaborative project brought to you by the %7$sMatomo team%8$s members as well as many other contributors around the globe. <br/> If you're a fan of Matomo, you can help: find out %3$sHow to participate in Matomo%4$s, or %5$sdonate now%6$s to help fund the next great Matomo release!",
"PiwikXIsAvailablePleaseNotifyPiwikAdmin": "%1$s is available. Please notify the %2$sMatomo administrator%3$s.",
diff --git a/libs/Authenticator/LICENSE.md b/libs/Authenticator/LICENSE.md
new file mode 100644
index 0000000000..530a37b0ab
--- /dev/null
+++ b/libs/Authenticator/LICENSE.md
@@ -0,0 +1,22 @@
+Copyright (c) 2012, Michael Kliewe All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation and/or
+other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file
diff --git a/libs/Authenticator/README.md b/libs/Authenticator/README.md
new file mode 100644
index 0000000000..fee7a28979
--- /dev/null
+++ b/libs/Authenticator/README.md
@@ -0,0 +1,85 @@
+Google Authenticator PHP class
+==============================
+
+* Copyright (c) 2012-2016, [http://www.phpgangsta.de](http://www.phpgangsta.de)
+* Author: Michael Kliewe, [@PHPGangsta](http://twitter.com/PHPGangsta) and [contributors](https://github.com/PHPGangsta/GoogleAuthenticator/graphs/contributors)
+* Licensed under the BSD License.
+
+[![Build Status](https://travis-ci.org/PHPGangsta/GoogleAuthenticator.png?branch=master)](https://travis-ci.org/PHPGangsta/GoogleAuthenticator)
+
+This PHP class can be used to interact with the Google Authenticator mobile app for 2-factor-authentication. This class
+can generate secrets, generate codes, validate codes and present a QR-Code for scanning the secret. It implements TOTP
+according to [RFC6238](https://tools.ietf.org/html/rfc6238)
+
+For a secure installation you have to make sure that used codes cannot be reused (replay-attack). You also need to
+limit the number of verifications, to fight against brute-force attacks. For example you could limit the amount of
+verifications to 10 tries within 10 minutes for one IP address (or IPv6 block). It depends on your environment.
+
+Usage:
+------
+
+See following example:
+
+```php
+<?php
+require_once 'PHPGangsta/GoogleAuthenticator.php';
+
+$ga = new PHPGangsta_GoogleAuthenticator();
+$secret = $ga->createSecret();
+echo "Secret is: ".$secret."\n\n";
+
+$qrCodeUrl = $ga->getQRCodeGoogleUrl('Blog', $secret);
+echo "Google Charts URL for the QR-Code: ".$qrCodeUrl."\n\n";
+
+$oneCode = $ga->getCode($secret);
+echo "Checking Code '$oneCode' and Secret '$secret':\n";
+
+$checkResult = $ga->verifyCode($secret, $oneCode, 2); // 2 = 2*30sec clock tolerance
+if ($checkResult) {
+ echo 'OK';
+} else {
+ echo 'FAILED';
+}
+```
+Running the script provides the following output:
+```
+Secret is: OQB6ZZGYHCPSX4AK
+
+Google Charts URL for the QR-Code: https://www.google.com/chart?chs=200x200&chld=M|0&cht=qr&chl=otpauth://totp/infoATphpgangsta.de%3Fsecret%3DOQB6ZZGYHCPSX4AK
+
+Checking Code '848634' and Secret 'OQB6ZZGYHCPSX4AK':
+OK
+```
+
+Installation:
+-------------
+
+- Use [Composer](https://getcomposer.org/doc/01-basic-usage.md) to
+ install the package
+
+- From project root directory execute following
+
+```composer install```
+
+- [Composer](https://getcomposer.org/doc/01-basic-usage.md) will take care of autoloading
+ the library. Just include the following at the top of your file
+
+ `require_once __DIR__ . '/../vendor/autoload.php';`
+
+Run Tests:
+----------
+
+- All tests are inside `tests` folder.
+- Execute `composer install` and then run the tests from project root
+ directory
+- Run as `phpunit tests` from the project root directory
+
+
+ToDo:
+-----
+- ??? What do you need?
+
+Notes:
+------
+
+If you like this script or have some features to add: contact me, visit my blog, fork this project, send pull requests, you know how it works. \ No newline at end of file
diff --git a/libs/Authenticator/TwoFactorAuthenticator.php b/libs/Authenticator/TwoFactorAuthenticator.php
new file mode 100644
index 0000000000..fc0b962856
--- /dev/null
+++ b/libs/Authenticator/TwoFactorAuthenticator.php
@@ -0,0 +1,195 @@
+<?php
+/**
+ * PHP Class for handling Google Authenticator 2-factor authentication
+ *
+ * @author Michael Kliewe
+ * @copyright 2012 Michael Kliewe
+ * @license http://www.opensource.org/licenses/bsd-license.php BSD License
+ * @link http://www.phpgangsta.de/
+ *
+ * small adjustments by @sgiehl / matomo.org
+ * - renamed class
+ * - removed method getQRCodeGoogleUrl
+ */
+
+class TwoFactorAuthenticator
+{
+ protected $_codeLength = 6;
+
+ /**
+ * Create new secret.
+ * 16 characters, randomly chosen from the allowed base32 characters.
+ *
+ * @param int $secretLength
+ * @return string
+ */
+ public function createSecret($secretLength = 16)
+ {
+ $validChars = $this->_getBase32LookupTable();
+ unset($validChars[32]);
+
+ $secret = '';
+ for ($i = 0; $i < $secretLength; $i++) {
+ $secret .= $validChars[array_rand($validChars)];
+ }
+ return $secret;
+ }
+
+ /**
+ * Calculate the code, with given secret and point in time
+ *
+ * @param string $secret
+ * @param int|null $timeSlice
+ * @return string
+ */
+ public function getCode($secret, $timeSlice = null)
+ {
+ if ($timeSlice === null) {
+ $timeSlice = floor(time() / 30);
+ }
+
+ $secretkey = $this->_base32Decode($secret);
+
+ // Pack time into binary string
+ $time = chr(0).chr(0).chr(0).chr(0).pack('N*', $timeSlice);
+ // Hash it with users secret key
+ $hm = hash_hmac('SHA1', $time, $secretkey, true);
+ // Use last nipple of result as index/offset
+ $offset = ord(substr($hm, -1)) & 0x0F;
+ // grab 4 bytes of the result
+ $hashpart = substr($hm, $offset, 4);
+
+ // Unpak binary value
+ $value = unpack('N', $hashpart);
+ $value = $value[1];
+ // Only 32 bits
+ $value = $value & 0x7FFFFFFF;
+
+ $modulo = pow(10, $this->_codeLength);
+ return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT);
+ }
+
+ /**
+ * Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now
+ *
+ * @param string $secret
+ * @param string $code
+ * @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
+ * @param int|null $currentTimeSlice time slice if we want use other that time()
+ * @return bool
+ */
+ public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null)
+ {
+ if ($currentTimeSlice === null) {
+ $currentTimeSlice = floor(time() / 30);
+ }
+
+ for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
+ $calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
+ if ($calculatedCode == $code ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the code length, should be >=6
+ *
+ * @param int $length
+ * @return self
+ */
+ public function setCodeLength($length)
+ {
+ $this->_codeLength = $length;
+ return $this;
+ }
+
+ /**
+ * Helper class to decode base32
+ *
+ * @param $secret
+ * @return bool|string
+ */
+ protected function _base32Decode($secret)
+ {
+ if (empty($secret)) return '';
+
+ $base32chars = $this->_getBase32LookupTable();
+ $base32charsFlipped = array_flip($base32chars);
+
+ $paddingCharCount = substr_count($secret, $base32chars[32]);
+ $allowedValues = array(6, 4, 3, 1, 0);
+ if (!in_array($paddingCharCount, $allowedValues)) return false;
+ for ($i = 0; $i < 4; $i++){
+ if ($paddingCharCount == $allowedValues[$i] &&
+ substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) return false;
+ }
+ $secret = str_replace('=','', $secret);
+ $secret = str_split($secret);
+ $binaryString = "";
+ for ($i = 0; $i < count($secret); $i = $i+8) {
+ $x = "";
+ if (!in_array($secret[$i], $base32chars)) return false;
+ for ($j = 0; $j < 8; $j++) {
+ $x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
+ }
+ $eightBits = str_split($x, 8);
+ for ($z = 0; $z < count($eightBits); $z++) {
+ $binaryString .= ( ($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48 ) ? $y:"";
+ }
+ }
+ return $binaryString;
+ }
+
+ /**
+ * Helper class to encode base32
+ *
+ * @param string $secret
+ * @param bool $padding
+ * @return string
+ */
+ protected function _base32Encode($secret, $padding = true)
+ {
+ if (empty($secret)) return '';
+
+ $base32chars = $this->_getBase32LookupTable();
+
+ $secret = str_split($secret);
+ $binaryString = "";
+ for ($i = 0; $i < count($secret); $i++) {
+ $binaryString .= str_pad(base_convert(ord($secret[$i]), 10, 2), 8, '0', STR_PAD_LEFT);
+ }
+ $fiveBitBinaryArray = str_split($binaryString, 5);
+ $base32 = "";
+ $i = 0;
+ while ($i < count($fiveBitBinaryArray)) {
+ $base32 .= $base32chars[base_convert(str_pad($fiveBitBinaryArray[$i], 5, '0'), 2, 10)];
+ $i++;
+ }
+ if ($padding && ($x = strlen($binaryString) % 40) != 0) {
+ if ($x == 8) $base32 .= str_repeat($base32chars[32], 6);
+ elseif ($x == 16) $base32 .= str_repeat($base32chars[32], 4);
+ elseif ($x == 24) $base32 .= str_repeat($base32chars[32], 3);
+ elseif ($x == 32) $base32 .= $base32chars[32];
+ }
+ return $base32;
+ }
+
+ /**
+ * Get array with all 32 characters for decoding from/encoding to base32
+ *
+ * @return array
+ */
+ protected function _getBase32LookupTable()
+ {
+ return array(
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
+ 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
+ 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
+ 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
+ '=' // padding char
+ );
+ }
+} \ No newline at end of file
diff --git a/plugins/CorePluginsAdmin/tests/UI/expected-screenshots/TagManagerTeaser_admin_page_disable.png b/plugins/CorePluginsAdmin/tests/UI/expected-screenshots/TagManagerTeaser_admin_page_disable.png
index 24a7d3cb68..39dea08b34 100644
--- a/plugins/CorePluginsAdmin/tests/UI/expected-screenshots/TagManagerTeaser_admin_page_disable.png
+++ b/plugins/CorePluginsAdmin/tests/UI/expected-screenshots/TagManagerTeaser_admin_page_disable.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7453fe81bd81010d6b8b535a63f520eddd9463d2a011df876be2c5ff5aaf41af
-size 139856
+oid sha256:d90a8beea55755cd1e93b0da9cad8f73dc137afccc12b6b69f63420234fcd437
+size 140488
diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_removed.png b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_removed.png
index a1a3e8e7d3..a6b86d651d 100644
--- a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_removed.png
+++ b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_removed.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4409dd3934dc0fc34be076b60333c05e2b0ad8117cc68f4ec3219f22cc0605b5
-size 437945
+oid sha256:f7d9e6e1415904dd15b54c4f4a5fd8bbe78f168a62a80e085ef7dafb8e11ee24
+size 438044
diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_invalid_token_auth.png b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_invalid_token_auth.png
index b8ccaea933..514e68a52d 100644
--- a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_invalid_token_auth.png
+++ b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_invalid_token_auth.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:be6e03775614fcb2561fa6ee76452b212963e488472f4777a2477a47545241ad
-size 33304
+oid sha256:a2882f7effbf816a911ff31ab4880f0866db99b68f67f93334e748309f03ffda
+size 35846
diff --git a/plugins/Login/Controller.php b/plugins/Login/Controller.php
index a69a7b9ce8..939a160e96 100644
--- a/plugins/Login/Controller.php
+++ b/plugins/Login/Controller.php
@@ -12,7 +12,7 @@ use Exception;
use Piwik\Common;
use Piwik\Config;
use Piwik\Container\StaticContainer;
-use Piwik\Cookie;
+use Piwik\Date;
use Piwik\Log;
use Piwik\Nonce;
use Piwik\Piwik;
@@ -44,13 +44,19 @@ class Controller extends \Piwik\Plugin\Controller
protected $sessionInitializer;
/**
+ * @var PasswordVerifier
+ */
+ protected $passwordVerify;
+
+ /**
* Constructor.
*
* @param PasswordResetter $passwordResetter
* @param AuthInterface $auth
* @param SessionInitializer $authenticatedSessionFactory
+ * @param PasswordVerifier $passwordVerify
*/
- public function __construct($passwordResetter = null, $auth = null, $sessionInitializer = null)
+ public function __construct($passwordResetter = null, $auth = null, $sessionInitializer = null, $passwordVerify = null)
{
parent::__construct();
@@ -64,6 +70,11 @@ class Controller extends \Piwik\Plugin\Controller
}
$this->auth = $auth;
+ if (empty($passwordVerify)) {
+ $passwordVerify = StaticContainer::get('Piwik\Plugins\Login\PasswordVerifier');
+ }
+ $this->passwordVerify = $passwordVerify;
+
if (empty($sessionInitializer)) {
$sessionInitializer = new \Piwik\Session\SessionInitializer();
}
@@ -148,6 +159,51 @@ class Controller extends \Piwik\Plugin\Controller
$view->nonce = Nonce::getNonce('Login.login');
}
+ public function confirmPassword()
+ {
+ Piwik::checkUserIsNotAnonymous();
+ Piwik::checkUserHasSomeViewAccess();
+
+ if (!$this->passwordVerify->hasPasswordVerifyBeenRequested()) {
+ throw new Exception('Not available');
+ }
+
+ if (!Url::isValidHost()) {
+ throw new Exception("Cannot confirm password with untrusted hostname!");
+ }
+
+ $nonceKey = 'confirmPassword';
+ $messageNoAccess = '';
+ if (!empty($_POST)) {
+ $nonce = Common::getRequestVar('nonce', null, 'string', $_POST);
+ if (!Nonce::verifyNonce($nonceKey, $nonce)) {
+ $messageNoAccess = $this->getMessageExceptionNoAccess();
+ } elseif ($this->verifyPasswordCorrect()) {
+ $this->passwordVerify->setPasswordVerifiedCorrectly();
+ return;
+ } else {
+ $messageNoAccess = Piwik::translate('Login_WrongPasswordEntered');
+ }
+ }
+
+ return $this->renderTemplate('confirmPassword', array(
+ 'nonce' => Nonce::getNonce($nonceKey),
+ 'AccessErrorString' => $messageNoAccess
+ ));
+ }
+
+ private function verifyPasswordCorrect()
+ {
+ /** @var \Piwik\Auth $authAdapter */
+ $authAdapter = StaticContainer::get('Piwik\Auth');
+ $authAdapter->setLogin(Piwik::getCurrentUserLogin());
+ $authAdapter->setPasswordHash(null);// ensure authentication happens on password
+ $authAdapter->setPassword(Common::getRequestVar('password', null, 'string', $_POST));
+ $authAdapter->setTokenAuth(null);// ensure authentication happens on password
+ $authResult = $authAdapter->authenticate();
+ return $authResult->wasAuthenticationSuccessful();
+ }
+
/**
* Form-less login
* @see how to use it on http://piwik.org/faq/how-to/#faq_30
diff --git a/plugins/Login/Login.php b/plugins/Login/Login.php
index bd0d9ae253..bc1f0bd302 100644
--- a/plugins/Login/Login.php
+++ b/plugins/Login/Login.php
@@ -9,13 +9,16 @@
namespace Piwik\Plugins\Login;
use Exception;
+use Piwik\API\Request;
use Piwik\Common;
use Piwik\Config;
use Piwik\Container\StaticContainer;
use Piwik\Cookie;
+use Piwik\Date;
use Piwik\FrontController;
use Piwik\Piwik;
use Piwik\Session;
+use Piwik\Url;
/**
*
@@ -23,7 +26,7 @@ use Piwik\Session;
class Login extends \Piwik\Plugin
{
/**
- * @see Piwik\Plugin::registerEvents
+ * @see \Piwik\Plugin::registerEvents
*/
public function registerEvents()
{
diff --git a/plugins/Login/PasswordVerifier.php b/plugins/Login/PasswordVerifier.php
new file mode 100644
index 0000000000..b3909eb71c
--- /dev/null
+++ b/plugins/Login/PasswordVerifier.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\Login;
+
+use Piwik\Date;
+use Piwik\Piwik;
+use Piwik\Session\SessionNamespace;
+use Piwik\Url;
+
+class PasswordVerifier
+{
+ const VERIFY_VALID_FOR_MINUTES = 30;
+ const VERIFY_REVALIDATE_X_MINUTES_LEFT = 15;
+
+ /**
+ * @var Date|null
+ */
+ private $now;
+ private $enableRedirect = true;
+
+ /**
+ * @ignore
+ * tests only
+ */
+ public function setDisableRedirect()
+ {
+ $this->enableRedirect = false;
+ }
+
+ private function getLoginSession()
+ {
+ return new SessionNamespace('Login');
+ }
+
+ public function hasPasswordVerifyBeenRequested()
+ {
+ $sessionNamespace = $this->getLoginSession();
+ return !empty($sessionNamespace->redirectParams);
+ }
+
+ public function forgetVerifiedPassword()
+ {
+ // call this method if you want the user to enter the password again after some action was finished which needed
+ // the password
+ $sessionNamespace = $this->getLoginSession();
+ unset($sessionNamespace->lastPasswordAuth);
+ unset($sessionNamespace->redirectParams);
+ }
+
+ /**
+ * @param Date $now
+ * @ignore
+ * tests only
+ */
+ public function setNow(Date $now)
+ {
+ $this->now = $now;
+ }
+
+ private function getNow()
+ {
+ if ($this->now) {
+ return $this->now;
+ }
+ return Date::now();
+ }
+
+ public function setPasswordVerifiedCorrectly()
+ {
+ $sessionNamespace = $this->getLoginSession();
+ $sessionNamespace->lastPasswordAuth = $this->getNow()->getDatetime();
+ $sessionNamespace->setExpirationSeconds(self::VERIFY_VALID_FOR_MINUTES * 60, 'lastPasswordAuth');
+ $sessionNamespace->setExpirationSeconds(self::VERIFY_VALID_FOR_MINUTES * 60, 'redirectParams');
+
+ if ($this->enableRedirect) {
+ Url::redirectToUrl('index.php' . Url::getCurrentQueryStringWithParametersModified(
+ $sessionNamespace->redirectParams
+ ));
+ }
+ }
+
+ public function hasBeenVerified()
+ {
+ $lastAuthValidTo = $this->getPasswordVerifyValidUpToDateIfVerified();
+ $now = $this->getNow();
+
+ if ($lastAuthValidTo && $now->isEarlier($lastAuthValidTo)) {
+ return true;
+ }
+ return false;
+ }
+
+ private function getPasswordVerifyValidUpToDateIfVerified()
+ {
+ $sessionNamespace = $this->getLoginSession();
+ if (!empty($sessionNamespace->lastPasswordAuth) && !empty($sessionNamespace->redirectParams)) {
+ $lastAuthValidTo = Date::factory($sessionNamespace->lastPasswordAuth)->addPeriod(self::VERIFY_VALID_FOR_MINUTES, 'minute');
+ return $lastAuthValidTo;
+ }
+ }
+
+ protected function hasBeenVerifiedAndHalfTimeValid()
+ {
+ $lastAuthValidTo = $this->getPasswordVerifyValidUpToDateIfVerified();
+ $now = $this->getNow()->addPeriod(self::VERIFY_REVALIDATE_X_MINUTES_LEFT, 'minute');
+
+ if ($lastAuthValidTo && $now->isEarlier($lastAuthValidTo)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Checks if the user has verified the password within the last 15 minutes. If not, the user will be redirected.
+ * The password verify will be valid for at least another 15 minutes giving the user some time to perform an action.
+ * See {@link requirePasswordVerified}
+ *
+ * @param $redirectParams
+ * @return true if password has been verified recently, will redirect if not
+ * @throws \Zend_Session_Exception
+ */
+ public function requirePasswordVerifiedRecently($redirectParams)
+ {
+ if ($this->hasBeenVerifiedAndHalfTimeValid()) {
+ return true;
+ }
+
+ $this->initiatePasswordVerifyRedirect($redirectParams);
+ }
+
+ /**
+ * Checks if the user has verified the password within the last 30 minutes. If not, the user will be redirected.
+ * Please note that if the user performs an action afterwards, the password verify could be valid for only few more
+ * seconds or minutes and by the time the user confirms a certain action, the password verify may no longer be valid.
+ * If you want to ensure the password will be still valid for eg 15 minutes before the user performs some action,
+ * consider using {@link requirePasswordVerifiedRecently}.
+ *
+ * @param $redirectParams
+ * @return true if password has been verified, will redirect if not
+ * @throws \Zend_Session_Exception
+ */
+ public function requirePasswordVerified($redirectParams)
+ {
+ if ($this->hasBeenVerified()) {
+ return true;
+ }
+
+ $this->initiatePasswordVerifyRedirect($redirectParams);
+ }
+
+ private function initiatePasswordVerifyRedirect($redirectParams)
+ {
+ $sessionNamespace = $this->getLoginSession();
+ $sessionNamespace->redirectParams = $redirectParams;
+ $sessionNamespace->setExpirationSeconds(self::VERIFY_VALID_FOR_MINUTES * 60 * 5, 'redirectParams');
+
+ if ($this->enableRedirect) {
+ Piwik::redirectToModule('Login', 'confirmPassword');
+ }
+ }
+}
diff --git a/plugins/Login/lang/en.json b/plugins/Login/lang/en.json
index 48f06f20b0..314bb38293 100644
--- a/plugins/Login/lang/en.json
+++ b/plugins/Login/lang/en.json
@@ -20,6 +20,8 @@
"PasswordChanged": "Your password has been changed.",
"PasswordRepeat": "Password (repeat)",
"PasswordsDoNotMatch": "Passwords do not match.",
+ "WrongPasswordEntered": "Please enter your correct password.",
+ "ConfirmPasswordToContinue": "Confirm your password to continue",
"PluginDescription": "Provides authentication via username and password as well as password reset functionality. Authentication method can be changed by using another Login plugin such as LoginLdap available on the Marketplace.",
"RememberMe": "Remember Me"
}
diff --git a/plugins/Login/templates/confirmPassword.twig b/plugins/Login/templates/confirmPassword.twig
new file mode 100644
index 0000000000..14db8f1113
--- /dev/null
+++ b/plugins/Login/templates/confirmPassword.twig
@@ -0,0 +1,43 @@
+{% extends '@Login/loginLayout.twig' %}
+
+{% set title %}{{ 'Login_ConfirmPasswordToContinue'|translate }}{% endset %}
+
+{% block loginContent %}
+ <div class="contentForm loginForm confirmPasswordForm">
+ {% embed 'contentBlock.twig' with {'title': ('Login_ConfirmPasswordToContinue'|translate)} %}
+ {% block content %}
+
+ <div class="message_container">
+ {% if AccessErrorString %}
+ <div piwik-notification
+ noclear="true"
+ context="error">
+ <strong>{{ 'General_Error'|translate }}</strong>: {{ AccessErrorString|raw }}<br/>
+ </div>
+ {% endif %}
+ </div>
+
+ <form action="{{ linkTo({'module': 'Login', 'action': 'confirmPassword'}) }}" ng-non-bindable method="post">
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="hidden" name="nonce" id="login_form_nonce" value="{{ nonce }}"/>
+ <input type="password" placeholder="" name="password" id="login_form_password" class="input" value="" size="20"
+ autocorrect="off" autocapitalize="none"
+ tabindex="20" />
+ <label for="login_form_password"><i class="icon-locked icon"></i> {{ 'General_Password'|translate }}</label>
+ </div>
+ </div>
+
+ <div class="row actions">
+ <div class="col s12">
+ <input class="submit btn" id='login_form_submit' type="submit" value="{{ 'General_Confirm'|translate }}"
+ tabindex="100"/>
+ </div>
+ </div>
+
+ </form>
+ {% endblock %}
+ {% endembed %}
+ </div>
+
+{% endblock %} \ No newline at end of file
diff --git a/plugins/Login/templates/login.twig b/plugins/Login/templates/login.twig
index abe2e2ae46..875d5e6818 100644
--- a/plugins/Login/templates/login.twig
+++ b/plugins/Login/templates/login.twig
@@ -1,170 +1,130 @@
-{% extends '@Morpheus/layout.twig' %}
-{% block meta %}
- <meta name="robots" content="index,follow">
-{% endblock %}
+{% extends '@Login/loginLayout.twig' %}
-{% block head %}
- {{ parent() }}
+{% block loginContent %}
+ <div class="contentForm loginForm">
+ {% embed 'contentBlock.twig' with {'title': 'Login_LogIn'|translate} %}
+ {% block content %}
+ <div class="message_container">
- <script type="text/javascript" src="libs/bower_components/jquery-placeholder/jquery.placeholder.js"></script>
-{% endblock %}
+ {{ include('@Login/_formErrors.twig', {formErrors: form_data.errors } ) }}
-{% set title %}{{ 'Login_LogIn'|translate }}{% endset %}
-
-{% block pageDescription %}{{ 'General_OpenSourceWebAnalytics'|translate }}{% endblock %}
-
-{% set bodyId = 'loginPage' %}
-
-{% block body %}
-
- {{ postEvent("Template.beforeTopBar", "login") }}
- {{ postEvent("Template.beforeContent", "login") }}
-
- {% include "_iframeBuster.twig" %}
-
- <div id="notificationContainer">
- </div>
- <nav>
- <div class="nav-wrapper">
- {% include "@CoreHome/_logo.twig" with { 'logoLink': 'https://matomo.org', 'centeredLogo': true, 'useLargeLogo': false } %}
- </div>
- </nav>
-
- <section class="loginSection row">
- <div class="col s12 m6 push-m3 l4 push-l4">
-
- {# untrusted host warning #}
- {% if (isValidHost is defined and invalidHostMessage is defined and isValidHost == false) %}
- {% include '@CoreHome/_warningInvalidHost.twig' %}
- {% else %}
- <div class="contentForm loginForm">
- {% embed 'contentBlock.twig' with {'title': 'Login_LogIn'|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 %}
-
- {% if infoMessage %}
- <p class="message">{{ infoMessage|raw }}</p>
- {% endif %}
- </div>
+ {% if AccessErrorString %}
+ <div piwik-notification
+ noclear="true"
+ context="error">
+ <strong>{{ 'General_Error'|translate }}</strong>: {{ AccessErrorString|raw }}<br/>
+ </div>
+ {% endif %}
- <form {{ form_data.attributes|raw }} ng-non-bindable>
- <div class="row">
- <div class="col s12 input-field">
- <input type="text" name="form_login" placeholder="" id="login_form_login" class="input" value="" size="20"
- autocorrect="off" autocapitalize="none"
- tabindex="10" autofocus="autofocus"/>
- <label for="login_form_login"><i class="icon-user icon"></i> {{ 'Login_LoginOrEmail'|translate }}</label>
- </div>
+ {% if infoMessage %}
+ <p class="message">{{ infoMessage|raw }}</p>
+ {% endif %}
+ </div>
+
+ <form {{ form_data.attributes|raw }} ng-non-bindable>
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="text" name="form_login" placeholder="" id="login_form_login" class="input" value="" size="20"
+ autocorrect="off" autocapitalize="none"
+ tabindex="10" autofocus="autofocus"/>
+ <label for="login_form_login"><i class="icon-user icon"></i> {{ 'Login_LoginOrEmail'|translate }}</label>
</div>
+ </div>
- <div class="row">
- <div class="col s12 input-field">
- <input type="hidden" name="form_nonce" id="login_form_nonce" value="{{ nonce }}"/>
- <input type="password" placeholder="" name="form_password" id="login_form_password" class="input" value="" size="20"
- autocorrect="off" autocapitalize="none"
- tabindex="20" />
- <label for="login_form_password"><i class="icon-locked icon"></i> {{ 'General_Password'|translate }}</label>
- </div>
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="hidden" name="form_nonce" id="login_form_nonce" value="{{ nonce }}"/>
+ <input type="password" placeholder="" name="form_password" id="login_form_password" class="input" value="" size="20"
+ autocorrect="off" autocapitalize="none"
+ tabindex="20" />
+ <label for="login_form_password"><i class="icon-locked icon"></i> {{ 'General_Password'|translate }}</label>
</div>
+ </div>
- <div class="row actions">
- <div class="col s12">
- <input name="form_rememberme" type="checkbox" id="login_form_rememberme" value="1" tabindex="90"
- {% if form_data.form_rememberme.value %}checked="checked" {% endif %}/>
- <label for="login_form_rememberme">{{ 'Login_RememberMe'|translate }}</label>
- <input class="submit btn" id='login_form_submit' type="submit" value="{{ 'Login_LogIn'|translate }}"
- tabindex="100"/>
- </div>
+ <div class="row actions">
+ <div class="col s12">
+ <input name="form_rememberme" type="checkbox" id="login_form_rememberme" value="1" tabindex="90"
+ {% if form_data.form_rememberme.value %}checked="checked" {% endif %}/>
+ <label for="login_form_rememberme">{{ 'Login_RememberMe'|translate }}</label>
+ <input class="submit btn" id='login_form_submit' type="submit" value="{{ 'Login_LogIn'|translate }}"
+ tabindex="100"/>
</div>
+ </div>
- </form>
- <p id="nav">
- {{ postEvent("Template.loginNav", "top") }}
- <a id="login_form_nav" href="#"
- title="{{ 'Login_LostYourPassword'|translate }}">{{ 'Login_LostYourPassword'|translate }}</a>
- {{ postEvent("Template.loginNav", "bottom") }}
+ </form>
+ <p id="nav">
+ {{ postEvent("Template.loginNav", "top") }}
+ <a id="login_form_nav" href="#"
+ title="{{ 'Login_LostYourPassword'|translate }}">{{ 'Login_LostYourPassword'|translate }}</a>
+ {{ postEvent("Template.loginNav", "bottom") }}
+ </p>
+
+ {% if isCustomLogo %}
+ <p id="piwik">
+ <i><a href="https://matomo.org/" rel="noreferrer noopener" target="_blank">{{ linkTitle }}</a></i>
</p>
+ {% endif %}
- {% if isCustomLogo %}
- <p id="piwik">
- <i><a href="https://matomo.org/" rel="noreferrer noopener" target="_blank">{{ linkTitle }}</a></i>
- </p>
- {% endif %}
-
- {% endblock %}
- {% endembed %}
- </div>
- <div class="contentForm resetForm" style="display:none;">
- {% embed 'contentBlock.twig' with {'title': 'Login_ChangeYourPassword'|translate} %}
- {% block content %}
-
- <div class="message_container">
- </div>
-
- <form id="reset_form" method="post" ng-non-bindable>
- <div class="row">
- <div class="col s12 input-field">
- <input type="hidden" name="form_nonce" id="reset_form_nonce" value="{{ nonce }}"/>
- <input type="text" placeholder="" name="form_login" id="reset_form_login" class="input" value="" size="20"
- autocorrect="off" autocapitalize="none"
- tabindex="10"/>
- <label for="reset_form_login"><i class="icon-user icon"></i> {{ 'Login_LoginOrEmail'|translate }}</label>
- </div>
+ {% endblock %}
+ {% endembed %}
+ </div>
+ <div class="contentForm resetForm" style="display:none;">
+ {% embed 'contentBlock.twig' with {'title': 'Login_ChangeYourPassword'|translate} %}
+ {% block content %}
+
+ <div class="message_container">
+ </div>
+
+ <form id="reset_form" method="post" ng-non-bindable>
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="hidden" name="form_nonce" id="reset_form_nonce" value="{{ nonce }}"/>
+ <input type="text" placeholder="" name="form_login" id="reset_form_login" class="input" value="" size="20"
+ autocorrect="off" autocapitalize="none"
+ tabindex="10"/>
+ <label for="reset_form_login"><i class="icon-user icon"></i> {{ 'Login_LoginOrEmail'|translate }}</label>
</div>
- <div class="row">
- <div class="col s12 input-field">
- <input type="password" placeholder="" name="form_password" id="reset_form_password" class="input" value="" size="20"
- autocorrect="off" autocapitalize="none"
- tabindex="20" autocomplete="off"/>
- <label for="reset_form_password"><i class="icon-locked icon"></i> {{ 'Login_NewPassword'|translate }}</label>
- </div>
+ </div>
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="password" placeholder="" name="form_password" id="reset_form_password" class="input" value="" size="20"
+ autocorrect="off" autocapitalize="none"
+ tabindex="20" autocomplete="off"/>
+ <label for="reset_form_password"><i class="icon-locked icon"></i> {{ 'Login_NewPassword'|translate }}</label>
</div>
- <div class="row">
- <div class="col s12 input-field">
- <input type="password" placeholder="" name="form_password_bis" id="reset_form_password_bis" class="input" value=""
- autocorrect="off" autocapitalize="none"
- size="20" tabindex="30" autocomplete="off"/>
- <label for="reset_form_password_bis"><i class="icon-locked icon"></i> {{ 'Login_NewPasswordRepeat'|translate }}</label>
- </div>
+ </div>
+ <div class="row">
+ <div class="col s12 input-field">
+ <input type="password" placeholder="" name="form_password_bis" id="reset_form_password_bis" class="input" value=""
+ autocorrect="off" autocapitalize="none"
+ size="20" tabindex="30" autocomplete="off"/>
+ <label for="reset_form_password_bis"><i class="icon-locked icon"></i> {{ 'Login_NewPasswordRepeat'|translate }}</label>
</div>
+ </div>
- <div class="row actions">
- <div class="col s12">
- <input class="submit btn" id='reset_form_submit' type="submit"
- value="{{ 'General_ChangePassword'|translate }}" tabindex="100"/>
+ <div class="row actions">
+ <div class="col s12">
+ <input class="submit btn" id='reset_form_submit' type="submit"
+ value="{{ 'General_ChangePassword'|translate }}" tabindex="100"/>
- <span class="loadingPiwik" style="display:none;">
- <img alt="Loading" src="plugins/Morpheus/images/loading-blue.gif"/>
- </span>
- </div>
+ <span class="loadingPiwik" style="display:none;">
+ <img alt="Loading" src="plugins/Morpheus/images/loading-blue.gif"/>
+ </span>
</div>
+ </div>
- <input type="hidden" name="module" value="{{ loginModule }}"/>
- <input type="hidden" name="action" value="resetPassword"/>
- </form>
- <p id="nav">
- <a id="reset_form_nav" href="#"
- title="{{ 'Mobile_NavigationBack'|translate }}">{{ 'General_Cancel'|translate }}</a>
- <a id="alternate_reset_nav" href="#" style="display:none;"
- title="{{'Login_LogIn'|translate}}">{{ 'Login_LogIn'|translate }}</a>
- </p>
- {% endblock %}
- {% endembed %}
- </div>
- {% endif %}
-
- </section>
+ <input type="hidden" name="module" value="{{ loginModule }}"/>
+ <input type="hidden" name="action" value="resetPassword"/>
+ </form>
+ <p id="nav">
+ <a id="reset_form_nav" href="#"
+ title="{{ 'Mobile_NavigationBack'|translate }}">{{ 'General_Cancel'|translate }}</a>
+ <a id="alternate_reset_nav" href="#" style="display:none;"
+ title="{{'Login_LogIn'|translate}}">{{ 'Login_LogIn'|translate }}</a>
+ </p>
+ {% endblock %}
+ {% endembed %}
+ </div>
-{% endblock %}
+{% endblock %} \ No newline at end of file
diff --git a/plugins/Login/templates/loginLayout.twig b/plugins/Login/templates/loginLayout.twig
new file mode 100644
index 0000000000..837d3d3fd5
--- /dev/null
+++ b/plugins/Login/templates/loginLayout.twig
@@ -0,0 +1,48 @@
+{% extends '@Morpheus/layout.twig' %}
+
+{% block meta %}
+ <meta name="robots" content="index,follow">
+{% endblock %}
+
+{% block head %}
+ {{ parent() }}
+
+ <script type="text/javascript" src="libs/bower_components/jquery-placeholder/jquery.placeholder.js"></script>
+{% endblock %}
+
+{% set title %}{{ 'Login_LogIn'|translate }}{% endset %}
+
+{% block pageDescription %}{{ 'General_OpenSourceWebAnalytics'|translate }}{% endblock %}
+
+{% set bodyId = 'loginPage' %}
+
+{% block body %}
+
+ {{ postEvent("Template.beforeTopBar", "login") }}
+ {{ postEvent("Template.beforeContent", "login") }}
+
+ {% include "_iframeBuster.twig" %}
+
+ <div id="notificationContainer">
+ </div>
+ <nav>
+ <div class="nav-wrapper">
+ {% include "@CoreHome/_logo.twig" with { 'logoLink': 'https://matomo.org', 'centeredLogo': true, 'useLargeLogo': false } %}
+ </div>
+ </nav>
+
+ <section class="loginSection row">
+ <div class="col s12 m6 push-m3 l4 push-l4">
+
+ {# untrusted host warning #}
+ {% if (isValidHost is defined and invalidHostMessage is defined and isValidHost == false) %}
+ {% include '@CoreHome/_warningInvalidHost.twig' %}
+ {% else %}
+ {% block loginContent %}
+ {% endblock %}
+ {% endif %}
+
+ </div>
+ </section>
+
+{% endblock %}
diff --git a/plugins/Login/tests/Integration/PasswordVerifierTest.php b/plugins/Login/tests/Integration/PasswordVerifierTest.php
new file mode 100644
index 0000000000..a169e5e723
--- /dev/null
+++ b/plugins/Login/tests/Integration/PasswordVerifierTest.php
@@ -0,0 +1,132 @@
+<?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\Login\tests\Integration;
+
+use Piwik\Date;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+use Piwik\Plugins\Login\PasswordVerifier;
+
+class CustomPasswordVerifier extends PasswordVerifier {
+ public function hasBeenVerifiedAndHalfTimeValid()
+ {
+ return parent::hasBeenVerifiedAndHalfTimeValid();
+ }
+}
+
+class PasswordVerifierTest extends IntegrationTestCase
+{
+
+ /**
+ * @var CustomPasswordVerifier
+ */
+ private $verifier;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->verifier = new CustomPasswordVerifier();
+ $this->verifier->setDisableRedirect();
+ }
+
+ public function test_hasBeenVerified_byDefaultNotVerified()
+ {
+ $this->assertFalse($this->verifier->hasBeenVerified());
+ }
+
+ public function test_hasBeenVerifiedAndHalfTimeValid_byDefaultNotVerified()
+ {
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ }
+
+ public function test_hasPasswordVerifyBeenRequested_byDefaultNotRequested()
+ {
+ $this->assertFalse($this->verifier->hasPasswordVerifyBeenRequested());
+ }
+
+ public function test_requirePasswordVerifiedRecently()
+ {
+ $this->assertNull($this->requirePasswordVerify());
+ $this->assertTrue($this->verifier->hasPasswordVerifyBeenRequested());
+ $this->assertFalse($this->verifier->hasBeenVerified());
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ }
+
+ public function test_setPasswordVerifiedCorrectly()
+ {
+ $this->assertNull($this->requirePasswordVerify());
+ $this->assertFalse($this->verifier->hasBeenVerified());
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+
+ $this->verifier->setPasswordVerifiedCorrectly();
+
+ $this->assertTrue($this->verifier->hasBeenVerified());
+ $this->assertTrue($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ $this->assertTrue($this->requirePasswordVerify()); // no need to redirect
+ }
+
+ public function test_setPasswordVerifiedCorrectly_requiresAPasswordToBeRequestedToBeValid()
+ {
+ $this->verifier->setPasswordVerifiedCorrectly();
+
+ $this->assertFalse($this->verifier->hasBeenVerified());
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ $this->assertNull($this->requirePasswordVerify());
+ }
+
+ public function test_setPasswordVerifiedCorrectly_expiresAfter15Min()
+ {
+ $this->assertNull($this->requirePasswordVerify());
+ $this->assertFalse($this->verifier->hasBeenVerified());
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+
+ $this->verifier->setPasswordVerifiedCorrectly();
+
+ $this->assertTrue($this->verifier->hasBeenVerified());
+ $this->assertTrue($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ $this->assertTrue($this->requirePasswordVerify()); // no need to redirect
+
+ $this->verifier->setNow(Date::now()->addPeriod(PasswordVerifier::VERIFY_REVALIDATE_X_MINUTES_LEFT - 1, 'minutes'));
+
+ $this->assertTrue($this->verifier->hasBeenVerified());
+ $this->assertTrue($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ $this->assertTrue($this->requirePasswordVerify()); // no need to redirect
+
+ $this->verifier->setNow(Date::now()->addPeriod(PasswordVerifier::VERIFY_REVALIDATE_X_MINUTES_LEFT + 1, 'minutes'));
+
+ $this->assertTrue($this->verifier->hasBeenVerified()); // it was verified recently
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ $this->assertNull($this->requirePasswordVerify()); // no need to redirect
+
+ $this->verifier->setNow(Date::now()->addPeriod(PasswordVerifier::VERIFY_VALID_FOR_MINUTES + 1, 'minutes'));
+
+ $this->assertFalse($this->verifier->hasBeenVerified()); // it was verified recently
+ $this->assertFalse($this->verifier->hasBeenVerifiedAndHalfTimeValid());
+ $this->assertNull($this->requirePasswordVerify()); // no need to redirect
+ }
+
+ public function test_forgetVerifiedPassword()
+ {
+ $this->requirePasswordVerify();
+ $this->verifier->setPasswordVerifiedCorrectly();
+ $this->assertTrue($this->verifier->hasBeenVerified());
+ $this->assertTrue($this->requirePasswordVerify()); // no need to redirect
+
+ $this->verifier->forgetVerifiedPassword();
+
+ $this->assertNull($this->requirePasswordVerify());
+ $this->assertFalse($this->verifier->hasBeenVerified());
+ }
+
+ private function requirePasswordVerify()
+ {
+ return $this->verifier->requirePasswordVerifiedRecently(array('module' => 'Login', 'action' => 'test'));
+ }
+
+} \ No newline at end of file
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
diff --git a/plugins/UsersManager/API.php b/plugins/UsersManager/API.php
index 7862cd7b3a..e2e96d3e64 100644
--- a/plugins/UsersManager/API.php
+++ b/plugins/UsersManager/API.php
@@ -21,6 +21,7 @@ use Piwik\Metrics\Formatter;
use Piwik\NoAccessException;
use Piwik\Option;
use Piwik\Piwik;
+use Piwik\Plugin;
use Piwik\SettingsPiwik;
use Piwik\Site;
use Piwik\Tracker\Cache;
@@ -74,6 +75,8 @@ class API extends \Piwik\Plugin\API
*/
private $capabilityProvider;
+ private $twoFaPluginActivated;
+
const PREFERENCE_DEFAULT_REPORT = 'defaultReport';
const PREFERENCE_DEFAULT_REPORT_DATE = 'defaultReportDate';
@@ -326,7 +329,6 @@ class API extends \Piwik\Plugin\API
$users = $this->enrichUsers($users);
$users = $this->enrichUsersWithLastSeen($users);
- $users = $this->removeUserInfoForNonSuperUsers($users);
foreach ($users as &$user) {
unset($user['password']);
@@ -360,9 +362,6 @@ class API extends \Piwik\Plugin\API
$users = $this->userFilter->filterUsers($users);
$users = $this->enrichUsers($users);
- // Non Super user can only access login & alias
- $users = $this->removeUserInfoForNonSuperUsers($users);
-
return $users;
}
@@ -762,15 +761,51 @@ class API extends \Piwik\Plugin\API
return $users;
}
+ private function isTwoFactorAuthPluginEnabled()
+ {
+ if (!isset($this->twoFaPluginActivated)) {
+ $this->twoFaPluginActivated = Plugin\Manager::getInstance()->isPluginActivated('TwoFactorAuth');
+ }
+ return $this->twoFaPluginActivated;
+ }
+
private function enrichUser($user)
{
- if (!empty($user)) {
- unset($user['token_auth']);
- unset($user['password']);
- unset($user['ts_password_modified']);
+ if (empty($user)) {
+ return $user;
}
- return $user;
+ unset($user['token_auth']);
+ unset($user['password']);
+ unset($user['ts_password_modified']);
+
+ if (Piwik::hasUserSuperUserAccess()) {
+ $user['uses_2fa'] = !empty($user['twofactor_secret']) && $this->isTwoFactorAuthPluginEnabled();
+ unset($user['twofactor_secret']);
+ return $user;
+ }
+
+ $newUser = array('login' => $user['login']);
+ if (isset($user['alias'])) {
+ $newUser['alias'] = $user['alias'];
+ }
+
+ if ($user['login'] === Piwik::getCurrentUserLogin() || !empty($user['superuser_access'])) {
+ $newUser['email'] = $user['email'];
+ }
+
+ if (isset($user['role'])) {
+ $newUser['role'] = $user['role'] == 'superuser' ? 'admin' : $user['role'];
+ }
+ if (isset($user['capabilities'])) {
+ $newUser['capabilities'] = $user['capabilities'];
+ }
+
+ if (isset($user['superuser_access'])) {
+ $newUser['superuser_access'] = $user['superuser_access'];
+ }
+
+ return $newUser;
}
/**
@@ -1303,23 +1338,6 @@ class API extends \Piwik\Plugin\API
return $user['token_auth'];
}
- private function removeUserInfoForNonSuperUsers($users)
- {
- if (!Piwik::hasUserSuperUserAccess()) {
- foreach ($users as $key => $user) {
- $newUser = array('login' => $user['login'], 'alias' => $user['alias']);
- if (isset($user['role'])) {
- $newUser['role'] = $user['role'] == 'superuser' ? 'admin' : $user['role'];
- }
- if (isset($user['capabilities'])) {
- $newUser['capabilities'] = $user['capabilities'];
- }
- $users[$key] = $newUser;
- }
- }
- return $users;
- }
-
private function isUserHasAdminAccessTo($idSite)
{
try {
diff --git a/plugins/UsersManager/Controller.php b/plugins/UsersManager/Controller.php
index c9cfcaecde..45661e3c5e 100644
--- a/plugins/UsersManager/Controller.php
+++ b/plugins/UsersManager/Controller.php
@@ -9,13 +9,10 @@
namespace Piwik\Plugins\UsersManager;
use Exception;
-use Piwik\Access;
use Piwik\API\Request;
use Piwik\API\ResponseBuilder;
use Piwik\Common;
use Piwik\Container\StaticContainer;
-use Piwik\Metrics\Formatter;
-use Piwik\NoAccessException;
use Piwik\Piwik;
use Piwik\Plugin\ControllerAdmin;
use Piwik\Plugins\LanguagesManager\API as APILanguagesManager;
@@ -174,7 +171,6 @@ class Controller extends ControllerAdmin
$user = Request::processRequest('UsersManager.getUser', array('userLogin' => $userLogin));
$view->userEmail = $user['email'];
$view->userTokenAuth = Piwik::getCurrentUserTokenAuth();
-
$view->ignoreSalt = $this->getIgnoreCookieSalt();
$userPreferences = new UserPreferences();
diff --git a/plugins/UsersManager/Model.php b/plugins/UsersManager/Model.php
index afba31a4da..c63d82c04e 100644
--- a/plugins/UsersManager/Model.php
+++ b/plugins/UsersManager/Model.php
@@ -278,7 +278,7 @@ class Model
));
}
- private function updateUserFields($userLogin, $fields)
+ public function updateUserFields($userLogin, $fields)
{
$set = array();
$bind = array();
@@ -307,7 +307,7 @@ class Model
public function getUsersHavingSuperUserAccess()
{
$db = $this->getDb();
- $users = $db->fetchAll("SELECT login, email, token_auth
+ $users = $db->fetchAll("SELECT login, email, token_auth, superuser_access
FROM " . Common::prefixTable("user") . "
WHERE superuser_access = 1
ORDER BY date_registered ASC");
diff --git a/plugins/UsersManager/UsersManager.php b/plugins/UsersManager/UsersManager.php
index 980528ad04..5369e430ca 100644
--- a/plugins/UsersManager/UsersManager.php
+++ b/plugins/UsersManager/UsersManager.php
@@ -244,6 +244,8 @@ class UsersManager extends \Piwik\Plugin
$translationKeys[] = 'UsersManager_SetPermission';
$translationKeys[] = 'UsersManager_RolesHelp';
$translationKeys[] = 'UsersManager_Role';
+ $translationKeys[] = 'UsersManager_2FA';
+ $translationKeys[] = 'UsersManager_UsesTwoFactorAuthentication';
$translationKeys[] = 'General_Actions';
$translationKeys[] = 'UsersManager_TheDisplayedWebsitesAreSelected';
$translationKeys[] = 'UsersManager_ClickToSelectAll';
@@ -277,6 +279,9 @@ class UsersManager extends \Piwik\Plugin
$translationKeys[] = 'UsersManager_DeleteUserConfirmMultiple';
$translationKeys[] = 'UsersManager_DeleteUserPermConfirmSingle';
$translationKeys[] = 'UsersManager_DeleteUserPermConfirmMultiple';
+ $translationKeys[] = 'UsersManager_ResetTwoFactorAuthentication';
+ $translationKeys[] = 'UsersManager_ResetTwoFactorAuthenticationInfo';
+ $translationKeys[] = 'UsersManager_TwoFactorAuthentication';
$translationKeys[] = 'UsersManager_AddNewUser';
$translationKeys[] = 'UsersManager_EditUser';
$translationKeys[] = 'UsersManager_CreateUser';
diff --git a/plugins/UsersManager/angularjs/paged-users-list/paged-users-list.component.html b/plugins/UsersManager/angularjs/paged-users-list/paged-users-list.component.html
index 99f7bb99ed..8e7db8489e 100644
--- a/plugins/UsersManager/angularjs/paged-users-list/paged-users-list.component.html
+++ b/plugins/UsersManager/angularjs/paged-users-list/paged-users-list.component.html
@@ -121,6 +121,7 @@
></div>
</th>
<th ng-if="$ctrl.currentUserRole == 'superuser'">{{:: 'UsersManager_Email'|translate }}</th>
+ <th ng-if="$ctrl.currentUserRole == 'superuser'" title="{{'UsersManager_UsesTwoFactorAuthentication'|translate}}">{{:: 'UsersManager_2FA'|translate }}</th>
<th ng-if="$ctrl.currentUserRole == 'superuser'">{{:: 'UsersManager_LastSeen'|translate }}</th>
<th class="actions-cell-header"><div>{{:: 'General_Actions'|translate }}</div></th>
</tr>
@@ -128,7 +129,7 @@
<tbody>
<tr class="select-all-row" ng-if="$ctrl.isAllCheckboxSelected && $ctrl.users.length && $ctrl.users.length < $ctrl.totalEntries">
- <td colspan="7">
+ <td colspan="8">
<div ng-if="!$ctrl.areAllResultsSelected">
<span piwik-translate="UsersManager_TheDisplayedUsersAreSelected"><strong>{{ $ctrl.users.length }}</strong></span>
<a class="toggle-select-all-in-search" href="#" ng-click="$ctrl.areAllResultsSelected = !$ctrl.areAllResultsSelected" piwik-translate="UsersManager_ClickToSelectAll"><strong>{{ $ctrl.totalEntries }}</strong></a>
@@ -160,6 +161,9 @@
></div>
</td>
<td id="email" ng-if="$ctrl.currentUserRole == 'superuser'">{{ user.email }}</td>
+ <td id="twofa" ng-if="$ctrl.currentUserRole == 'superuser'">
+ {{ user.uses_2fa ? ('✓') : '☓' }}
+ </td>
<td id="last_seen" ng-if="$ctrl.currentUserRole == 'superuser'">
{{ user.last_seen ? (user.last_seen + ' ago') : '-' }}
</td>
diff --git a/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.html b/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.html
index 89ec94e5db..bd897b607a 100644
--- a/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.html
+++ b/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.html
@@ -21,6 +21,10 @@
<li ng-class="{active: $ctrl.activeTab === 'superuser'}" class="menuSuperuser" ng-if="$ctrl.currentUserRole == 'superuser'">
<a href="" ng-click="$ctrl.activeTab = 'superuser'">{{:: 'UsersManager_SuperUserAccess'|translate }}</a>
</li>
+
+ <li ng-class="{active: $ctrl.activeTab === '2fa'}" class="menuUserTwoFa" ng-if="$ctrl.currentUserRole == 'superuser' && $ctrl.user.uses_2fa && !$ctrl.isAdd">
+ <a href="" ng-click="$ctrl.activeTab = '2fa'">{{:: 'UsersManager_TwoFactorAuthentication'|translate }}</a>
+ </li>
</ul>
<div class="save-button-spacer hide-on-small-only">
@@ -139,6 +143,28 @@
</div>
</div>
</div>
+
+ <div ng-if="$ctrl.activeTab === '2fa' && $ctrl.currentUserRole == 'superuser' && !$ctrl.isAdd" class="twofa-reset">
+ <p>{{:: 'UsersManager_ResetTwoFactorAuthenticationInfo'|translate }}</p>
+
+ <div piwik-save-button
+ class="resetTwoFa"
+ saving="$ctrl.isResetting2FA"
+ onconfirm="$ctrl.confirmReset2FA()"
+ value="{{ 'UsersManager_ResetTwoFactorAuthentication'|translate }}"
+ ></div>
+
+ <div class="twofa-confirm-modal modal">
+ <div class="modal-content">
+ <h2>{{:: 'UsersManager_AreYouSure'|translate }}</h2>
+ </div>
+ <div class="modal-footer">
+ <a href="" class="modal-action modal-close btn" ng-click="$ctrl.reset2FA()">{{:: 'General_Yes'|translate }}</a>
+ <a href="" class="modal-action modal-close modal-no">{{:: 'General_No'|translate }}</a>
+ </div>
+ </div>
+
+ </div>
</div>
</div>
diff --git a/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.js b/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.js
index 26bcdad072..bb929d5749 100644
--- a/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.js
+++ b/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.js
@@ -39,10 +39,12 @@
vm.$onInit = $onInit;
vm.$onChanges = $onChanges;
vm.confirmSuperUserChange = confirmSuperUserChange;
+ vm.confirmReset2FA = confirmReset2FA;
vm.getFormTitle = getFormTitle;
vm.getSaveButtonLabel = getSaveButtonLabel;
vm.toggleSuperuserAccess = toggleSuperuserAccess;
vm.saveUserInfo = saveUserInfo;
+ vm.reset2FA = reset2FA;
vm.updateUser = updateUser;
function $onInit() {
@@ -77,6 +79,10 @@
$element.find('.superuser-confirm-modal').openModal({ dismissible: false });
}
+ function confirmReset2FA() {
+ $element.find('.twofa-confirm-modal').openModal({ dismissible: false });
+ }
+
function confirmUserChange() {
vm.passwordConfirmation = '';
function onEnter(event){
@@ -116,6 +122,23 @@
}
}
+ function reset2FA() {
+ vm.isResetting2FA = true;
+ return piwikApi.post({
+ method: 'TwoFactorAuth.resetTwoFactorAuth',
+ userLogin: vm.user.login
+ }).catch(function (e) {
+ vm.isResetting2FA = false;
+ throw e;
+ }).then(function () {
+ vm.isResetting2FA = false;
+ vm.user.uses_2fa = false;
+ vm.activeTab = 'basic';
+
+ showUserSavedNotification();
+ });
+ }
+
function showUserSavedNotification() {
var UI = require('piwik/UI');
var notification = new UI.Notification();
diff --git a/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.less b/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.less
index d8c8bf968c..3e64e1d296 100644
--- a/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.less
+++ b/plugins/UsersManager/angularjs/user-edit-form/user-edit-form.component.less
@@ -20,7 +20,7 @@
height: 48px;
}
- .superuser-confirm-modal,.change-password-modal {
+ .twofa-confirm-modal, .superuser-confirm-modal,.change-password-modal {
.modal-no {
float: right;
margin-right: 1em;
diff --git a/plugins/UsersManager/lang/en.json b/plugins/UsersManager/lang/en.json
index 127d594a86..ea4972c43d 100644
--- a/plugins/UsersManager/lang/en.json
+++ b/plugins/UsersManager/lang/en.json
@@ -1,5 +1,10 @@
{
"UsersManager": {
+ "2FA": "2FA",
+ "UsesTwoFactorAuthentication": "Uses two-factor authentication",
+ "TwoFactorAuthentication": "Two-factor authentication",
+ "ResetTwoFactorAuthentication": "Reset two-factor authentication",
+ "ResetTwoFactorAuthenticationInfo": "If the user can no longer log in due to lost recovery codes or a lost authentication device, you can reset two-factor authentication for the user so they can log in again.",
"AddUser": "Add a new user",
"AddExistingUser": "Add an existing user",
"AddNewUser": "Add new user",
diff --git a/plugins/UsersManager/templates/userSettings.twig b/plugins/UsersManager/templates/userSettings.twig
index 45f8d7359a..224d4866e1 100644
--- a/plugins/UsersManager/templates/userSettings.twig
+++ b/plugins/UsersManager/templates/userSettings.twig
@@ -129,6 +129,8 @@
ng-click="personalSettings.regenerateTokenAuth()">{{ 'UsersManager_TokenRegenerateTitle'|translate }}</button>
</div>
+{{ postEvent('Template.userSettings.afterTokenAuth') }}
+
<div piwik-plugin-settings mode="user"></div>
<div piwik-content-block
diff --git a/plugins/UsersManager/tests/Integration/APITest.php b/plugins/UsersManager/tests/Integration/APITest.php
index 2e2040ef79..d53716dbfe 100644
--- a/plugins/UsersManager/tests/Integration/APITest.php
+++ b/plugins/UsersManager/tests/Integration/APITest.php
@@ -453,11 +453,11 @@ class APITest extends IntegrationTestCase
$users = $this->api->getUsersPlusRole(1);
$this->cleanUsers($users);
$expected = [
- ['login' => 'userLogin', 'alias' => 'userLogin', 'email' => 'userlogin@password.de', 'superuser_access' => false, 'role' => 'noaccess', 'capabilities' => []],
- ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'userLogin2@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => []],
- ['login' => 'userLogin3', 'alias' => 'userLogin3', 'email' => 'userLogin3@password.de', 'superuser_access' => false, 'role' => 'view', 'capabilities' => []],
- ['login' => 'userLogin4', 'alias' => 'userLogin4', 'email' => 'userLogin4@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => []],
- ['login' => 'userLogin5', 'alias' => 'userLogin5', 'email' => 'userLogin5@password.de', 'superuser_access' => false, 'role' => 'noaccess', 'capabilities' => []],
+ ['login' => 'userLogin', 'alias' => 'userLogin', 'email' => 'userlogin@password.de', 'superuser_access' => false, 'role' => 'noaccess', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'userLogin2@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin3', 'alias' => 'userLogin3', 'email' => 'userLogin3@password.de', 'superuser_access' => false, 'role' => 'view', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin4', 'alias' => 'userLogin4', 'email' => 'userLogin4@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin5', 'alias' => 'userLogin5', 'email' => 'userLogin5@password.de', 'superuser_access' => false, 'role' => 'noaccess', 'capabilities' => [], 'uses_2fa' => false],
];
$this->assertEquals($expected, $users);
}
@@ -498,9 +498,9 @@ class APITest extends IntegrationTestCase
$users = $this->api->getUsersPlusRole(1, null, null, null, 'noaccess');
$this->cleanUsers($users);
$expected = [
- ['login' => 'userLogin', 'alias' => 'userLogin', 'role' => 'noaccess', 'superuser_access' => false, 'email' => 'userlogin@password.de', 'capabilities' => []],
- ['login' => 'userLogin2', 'alias' => 'userLogin2', 'role' => 'noaccess', 'superuser_access' => false, 'email' => 'userLogin2@password.de', 'capabilities' => []],
- ['login' => 'userLogin5', 'alias' => 'userLogin5', 'role' => 'noaccess', 'superuser_access' => false, 'email' => 'userLogin5@password.de', 'capabilities' => []],
+ ['login' => 'userLogin', 'alias' => 'userLogin', 'role' => 'noaccess', 'superuser_access' => false, 'email' => 'userlogin@password.de', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin2', 'alias' => 'userLogin2', 'role' => 'noaccess', 'superuser_access' => false, 'email' => 'userLogin2@password.de', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin5', 'alias' => 'userLogin5', 'role' => 'noaccess', 'superuser_access' => false, 'email' => 'userLogin5@password.de', 'capabilities' => [], 'uses_2fa' => false],
];
$this->assertEquals($expected, $users);
}
@@ -517,8 +517,8 @@ class APITest extends IntegrationTestCase
$users = $this->api->getUsersPlusRole(1, null, null, null, 'superuser');
$this->cleanUsers($users);
$expected = [
- ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'userLogin2@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => []],
- ['login' => 'userLogin4', 'alias' => 'userLogin4', 'email' => 'userLogin4@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => []],
+ ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'userLogin2@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin4', 'alias' => 'userLogin4', 'email' => 'userLogin4@password.de', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => [], 'uses_2fa' => false],
];
$this->assertEquals($expected, $users);
}
@@ -534,8 +534,8 @@ class APITest extends IntegrationTestCase
$users = $this->api->getUsersPlusRole(1, null, null, 'searchText');
$this->cleanUsers($users);
$expected = [
- ['login' => 'searchTextLogin', 'alias' => 'alias', 'email' => 'someemail@email.com', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => []],
- ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'searchTextdef@email.com', 'superuser_access' => false, 'role' => 'view', 'capabilities' => []],
+ ['login' => 'searchTextLogin', 'alias' => 'alias', 'email' => 'someemail@email.com', 'superuser_access' => true, 'role' => 'superuser', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'searchTextdef@email.com', 'superuser_access' => false, 'role' => 'view', 'capabilities' => [], 'uses_2fa' => false],
];
$this->assertEquals($expected, $users);
}
@@ -551,8 +551,8 @@ class APITest extends IntegrationTestCase
$users = $this->api->getUsersPlusRole(1, $limit = 2, $offset = 1);
$this->cleanUsers($users);
$expected = [
- ['login' => 'userLogin', 'alias' => 'userLogin', 'email' => 'userlogin@password.de', 'superuser_access' => false, 'role' => 'noaccess', 'capabilities' => []],
- ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'searchTextdef@email.com', 'superuser_access' => false, 'role' => 'view', 'capabilities' => []],
+ ['login' => 'userLogin', 'alias' => 'userLogin', 'email' => 'userlogin@password.de', 'superuser_access' => false, 'role' => 'noaccess', 'capabilities' => [], 'uses_2fa' => false],
+ ['login' => 'userLogin2', 'alias' => 'userLogin2', 'email' => 'searchTextdef@email.com', 'superuser_access' => false, 'role' => 'view', 'capabilities' => [], 'uses_2fa' => false],
];
$this->assertEquals($expected, $users);
}
diff --git a/plugins/UsersManager/tests/Integration/UsersManagerTest.php b/plugins/UsersManager/tests/Integration/UsersManagerTest.php
index c8c2262137..747f856601 100644
--- a/plugins/UsersManager/tests/Integration/UsersManagerTest.php
+++ b/plugins/UsersManager/tests/Integration/UsersManagerTest.php
@@ -113,6 +113,7 @@ class UsersManagerTest extends IntegrationTestCase
$user['email'] = $newEmail;
$user['alias'] = $newAlias;
$user['superuser_access'] = 0;
+ $user['twofactor_secret'] = '';
unset($user['password']);
@@ -474,9 +475,9 @@ class UsersManagerTest extends IntegrationTestCase
$users = $this->api->getUsers();
$users = $this->_removeNonTestableFieldsFromUsers($users);
- $user1 = array('login' => "gegg4564eqgeqag", 'alias' => "alias", 'email' => "tegst@tesgt.com", 'superuser_access' => 0);
- $user2 = array('login' => "geggeqge632ge56a4qag", 'alias' => "alias", 'email' => "tesggt@tesgt.com", 'superuser_access' => 0);
- $user3 = array('login' => "geggeqgeqagqegg", 'alias' => 'geggeqgeqagqegg', 'email' => "tesgggt@tesgt.com", 'superuser_access' => 0);
+ $user1 = array('login' => "gegg4564eqgeqag", 'alias' => "alias", 'email' => "tegst@tesgt.com", 'superuser_access' => 0, 'uses_2fa' => false);
+ $user2 = array('login' => "geggeqge632ge56a4qag", 'alias' => "alias", 'email' => "tesggt@tesgt.com", 'superuser_access' => 0, 'uses_2fa' => false);
+ $user3 = array('login' => "geggeqgeqagqegg", 'alias' => 'geggeqgeqagqegg', 'email' => "tesgggt@tesgt.com", 'superuser_access' => 0, 'uses_2fa' => false);
$expectedUsers = array($user1, $user2, $user3);
$this->assertEquals($expectedUsers, $users);
$this->assertEquals(array($user1), $this->_removeNonTestableFieldsFromUsers($this->api->getUsers('gegg4564eqgeqag')));
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login1_when_superuseraccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login1_when_superuseraccess.xml
index c54ea7c443..c460ef3a4c 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login1_when_superuseraccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login1_when_superuseraccess.xml
@@ -6,5 +6,6 @@
<email>login1@example.com</email>
<superuser_access>1</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_adminaccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_adminaccess.xml
index 4356dcc2c8..6e71ca57aa 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_adminaccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_adminaccess.xml
@@ -5,6 +5,5 @@
<alias>login2</alias>
<email>login2@example.com</email>
<superuser_access>0</superuser_access>
-
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_superuseraccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_superuseraccess.xml
index 4356dcc2c8..7498981d07 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_superuseraccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login2_when_superuseraccess.xml
@@ -6,5 +6,6 @@
<email>login2@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_superuseraccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_superuseraccess.xml
index 6d73ea60cb..57c49e0cb9 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_superuseraccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_superuseraccess.xml
@@ -6,5 +6,6 @@
<email>login4@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_viewaccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_viewaccess.xml
index 6d73ea60cb..01f8684c42 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_viewaccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login4_when_viewaccess.xml
@@ -5,6 +5,5 @@
<alias>login4</alias>
<email>login4@example.com</email>
<superuser_access>0</superuser_access>
-
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login6_when_superuseraccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login6_when_superuseraccess.xml
index 99cd8e9dc2..8573855563 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login6_when_superuseraccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUser_login6_when_superuseraccess.xml
@@ -6,5 +6,6 @@
<email>login6@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsersWithSiteAccess_3_admin_when_superuseraccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsersWithSiteAccess_3_admin_when_superuseraccess.xml
index 749c6167be..52c2cc1a6e 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsersWithSiteAccess_3_admin_when_superuseraccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsersWithSiteAccess_3_admin_when_superuseraccess.xml
@@ -6,6 +6,7 @@
<email>login5@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login6</login>
@@ -13,5 +14,6 @@
<email>login6@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_adminaccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_adminaccess.xml
index 88daa22577..5000925acd 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_adminaccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_adminaccess.xml
@@ -3,25 +3,32 @@
<row>
<login>login2</login>
<alias>login2</alias>
+ <email>login2@example.com</email>
+ <superuser_access>0</superuser_access>
</row>
<row>
<login>login4</login>
<alias>login4</alias>
+ <superuser_access>0</superuser_access>
</row>
<row>
<login>login6</login>
<alias>login6</alias>
+ <superuser_access>0</superuser_access>
</row>
<row>
<login>login7</login>
<alias>login7</alias>
+ <superuser_access>0</superuser_access>
</row>
<row>
<login>login8</login>
<alias>login8</alias>
+ <superuser_access>0</superuser_access>
</row>
<row>
<login>login9</login>
<alias>login9</alias>
+ <superuser_access>0</superuser_access>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_superuseraccess.xml b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_superuseraccess.xml
index b705a46a92..701f33a009 100644
--- a/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_superuseraccess.xml
+++ b/plugins/UsersManager/tests/System/expected/test___UsersManager.getUsers__when_superuseraccess.xml
@@ -6,6 +6,7 @@
<email>login1@example.com</email>
<superuser_access>1</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login10</login>
@@ -13,6 +14,7 @@
<email>login10@example.com</email>
<superuser_access>1</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login2</login>
@@ -20,6 +22,7 @@
<email>login2@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login3</login>
@@ -27,6 +30,7 @@
<email>login3@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login4</login>
@@ -34,6 +38,7 @@
<email>login4@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login5</login>
@@ -41,6 +46,7 @@
<email>login5@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login6</login>
@@ -48,6 +54,7 @@
<email>login6@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login7</login>
@@ -55,6 +62,7 @@
<email>login7@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login8</login>
@@ -62,6 +70,7 @@
<email>login8@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>login9</login>
@@ -69,6 +78,7 @@
<email>login9@example.com</email>
<superuser_access>0</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
<row>
<login>superUserLogin</login>
@@ -76,5 +86,6 @@
<email>hello@example.org</email>
<superuser_access>1</superuser_access>
+ <uses_2fa>0</uses_2fa>
</row>
</result> \ No newline at end of file
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search.png
index cdcc79c6aa..4ee07c0411 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:eda08160dcd8f481950272a6cfc425f83872f6fa2a2a874087eaf4d1df3da8cb
-size 159013
+oid sha256:02a4aad80769cc9a72e9ec80febe82f6eade8cc7989da949d7ce8d4273447355
+size 162558
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search_deselected.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search_deselected.png
index 65383d82dd..812d0a35b6 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search_deselected.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_in_search_deselected.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:39305fab59ef5666d27152bbfbc3eaa393e900e3c33e0e231c40f02f5aab304f
-size 159014
+oid sha256:eddf8afc4d4917512d01cd3320b4160de827d11cc38c808bb38f2d8331c316a7
+size 162549
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_selected.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_selected.png
index 65383d82dd..812d0a35b6 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_selected.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_all_rows_selected.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:39305fab59ef5666d27152bbfbc3eaa393e900e3c33e0e231c40f02f5aab304f
-size 159014
+oid sha256:eddf8afc4d4917512d01cd3320b4160de827d11cc38c808bb38f2d8331c316a7
+size 162549
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_remove_access.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_remove_access.png
index b57b66c034..7209e7594b 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_remove_access.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_remove_access.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:085023cb15e0321b2edea4b4cfac8b02743c639ed1b8d3137061699402077914
-size 154035
+oid sha256:907ff23bc446226bad0f2b27ff0be44fc80b7e102eb952a316ed4af482a001c2
+size 157504
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_set_access.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_set_access.png
index 109dde10f0..0f9dd3b596 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_set_access.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_bulk_set_access.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:20943b24aa9feaff6aaee1ecfe24be57d3e979fec2f0f612131e2106e97ee977
-size 140044
+oid sha256:49e47fc3ea646316bd3ea7c5f83f6038e461b345ea27c95360df7371083a3a91
+size 143490
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_bulk_access.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_bulk_access.png
index bde872f146..f777bf4342 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_bulk_access.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_bulk_access.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d37df203e7b7b58511171d62cefed00d795bc988ad02c5781fe0366e6cd31e24
-size 152590
+oid sha256:17605bdd7e770372f958b079ad1466e11ebac45ffd9716350c97c928cd8059ba
+size 156043
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_single.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_single.png
index 1bd16afaed..e22a1a2db4 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_single.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_delete_single.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2a0b967e58683457840a1a3c07a3453a81ba754df9e3b8d4eb85e0f1f3e1d7c3
-size 140151
+oid sha256:0e6e9d998e354c0c1e7df5fce0822af8b096a01f214fdbff05f7c57a7cf45112
+size 143602
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_filters.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_filters.png
index e6be73e58d..34d59aa733 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_filters.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_filters.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f5ec8562b201ade582156ed83e6236bdbc8a36a74d30905926d4cb4266e705b5
-size 137866
+oid sha256:1f4c0ec9766420ffa29e42a0585f3c23a69f8b57672ca9f1875a8e8ccbc1d700
+size 141469
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_load.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_load.png
index a299102517..e0a90d3452 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_load.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_load.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1e3ec670d9929b74b36e8b3be2c37defd358bb8400f1e67dda59c31f4d4bef9b
-size 149040
+oid sha256:1001a998713681241d9163f0f59371acdae0269b647106b79e7c98453d8f6ee1
+size 152550
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_manage_users_back.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_manage_users_back.png
index 85b9b39f26..8ed13a7929 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_manage_users_back.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_manage_users_back.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:866354269d679b857fcfdba8b85472fa4d3729cde7e6111d462b6e41857c557b
-size 146412
+oid sha256:01a27e9aaaad2e14e8fd42e568a49b5400d61aa9f7b265c1c2ce6c1644b63e6f
+size 149877
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_next_click.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_next_click.png
index 970a47798e..ecbe5cc1d2 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_next_click.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_next_click.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8dab27b3df6be96f4b003f6280bd81d4374b8094c240ca8e14110b90580dc7c2
-size 151328
+oid sha256:e07b964a068d778560d0cc468e00101e59fa159303d3cb4fa9ee16ec451faab1
+size 154848
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_previous.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_previous.png
index b32c8143f0..f59b4b10ec 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_previous.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_previous.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4a7b143d52ae9fcdb30e7ce5eb9dab12ae917656e72c566c46d60981b81c5943
-size 139447
+oid sha256:06e86f317c80d229141316c424553d2b9c69a15b2a4776d541ced793818ce48e
+size 142879
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_role_for.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_role_for.png
index 105c5c3115..d58bfe9766 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_role_for.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_role_for.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:52801eff0c69f00a6fd411960a74aa517d03f5feb43da52a40792cc4a0884ca3
-size 145538
+oid sha256:cc86791d4e6a184f18cd3d6660ee4de749bbb6d8daa9dbb1620dbaf6e9ebd233
+size 149045
diff --git a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_rows_selected.png b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_rows_selected.png
index c06e6183bb..c5b036c43e 100644
--- a/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_rows_selected.png
+++ b/plugins/UsersManager/tests/UI/expected-screenshots/UsersManager_rows_selected.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0374cac8da8d89966c44d152fdcc29fbb69a94a0940a772ca79fe0f2e1092d9a
-size 146784
+oid sha256:e4dbd02ff49853db34aba1d57e987103d885f0fb35a08e49b6fed2c2d2decfc3
+size 150218
diff --git a/tests/PHPUnit/Integration/CacheTest.php b/tests/PHPUnit/Integration/CacheTest.php
index 31d5faeed8..4415511c77 100644
--- a/tests/PHPUnit/Integration/CacheTest.php
+++ b/tests/PHPUnit/Integration/CacheTest.php
@@ -28,7 +28,8 @@ class CacheTest extends IntegrationTestCase
$backend = StaticContainer::get('Piwik\Cache\Backend');
$this->assertFalse($backend->doContains($storageId));
- Piwik::postEvent('Request.dispatch.end'); // should trigger save
+ $result = ''; $module = 'CoreHome'; $action = 'index'; $params = array();
+ Piwik::postEvent('Request.dispatch.end', array(&$result, $module, $action, $params)); // should trigger save
$this->assertTrue($backend->doContains($storageId));
}
diff --git a/tests/PHPUnit/Integration/ReleaseCheckListTest.php b/tests/PHPUnit/Integration/ReleaseCheckListTest.php
index 0fb62a64c5..b064062a2a 100644
--- a/tests/PHPUnit/Integration/ReleaseCheckListTest.php
+++ b/tests/PHPUnit/Integration/ReleaseCheckListTest.php
@@ -207,7 +207,7 @@ class ReleaseCheckListTest extends \PHPUnit_Framework_TestCase
PIWIK_INCLUDE_PATH . '/plugins/TestRunner/templates/travis.yml.twig',
PIWIK_INCLUDE_PATH . '/plugins/CoreUpdater/templates/layout.twig',
PIWIK_INCLUDE_PATH . '/plugins/Installation/templates/layout.twig',
- PIWIK_INCLUDE_PATH . '/plugins/Login/templates/login.twig',
+ PIWIK_INCLUDE_PATH . '/plugins/Login/templates/loginLayout.twig',
PIWIK_INCLUDE_PATH . '/tests/UI/screenshot-diffs/singlediff.html',
// Note: entries below are paths and any file within these paths will be automatically whitelisted
diff --git a/tests/PHPUnit/System/expected/test_ImportLogs__CorePluginsAdmin.getSystemSettings.xml b/tests/PHPUnit/System/expected/test_ImportLogs__CorePluginsAdmin.getSystemSettings.xml
index 3f29c2f1f8..1775d8b5ea 100644
--- a/tests/PHPUnit/System/expected/test_ImportLogs__CorePluginsAdmin.getSystemSettings.xml
+++ b/tests/PHPUnit/System/expected/test_ImportLogs__CorePluginsAdmin.getSystemSettings.xml
@@ -269,6 +269,44 @@
</settings>
</row>
<row>
+ <pluginName>TwoFactorAuth</pluginName>
+ <title>TwoFactorAuth</title>
+ <settings>
+ <row>
+ <name>twoFactorAuthRequired</name>
+ <title>Require two-factor authentication for everyone</title>
+ <value>0</value>
+ <defaultValue>0</defaultValue>
+ <type>boolean</type>
+ <uiControl>checkbox</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>When enabled, every user has to enable two factor authentication.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ <row>
+ <name>twoFactorAuthName</name>
+ <title>Two-factor authentication title</title>
+ <value>Analytics - localhost</value>
+ <defaultValue>Analytics - localhost</defaultValue>
+ <type>string</type>
+ <uiControl>text</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>The name of the title to display that will be displayed in the Authenticator app.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ </settings>
+ </row>
+ <row>
<pluginName>CoreUpdater</pluginName>
<title>Update settings</title>
<settings>
diff --git a/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits__CorePluginsAdmin.getSystemSettings.xml b/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits__CorePluginsAdmin.getSystemSettings.xml
index 3f29c2f1f8..1775d8b5ea 100644
--- a/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits__CorePluginsAdmin.getSystemSettings.xml
+++ b/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits__CorePluginsAdmin.getSystemSettings.xml
@@ -269,6 +269,44 @@
</settings>
</row>
<row>
+ <pluginName>TwoFactorAuth</pluginName>
+ <title>TwoFactorAuth</title>
+ <settings>
+ <row>
+ <name>twoFactorAuthRequired</name>
+ <title>Require two-factor authentication for everyone</title>
+ <value>0</value>
+ <defaultValue>0</defaultValue>
+ <type>boolean</type>
+ <uiControl>checkbox</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>When enabled, every user has to enable two factor authentication.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ <row>
+ <name>twoFactorAuthName</name>
+ <title>Two-factor authentication title</title>
+ <value>Analytics - localhost</value>
+ <defaultValue>Analytics - localhost</defaultValue>
+ <type>string</type>
+ <uiControl>text</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>The name of the title to display that will be displayed in the Authenticator app.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ </settings>
+ </row>
+ <row>
<pluginName>CoreUpdater</pluginName>
<title>Update settings</title>
<settings>
diff --git a/tests/PHPUnit/System/expected/test_noVisit_PeriodIsLast__CorePluginsAdmin.getSystemSettings.xml b/tests/PHPUnit/System/expected/test_noVisit_PeriodIsLast__CorePluginsAdmin.getSystemSettings.xml
index 3f29c2f1f8..1775d8b5ea 100644
--- a/tests/PHPUnit/System/expected/test_noVisit_PeriodIsLast__CorePluginsAdmin.getSystemSettings.xml
+++ b/tests/PHPUnit/System/expected/test_noVisit_PeriodIsLast__CorePluginsAdmin.getSystemSettings.xml
@@ -269,6 +269,44 @@
</settings>
</row>
<row>
+ <pluginName>TwoFactorAuth</pluginName>
+ <title>TwoFactorAuth</title>
+ <settings>
+ <row>
+ <name>twoFactorAuthRequired</name>
+ <title>Require two-factor authentication for everyone</title>
+ <value>0</value>
+ <defaultValue>0</defaultValue>
+ <type>boolean</type>
+ <uiControl>checkbox</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>When enabled, every user has to enable two factor authentication.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ <row>
+ <name>twoFactorAuthName</name>
+ <title>Two-factor authentication title</title>
+ <value>Analytics - localhost</value>
+ <defaultValue>Analytics - localhost</defaultValue>
+ <type>string</type>
+ <uiControl>text</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>The name of the title to display that will be displayed in the Authenticator app.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ </settings>
+ </row>
+ <row>
<pluginName>CoreUpdater</pluginName>
<title>Update settings</title>
<settings>
diff --git a/tests/PHPUnit/System/expected/test_noVisit__CorePluginsAdmin.getSystemSettings.xml b/tests/PHPUnit/System/expected/test_noVisit__CorePluginsAdmin.getSystemSettings.xml
index 3f29c2f1f8..1775d8b5ea 100644
--- a/tests/PHPUnit/System/expected/test_noVisit__CorePluginsAdmin.getSystemSettings.xml
+++ b/tests/PHPUnit/System/expected/test_noVisit__CorePluginsAdmin.getSystemSettings.xml
@@ -269,6 +269,44 @@
</settings>
</row>
<row>
+ <pluginName>TwoFactorAuth</pluginName>
+ <title>TwoFactorAuth</title>
+ <settings>
+ <row>
+ <name>twoFactorAuthRequired</name>
+ <title>Require two-factor authentication for everyone</title>
+ <value>0</value>
+ <defaultValue>0</defaultValue>
+ <type>boolean</type>
+ <uiControl>checkbox</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>When enabled, every user has to enable two factor authentication.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ <row>
+ <name>twoFactorAuthName</name>
+ <title>Two-factor authentication title</title>
+ <value>Analytics - localhost</value>
+ <defaultValue>Analytics - localhost</defaultValue>
+ <type>string</type>
+ <uiControl>text</uiControl>
+ <uiControlAttributes>
+ </uiControlAttributes>
+ <availableValues />
+ <description>The name of the title to display that will be displayed in the Authenticator app.</description>
+ <inlineHelp />
+ <templateFile />
+ <introduction />
+ <condition />
+ </row>
+ </settings>
+ </row>
+ <row>
<pluginName>CoreUpdater</pluginName>
<title>Update settings</title>
<settings>
diff --git a/tests/PHPUnit/Unit/Session/SessionFingerprintTest.php b/tests/PHPUnit/Unit/Session/SessionFingerprintTest.php
index 7ab0ecdf5b..a0cae1ef98 100644
--- a/tests/PHPUnit/Unit/Session/SessionFingerprintTest.php
+++ b/tests/PHPUnit/Unit/Session/SessionFingerprintTest.php
@@ -65,6 +65,18 @@ class SessionFingerprintTest extends \PHPUnit_Framework_TestCase
);
}
+ public function test_initialize_hasVerifiedTwoFactor()
+ {
+ $this->testInstance->initialize('testuser', self::TEST_TIME_VALUE);
+
+ // after logging in, the user has by default not verified two factor, important
+ $this->assertFalse($this->testInstance->hasVerifiedTwoFactor());
+
+ $this->testInstance->setTwoFactorAuthenticationVerified();
+
+ $this->assertTrue($this->testInstance->hasVerifiedTwoFactor());
+ }
+
public function test_getSessionStartTime_()
{
$_SESSION[SessionFingerprint::SESSION_INFO_SESSION_VAR_NAME] = [
diff --git a/tests/UI/expected-screenshots/EmptySite_emptySiteDashboard_ignored.png b/tests/UI/expected-screenshots/EmptySite_emptySiteDashboard_ignored.png
index 7104445f72..fb2cef1273 100644
--- a/tests/UI/expected-screenshots/EmptySite_emptySiteDashboard_ignored.png
+++ b/tests/UI/expected-screenshots/EmptySite_emptySiteDashboard_ignored.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fa9f8635be61a7743aea5ca24d837e8029bee57caeb2b574a88995787a3646af
-size 321442
+oid sha256:3a4392adc20482d1aaa6e5f6c54621b7986f7a09db817e9a668626e46234df32
+size 321537
diff --git a/tests/UI/expected-screenshots/Menus_mobile_top.png b/tests/UI/expected-screenshots/Menus_mobile_top.png
index 1ee36c1d70..680e540414 100644
--- a/tests/UI/expected-screenshots/Menus_mobile_top.png
+++ b/tests/UI/expected-screenshots/Menus_mobile_top.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1ba4135e06d5d99251d925c3d108701d9971f8cf932db8b9c6b3bb180f70fee1
-size 191695
+oid sha256:ff3e8ef0ff637e39b0ded6ada4d903643ac65e627eaad4d5fbe4d91dba75b929
+size 233082
diff --git a/tests/UI/expected-screenshots/Theme_home.png b/tests/UI/expected-screenshots/Theme_home.png
index c457f18393..43b3c74214 100644
--- a/tests/UI/expected-screenshots/Theme_home.png
+++ b/tests/UI/expected-screenshots/Theme_home.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2ae965c8ef6e9e48230b0457e86ea388a0b5511ea099b9304646e097b7c5ede2
-size 648283
+oid sha256:dbd1c70d53852513051986618c84ad238fcb72f390d11df4d6f46beeea735a99
+size 648280
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png
index 3d476ae265..e24f40af97 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7fd7423582cc1f700803d20d1e239b517185e8cf2a0770768cb341efdf788df0
-size 4079240
+oid sha256:8d7623760a3a9aab115a5b204f746113b4a81d89ca4892fb69ce5d8988064726
+size 4108985
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_home.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_home.png
index d6422e75dc..38d8be92cf 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_home.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_home.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f865f5c79b1784ec2049a4b6b68b5f652c8f14643052e8c72e6c9d10664cc8bb
-size 135232
+oid sha256:9b68c1038e1b971f1fc3a20f0a099070aed8aa06e7975f6dd57f7e508ff41ae0
+size 135316
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins.png
index bb9f85998e..e66f91d3ee 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b4adce722c2f48fbd883e5d142b13c425962122ac64d1817f27013fe6900408e
-size 1043948
+oid sha256:cd998d1a5c59d6caa66fe3df51ac515e5148372952060a0f853bf3706065cd0c
+size 1052915
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins_no_internet.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins_no_internet.png
index f8bfdc6983..26ca229127 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins_no_internet.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_plugins_no_internet.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6f7580dc36fbd3090a3b93dd1f5290059c358b31a6841ed8f39e19e414236e46
-size 1045534
+oid sha256:9674685d2a18126910d303fa64095d82eca00db5c5826ec01a04bc4dffaa1a3b
+size 1054258
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_settings_general.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_settings_general.png
index def09c3c19..056fa0cf17 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_settings_general.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_settings_general.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:40192c9d43e4afe6c9e83559d6851c939415fe9dedbc1bfe3f58e4b6aec9c88d
-size 929225
+oid sha256:ebd3d2cc498bf2f710fbe629f0302bdc64670dfb50556aea99c0dcdaea10be03
+size 966478
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings.png
index 2e13c5979b..4c6c3ba0d6 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f472a8db4bb0bdbc20a25c04d952f9b66e00fb358d0f1b7fbd4c2e1f8ff422c5
-size 223158
+oid sha256:423d06abd00fcec6a14dd2ccf9650f35feef5d7492f26a43a80dafd5768c65a0
+size 268668
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings_asks_confirmation.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings_asks_confirmation.png
index 2b87f10f7a..876a87376f 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings_asks_confirmation.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_user_settings_asks_confirmation.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:14f056815c25f149bc9604e24370d3082ca6969999d1efcc176b5094a8686a28
-size 15719
+oid sha256:b7e1c86b865ba52667266f0270062e6da2f1124d1d65f7d67c3ef2215a97f10e
+size 15724
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png b/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png
index 811867b323..8f9913d8e0 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fc9bdcd48199447dc13323472fd8336b76874001cc52c012415b11dc5b007533
-size 4995949
+oid sha256:fc3c326add9d34a8f4f11f65a275b5af485542c6e4627447f123b54bccf70870
+size 5011417
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_dashboard1.png b/tests/UI/expected-screenshots/UIIntegrationTest_dashboard1.png
index 9d5421beb4..4097c1bcfa 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_dashboard1.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_dashboard1.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c9c796a8ccb0b4c4d2e073fca1f03da21419a3e2360234e159b1d03b00cc3fad
-size 591696
+oid sha256:b7c3c9aceecf73530ab9401447c71b8a5f695c442ba22ecee69ee34ab83231fb
+size 591784
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_dashboard3.png b/tests/UI/expected-screenshots/UIIntegrationTest_dashboard3.png
index 2ebce0e11d..ba7df8488e 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_dashboard3.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_dashboard3.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9d96edfc86d49ccf9d5c97fd307bd74f4fab1ad072216d22d2ae5dfa33879b58
-size 647182
+oid sha256:33550a953fd5168d2d04f185539062da2995dbad8e622c785b4bf28ae987c665
+size 647044
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_referrers_overview.png b/tests/UI/expected-screenshots/UIIntegrationTest_referrers_overview.png
index 2b6031fb48..d4b8fa8f85 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_referrers_overview.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_referrers_overview.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a74f22785a1f108be93585591c26a45cb26950c58071ff578331f31aec2e7b4b
-size 61311
+oid sha256:59481d429cbc1cd3c83a00f0c9779cbcabcec9d0884157726b23b94e3f93dd94
+size 61667
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_referrers_socials.png b/tests/UI/expected-screenshots/UIIntegrationTest_referrers_socials.png
index 9f0d9b64b2..fa7a211559 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_referrers_socials.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_referrers_socials.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cb7a1bb0cd332e21326e0027f3cf13f8ccbd8ff8d7800737773e068558b0ca34
-size 13411
+oid sha256:8fb49ec9cc4e8beae9f63031bd9b9ab9ff246746c8e4193cb64f38da2979ca7b
+size 13658
diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_shortcuts.png b/tests/UI/expected-screenshots/UIIntegrationTest_shortcuts.png
index a1d382b0a8..db2c8a3fad 100644
--- a/tests/UI/expected-screenshots/UIIntegrationTest_shortcuts.png
+++ b/tests/UI/expected-screenshots/UIIntegrationTest_shortcuts.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4db2654167d07dbc67ad6e89d40507f7ce7b3d6fd7b7d463609f64de04ee1c27
-size 35296
+oid sha256:53bf1c433b239cf4e93df897d76d1fcc5dfbdd763420057546b21704c4540ba1
+size 35295
diff --git a/tests/UI/screenshot-diffs/missing-expected.list b/tests/UI/screenshot-diffs/missing-expected.list
new file mode 100644
index 0000000000..e91347ca22
--- /dev/null
+++ b/tests/UI/screenshot-diffs/missing-expected.list
@@ -0,0 +1,381 @@
+TagManagerTeaser_superuser_page.png
+TagManagerTeaser_super_user_activate_plgin.png
+TagManagerTeaser_admin_page.png
+TagManagerTeaser_admin_page_disable.png
+TagManagerTeaser_superuser_page.png
+TagManagerTeaser_superuser_page.png
+TagManagerTeaser_super_user_activate_plgin.png
+TagManagerTeaser_admin_page.png
+TagManagerTeaser_admin_page_disable.png
+TagManagerTeaser_superuser_page.png
+TagManagerTeaser_super_user_activate_plgin.png
+TagManagerTeaser_admin_page.png
+TagManagerTeaser_admin_page_disable.png
+TagManagerTeaser_super_user_activate_plugin.png
+TagManagerTeaser_super_user_activate_plugin.png
+TagManagerTeaser_super_user_activate_plugin.png
+TagManagerTeaser_admin_page.png
+TagManagerTeaser_admin_page_disable.png
+TagManagerTeaser_admin_page_disable.png
+TagManagerTeaser_admin_page_disable.png
+Login_bruteforcelog_withentries.png
+Login_bruteforcelog_blockedapi.png
+Login_bruteforcelog_noentries.png
+Login_bruteforcelog_noentries.png
+Login_bruteforcelog_blockedlogin.png
+Login_bruteforcelog_noentries.png
+Login_bruteforcelog_noentries.png
+IntranetMeasurable_add_new_dialog.png
+IntranetMeasurable_intranet_create.png
+IntranetMeasurable_intranet_created.png
+IntranetMeasurable_intranet_create.png
+IntranetMeasurable_intranet_created.png
+TrackingFailures_widget_with_failures.png
+TrackingFailures_manage_no_failures.png
+TrackingFailures_manage_with_failures.png
+TrackingFailures_manage_with_failures_delete_one_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_with_failures_delete_all_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_widget_no_failures.png
+TrackingFailures_widget_with_failures.png
+TrackingFailures_manage_no_failures.png
+TrackingFailures_manage_with_failures.png
+TrackingFailures_manage_with_failures_delete_one_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_with_failures_delete_all_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_widget_no_failures.png
+TrackingFailures_widget_with_failures.png
+TrackingFailures_manage_no_failures.png
+TrackingFailures_manage_with_failures.png
+TrackingFailures_manage_with_failures_delete_one_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_with_failures_delete_all_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_widget_with_failures.png
+TrackingFailures_manage_no_failures.png
+TrackingFailures_manage_with_failures.png
+TrackingFailures_manage_with_failures_delete_one_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_with_failures_delete_all_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_no_failures.png
+TrackingFailures_manage_with_failures.png
+TrackingFailures_manage_with_failures_delete_one_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_with_failures_delete_all_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_all_confirmed.png
+TrackingFailures_manage_with_failures_delete_one_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_one_confirmed.png
+TrackingFailures_manage_with_failures_delete_all_ask_confirmation.png
+TrackingFailures_manage_with_failures_delete_all_confirmed.png
+CampaignBuilder_loaded.png
+CampaignBuilder_loaded.png
+CampaignBuilder_generate_url_nokeyword.png
+CampaignBuilder_generate_url_reset.png
+CampaignBuilder_generate_url_withkeyword.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes.png
+TwoFactorAuth_usersettings_twofa_enabled_required.png
+TwoFactorAuth_usersettings_twofa_disabled.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes.png
+TwoFactorAuth_usersettings_twofa_enabled_required.png
+TwoFactorAuth_usersettings_twofa_disabled.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_logme_not_verified.png
+TwoFactorAuth_logme_verified.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes.png
+TwoFactorAuth_usersettings_twofa_enabled_required.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_usersettings_twofa_disable_step2_confirm_password.png
+TwoFactorAuth_usersettings_twofa_disabled.png
+TwoFactorAuth_usersettings_twofa_setup.png
+TwoFactorAuth_logme_not_verified.png
+TwoFactorAuth_logme_not_verified_wrong_code.png
+TwoFactorAuth_logme_verified.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes.png
+TwoFactorAuth_usersettings_twofa_enabled_required.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_usersettings_twofa_disable_step2_confirm_password.png
+TwoFactorAuth_usersettings_twofa_disabled.png
+TwoFactorAuth_usersettings_twofa_setup.png
+TwoFactorAuth_logme_not_verified.png
+TwoFactorAuth_logme_not_verified_wrong_code.png
+TwoFactorAuth_logme_verified.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes.png
+TwoFactorAuth_usersettings_twofa_enabled_required.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_usersettings_twofa_disable_step2_confirm_password.png
+TwoFactorAuth_usersettings_twofa_disabled.png
+TwoFactorAuth_twofa_setup_step1.png
+TwoFactorAuth_twofa_setup_step2.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_forced_step1.png
+TwoFactorAuth_twofa_forced_step2.png
+TwoFactorAuth_twofa_forced_step3.png
+TwoFactorAuth_logme_not_verified.png
+TwoFactorAuth_logme_not_verified_wrong_code.png
+TwoFactorAuth_logme_verified.png
+TwoFactorAuth_logme_not_verified.png
+TwoFactorAuth_logme_not_verified_wrong_code.png
+TwoFactorAuth_logme_verified.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes.png
+TwoFactorAuth_usersettings_twofa_enabled_required.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_usersettings_twofa_disable_step2_confirm_password.png
+TwoFactorAuth_usersettings_twofa_disabled.png
+TwoFactorAuth_twofa_setup_step1.png
+TwoFactorAuth_twofa_setup_step2.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_forced_step1.png
+TwoFactorAuth_twofa_forced_step2.png
+TwoFactorAuth_twofa_forced_step3.png
+TwoFactorAuth_twofa_forced_step3.png
+TwoFactorAuth_usersettings_twofa_enabled.png
+TwoFactorAuth_show_recovery_codes_step1.png
+TwoFactorAuth_usersettings_twofa_disable_step1.png
+TwoFactorAuth_usersettings_twofa_disable_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_twofa_setup_step1.png
+TwoFactorAuth_twofa_setup_step2.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+TwoFactorAuth_show_recovery_codes_step1.png
+TwoFactorAuth_show_recovery_codes_step2.png
+TwoFactorAuth_show_recovery_codes_step1.png
+TwoFactorAuth_show_recovery_codes_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_twofa_setup_step1.png
+TwoFactorAuth_twofa_setup_step2.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+TwoFactorAuth_show_recovery_codes_step1.png
+TwoFactorAuth_show_recovery_codes_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_twofa_setup_step1.png
+TwoFactorAuth_twofa_setup_step2.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+TwoFactorAuth_show_recovery_codes_step1.png
+TwoFactorAuth_show_recovery_codes_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_usersettings_twofa_disable_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_usersettings_twofa_disable_step2.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_twofa_setup_step1.png
+TwoFactorAuth_twofa_setup_step2.png
+TwoFactorAuth_twofa_setup_step3.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_usersettings_twofa_disable_step3.png
+TwoFactorAuth_usersettings_twofa_disable_step4.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+TwoFactorAuthUsersManager_list.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_tab.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirm.png
+TwoFactorAuthUsersManager_edit_with_2fa_reset_confirmed.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+TwoFactorAuth_widgetized_no_verify.png
+TwoFactorAuth_widgetized_no_verify.png
+TwoFactorAuth_widgetized_no_verify.png
+TwoFactorAuth_widgetized_no_verify.png
+TwoFactorAuth_twofa_setup_step4.png
+TwoFactorAuth_twofa_forced_step4.png
+CustomTranslationReporting_simplePage.png
+CustomTranslationManage_loaded.png
+CustomTranslationManage_values_entered.png
+CustomTranslationManage_values_saved.png
+CustomTranslationManage_values_saved_verify.png
+CustomTranslationManage_loaded.png
+CustomTranslationManage_values_entered.png
+CustomTranslationManage_values_saved.png
+CustomTranslationManage_values_saved_verify.png
+CustomTranslationManage_loaded.png
+CustomTranslationManage_values_entered.png
+CustomTranslationManage_values_saved.png
+CustomTranslationManage_values_saved_verify.png
+CustomTranslationReporting_menu_loaded_dashboards.png
+CustomTranslationReporting_menu_loaded_visitors.png
+CustomTranslationReporting_menu_loaded_behaviour.png
+CustomTranslationReporting_menu_loaded_customreports.png
+CustomTranslationReporting_dashboard_rename.png
+CustomTranslationReporting_report_eventActionName.png
+CustomTranslationReporting_report_eventCategoryAction.png
+CustomTranslationReporting_report_visitDimension1.png
+CustomTranslationReporting_report_actionDimension3.png
+CustomTranslationReporting_report_customReports1.png
+CustomTranslationReporting_simplePagePartial.png
+CustomTranslationReporting_menu_loaded_customreports.png
+CustomTranslationReporting_dashboard_rename.png
+CustomTranslationReporting_report_eventActionName.png
+CustomTranslationReporting_report_eventActionName_row_evolution.png
+CustomTranslationReporting_report_eventActionName_segmented_visitor_log.png
+CustomTranslationReporting_report_eventActionName_search.png
+CustomTranslationReporting_report_eventActionName_bar.png
+CustomTranslationReporting_report_eventActionName_pie.png
+CustomTranslationReporting_report_eventActionName_pivoted.png
+CustomTranslationReporting_report_eventCategoryAction.png
+CustomTranslationReporting_report_visitDimension1.png
+CustomTranslationReporting_report_visitDimension1_row_evolution.png
+CustomTranslationReporting_report_visitDimension1_segmented_visitor_log.png
+CustomTranslationReporting_report_visitDimension1_search.png
+CustomTranslationReporting_report_visitDimension1_bar.png
+CustomTranslationReporting_report_visitDimension1_pie.png
+CustomTranslationReporting_report_visitDimension2.png
+CustomTranslationReporting_report_actionDimension3.png
+CustomTranslationReporting_report_actionDimension3_row_evolution.png
+CustomTranslationReporting_report_actionDimension3_segmented_visitor_log.png
+CustomTranslationReporting_report_actionDimension3_search.png
+CustomTranslationReporting_report_actionDimension3_bar.png
+CustomTranslationReporting_report_actionDimension3_pie.png
+CustomTranslationReporting_report_actionDimension4.png
+CustomTranslationReporting_report_customReports1.png
+CustomTranslationReporting_report_customReports1_row_evolution.png
+CustomTranslationReporting_report_customReports1_segmented_visitor_log.png
+CustomTranslationReporting_report_customReports1_search.png
+CustomTranslationReporting_report_customReports1_bar.png
+CustomTranslationReporting_report_customReports1_pie.png
+CustomTranslationReporting_report_customReports2.png
+CustomTranslationReporting_menu_loaded_customreports.png
+CustomTranslationReporting_report_eventActionName.png
+CustomTranslationReporting_report_eventActionName_row_evolution.png
+CustomTranslationReporting_report_eventActionName_segmented_visitor_log.png
+CustomTranslationReporting_report_eventActionName_search.png
+CustomTranslationReporting_report_eventActionName_bar.png
+CustomTranslationReporting_report_eventCategoryAction.png
+CustomTranslationReporting_report_visitDimension1.png
+CustomTranslationReporting_report_visitDimension1_row_evolution.png
+CustomTranslationReporting_report_visitDimension1_segmented_visitor_log.png
+CustomTranslationReporting_report_visitDimension1_search.png
+CustomTranslationReporting_report_visitDimension1_bar.png
+CustomTranslationReporting_report_visitDimension1_pie.png
+CustomTranslationReporting_report_visitDimension2.png
+CustomTranslationReporting_report_actionDimension3_row_evolution.png
+CustomTranslationReporting_report_actionDimension3_segmented_visitor_log.png
+CustomTranslationReporting_report_actionDimension3_search.png
+CustomTranslationReporting_report_actionDimension4.png
+CustomTranslationReporting_report_customReports1.png
+CustomTranslationReporting_report_customReports1_row_evolution.png
+CustomTranslationReporting_report_customReports1_segmented_visitor_log.png
+CustomTranslationReporting_report_customReports1_search.png
+CustomTranslationReporting_report_customReports1_bar.png
+CustomTranslationReporting_report_customReports1_pie.png
+CustomTranslationReporting_report_customReports2.png
+CustomTranslationReporting_report_customReports3.png
+CustomTranslationReporting_report_customReports4.png
+CustomTranslationReporting_menu_loaded_customreports.png
+CustomTranslationReporting_report_eventActionName.png
+CustomTranslationReporting_report_eventActionName_row_evolution.png
+CustomTranslationReporting_report_eventActionName_segmented_visitor_log.png
+CustomTranslationReporting_report_eventActionName_search.png
+CustomTranslationReporting_report_eventActionName_bar.png
+CustomTranslationReporting_report_eventCategoryAction.png
+CustomTranslationReporting_report_visitDimension1.png
+CustomTranslationReporting_report_visitDimension1_row_evolution.png
+CustomTranslationReporting_report_visitDimension1_segmented_visitor_log.png
+CustomTranslationReporting_report_visitDimension1_search.png
+CustomTranslationReporting_report_visitDimension1_bar.png
+CustomTranslationReporting_menu_loaded_customreports.png
+CustomTranslationReporting_report_eventActionName.png
+CustomTranslationReporting_report_eventActionName_row_evolution.png
+CustomTranslationReporting_report_eventActionName_segmented_visitor_log.png
+CustomTranslationReporting_report_eventActionName_search.png
+CustomTranslationReporting_report_eventActionName_bar.png
+CustomTranslationReporting_report_eventCategoryAction.png
+CustomTranslationReporting_report_visitDimension1.png
+CustomTranslationReporting_report_visitDimension1_row_evolution.png
+CustomTranslationReporting_report_visitDimension1_segmented_visitor_log.png
+CustomTranslationReporting_report_visitDimension1_search.png
+CustomTranslationReporting_report_visitDimension1_bar.png
+CustomTranslationReporting_report_visitDimension1_pie.png
+CustomTranslationReporting_report_visitDimension2.png
+CustomTranslationReporting_report_actionDimension3_row_evolution.png
+CustomTranslationReporting_report_actionDimension3_segmented_visitor_log.png
+CustomTranslationReporting_report_actionDimension4.png
+CustomTranslationReporting_report_customReports1.png
+CustomTranslationReporting_report_customReports2.png
+CustomTranslationReporting_report_customReports3.png
+CustomTranslationReporting_report_customReports3_row_evolution.png
+CustomTranslationReporting_report_customReports3_segmented_visitor_log.png
+CustomTranslationReporting_report_customReports3_search.png
+CustomTranslationReporting_report_customReports3_bar.png
+CustomTranslationReporting_report_customReports3_pie.png
+CustomTranslationReporting_report_customReports3_flat.png
+CustomTranslationReporting_report_customReports4.png
+CustomTranslationReporting_menu_loaded_customreports.png
+CustomTranslationReporting_report_eventActionName_row_evolution.png
+CustomTranslationReporting_report_eventActionName_segmented_visitor_log.png
+CustomTranslationReporting_report_eventActionName_search.png
+CustomTranslationReporting_report_eventActionName_bar.png
+CustomTranslationReporting_menu_loaded_dashboards.png
+CustomTranslationReporting_report_eventActionName_row_evolution.png
+CustomTranslationReporting_report_eventActionName_segmented_visitor_log.png
+CustomTranslationReporting_report_eventActionName_search.png
+CustomTranslationReporting_report_eventCategoryAction.png
+CustomTranslationReporting_report_visitDimension1_row_evolution.png
+CustomTranslationReporting_report_visitDimension1_segmented_visitor_log.png
+CustomTranslationReporting_report_visitDimension2.png
+CustomTranslationReporting_report_actionDimension3_row_evolution.png
+CustomTranslationReporting_report_actionDimension3_segmented_visitor_log.png
+CustomTranslationReporting_report_actionDimension4.png
+CustomTranslationReporting_report_customReports3_row_evolution.png
+CustomTranslationReporting_report_customReports3_segmented_visitor_log.png
+CustomTranslationReporting_report_customReports3_flat.png
+CustomTranslationReporting_report_customReports5.png
+CustomTranslationReporting_report_customReports6.png
+CustomTranslationReporting_report_customReports7.png
+CustomTranslationReporting_menu_loaded_dashboards.png
+CustomTranslationReporting_menu_loaded_dashboards.png
+CustomTranslationReporting_menu_loaded_dashboards.png
+CustomTranslationReporting_manage_custom_reports_admin.png
+CustomTranslationReporting_menu_loaded_dashboards.png
+CustomTranslationReporting_manage_custom_reports_admin.png
+CustomTranslationReporting_manage_custom_reports_admin.png
+CustomTranslationReporting_manage_custom_dimensions_admin.png