From a646d4314e77b909f16ce5a85e4b38dcf0747cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Mon, 30 Oct 2017 15:05:14 +0100 Subject: [PATCH 01/15] Add generic interface for second authentication factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- .../classes/Plugins/AuthenticationPlugin.php | 40 ++++ .../classes/Plugins/SecondFactor/Simple.php | 64 ++++++ .../classes/Plugins/SecondFactorPlugin.php | 114 +++++++++ libraries/classes/SecondFactor.php | 216 ++++++++++++++++++ libraries/classes/UserPreferences.php | 1 + libraries/common.inc.php | 2 + libraries/config.default.php | 7 + templates/login/second.twig | 5 + templates/login/second/simple.twig | 1 + test/classes/SecondFactorTest.php | 90 ++++++++ 10 files changed, 540 insertions(+) create mode 100644 libraries/classes/Plugins/SecondFactor/Simple.php create mode 100644 libraries/classes/Plugins/SecondFactorPlugin.php create mode 100644 libraries/classes/SecondFactor.php create mode 100644 templates/login/second.twig create mode 100644 templates/login/second/simple.twig create mode 100644 test/classes/SecondFactorTest.php diff --git a/libraries/classes/Plugins/AuthenticationPlugin.php b/libraries/classes/Plugins/AuthenticationPlugin.php index a153f8f190..2556e7543d 100644 --- a/libraries/classes/Plugins/AuthenticationPlugin.php +++ b/libraries/classes/Plugins/AuthenticationPlugin.php @@ -7,11 +7,16 @@ */ namespace PhpMyAdmin\Plugins; +use PhpMyAdmin\Config; use PhpMyAdmin\Core; use PhpMyAdmin\IpAllowDeny; use PhpMyAdmin\Logging; +use PhpMyAdmin\Message; +use PhpMyAdmin\Response; use PhpMyAdmin\Sanitize; +use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Session; +use PhpMyAdmin\Template; use PhpMyAdmin\Url; /** @@ -297,4 +302,39 @@ abstract class AuthenticationPlugin $this->showFailure('empty-denied'); } } + + /** + * Checks whether two factor authentication is active + * for given user and performs it. + * + * @return void + */ + public function checkSecondFactor() + { + $second = new SecondFactor($this->user); + + /* Do we need to show the form? */ + if ($second->check()) { + return; + } + + $response = Response::getInstance(); + if ($response->loginPage()) { + if (defined('TESTSUITE')) { + return true; + } else { + exit; + } + } + echo Template::get('login/header')->render(['theme' => $GLOBALS['PMA_Theme']]); + Message::rawNotice( + __('You have enabled two factor authentication, please confirm your login.') + )->display(); + echo Template::get('login/second')->render(['form' => $second->render()]); + echo Template::get('login/footer')->render(); + echo Config::renderFooter(); + if (! defined('TESTSUITE')) { + exit; + } + } } diff --git a/libraries/classes/Plugins/SecondFactor/Simple.php b/libraries/classes/Plugins/SecondFactor/Simple.php new file mode 100644 index 0000000000..7a628e14bb --- /dev/null +++ b/libraries/classes/Plugins/SecondFactor/Simple.php @@ -0,0 +1,64 @@ +render(); + } + + /** + * Get user visible name + * + * @return string + */ + public static function getName() + { + return __('Simple second factor'); + } + + /** + * Get user visible description + * + * @return string + */ + public static function getDescription() + { + return __('For testing purposes only!'); + } +} diff --git a/libraries/classes/Plugins/SecondFactorPlugin.php b/libraries/classes/Plugins/SecondFactorPlugin.php new file mode 100644 index 0000000000..8a8a534ded --- /dev/null +++ b/libraries/classes/Plugins/SecondFactorPlugin.php @@ -0,0 +1,114 @@ +_user = $user; + $this->_config = $config; + } + + /** + * Checks authentication, returns true on success + * + * @return boolean + */ + public function check() + { + return true; + } + + /** + * Renders user interface to enter second factor + * + * @return string HTML code + */ + public function render() + { + return ''; + } + + /** + * Renders user interface to configure second factor + * + * @return string HTML code + */ + public function setup() + { + return ''; + } + + /** + * Performs backend configuration + * + * @return boolean + */ + public function configure() + { + return true; + } + + /** + * Return current configuration + * + * @return array + */ + public function getConfig() + { + return $this->_config; + } + + /** + * Get user visible name + * + * @return string + */ + public static function getName() + { + return __('None two-factor'); + } + + /** + * Get user visible description + * + * @return string + */ + public static function getDescription() + { + return __('Login using password only.'); + } +} diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php new file mode 100644 index 0000000000..7f47de5dc4 --- /dev/null +++ b/libraries/classes/SecondFactor.php @@ -0,0 +1,216 @@ +_user = $user; + $this->_available = $this->getAvailable(); + $this->_config = $this->readConfig(); + $this->_writable = ($this->_config['type'] == 'db'); + $this->_backend = $this->getBackend(); + } + + /** + * Reads the configuration + * + * @return array + */ + public function readConfig() + { + $result = []; + $config = UserPreferences::load(); + if (isset($config['config_data']['2fa'])) { + $result = $config['config_data']['2fa']; + } + $result['type'] = $config['type']; + if (! isset($result['backend'])) { + $result['backend'] = ''; + } + if (! isset($result['settings'])) { + $result['settings'] = []; + } + return $result; + } + + /** + * Get any property of this class + * + * @param string $property name of the property + * + * @return mixed|void if property exist, value of the relevant property + */ + public function __get($property) + { + switch ($property) { + case 'backend': + return $this->_backend; + case 'available': + return $this->_available; + case 'writable': + return $this->_writable; + } + } + + /** + * Returns list of available backends + * + * @return array + */ + public function getAvailable() + { + $result = []; + if ($GLOBALS['cfg']['DBG']['simple2fa']) { + $result[] = 'simple'; + } + if (class_exists('PragmaRX\Google2FA\Google2FA') && class_exists('BaconQrCode\Renderer\Image\Png')) { + $result[] = 'application'; + } + return $result; + } + + /** + * Returns class name for given name + * + * @param string $name Backend name + * + * @return string + */ + public function getBackendClass($name) + { + $result = 'PhpMyAdmin\\Plugins\\SecondFactorPlugin'; + if (in_array($name, $this->_available)) { + $result = 'PhpMyAdmin\\Plugins\\SecondFactor\\' . ucfirst($name); + } + return $result; + } + + /** + * Returns backend for current user + * + * @return PhpMyAdmin\Plugins\SecondFactorPlugin + */ + public function getBackend() + { + $name = $this->getBackendClass($this->_config['backend']); + return new $name($this->_user, $this->_config['settings']); + } + + /** + * Checks authentication, returns true on success + * + * @param boolean $skip_session Skip session cache + * + * @return boolean + */ + public function check($skip_session = false) + { + if ($skip_session) { + return $this->_backend->check(); + } + if (empty($_SESSION['second_factor_check'])) { + $_SESSION['second_factor_check'] = $this->_backend->check(); + } + return $_SESSION['second_factor_check']; + } + + /** + * Renders user interface to enter second factor + * + * @return string HTML code + */ + public function render() + { + return $this->_backend->render(); + } + + /** + * Renders user interface to configure second factor + * + * @return string HTML code + */ + public function setup() + { + return $this->_backend->setup(); + } + + /** + * Changes second factor settings + * + * The object might stay in partialy changed setup + * if configuration fails. + * + * @param string $name Backend name + * + * @return boolean + */ + public function configure($name) + { + $config = [ + 'backend' => $name + ]; + if ($name === '') { + $cls = $this->getBackendClass($name); + $this->_backend = new $cls($this->_user, []); + $config['settings'] = []; + } else { + if (! in_array($name, $this->_available)) { + return false; + } + $cls = $this->getBackendClass($name); + $this->_backend = new $cls($this->_user, []); + if (! $this->_backend->configure()) { + return false; + } + $config['settings'] = $this->_backend->getConfig(); + } + $result = UserPreferences::persistOption('2fa', $config, null); + if ($result !== true) { + $result->display(); + } + $this->_config = $config['settings']; + return true; + } +} diff --git a/libraries/classes/UserPreferences.php b/libraries/classes/UserPreferences.php index e79c570b33..05e90ab4a9 100644 --- a/libraries/classes/UserPreferences.php +++ b/libraries/classes/UserPreferences.php @@ -173,6 +173,7 @@ class UserPreferences $whitelist['collation_connection'] = true; $whitelist['Server/hide_db'] = true; $whitelist['Server/only_db'] = true; + $whitelist['2fa'] = true; foreach ($config_data as $path => $value) { if (! isset($whitelist[$path]) || isset($blacklist[$path])) { continue; diff --git a/libraries/common.inc.php b/libraries/common.inc.php index f14d66ac63..a1e9632bf8 100644 --- a/libraries/common.inc.php +++ b/libraries/common.inc.php @@ -544,6 +544,8 @@ if (! defined('PMA_MINIMUM_COMMON')) { $auth_plugin->rememberCredentials(); + $auth_plugin->checkSecondFactor(); + /* Log success */ Logging::logUser($cfg['Server']['user']); diff --git a/libraries/config.default.php b/libraries/config.default.php index 5e8893804c..95f313fb3c 100644 --- a/libraries/config.default.php +++ b/libraries/config.default.php @@ -3058,6 +3058,13 @@ $cfg['DBG']['sqllog'] = false; */ $cfg['DBG']['demo'] = false; +/** + * Enable Simple second factor + * + * @global boolean $cfg['DBG']['simple2fa'] + */ +$cfg['DBG']['simple2fa'] = false; + /******************************************************************************* * MySQL settings diff --git a/templates/login/second.twig b/templates/login/second.twig new file mode 100644 index 0000000000..20a9833df5 --- /dev/null +++ b/templates/login/second.twig @@ -0,0 +1,5 @@ +
+{{ Url_getHiddenInputs() }} +{{ form|raw }} + +
diff --git a/templates/login/second/simple.twig b/templates/login/second/simple.twig new file mode 100644 index 0000000000..7fd98241d5 --- /dev/null +++ b/templates/login/second/simple.twig @@ -0,0 +1 @@ + diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php new file mode 100644 index 0000000000..6c197c6977 --- /dev/null +++ b/test/classes/SecondFactorTest.php @@ -0,0 +1,90 @@ +getMockbuilder('PhpMyAdmin\SecondFactor') + ->setMethods(['readConfig']) + ->disableOriginalConstructor() + ->getMock(); + $result->method('readConfig')->willReturn($config); + $result->__construct($user); + return $result; + } + + public function testNone() + { + $object = $this->getSecondFactorMock('user', ['type' => 'db']); + $backend = $object->backend; + $this->assertEquals('', $backend::$id); + $this->assertTrue($object->check(true)); + $this->assertEquals('', $object->render()); + $this->assertTrue($object->configure('')); + $this->assertEquals('', $object->setup()); + } + + public function testSimple() + { + $GLOBALS['cfg']['DBG']['simple2fa'] = true; + $object = $this->getSecondFactorMock('user', ['type' => 'db', 'backend' => 'simple']); + $backend = $object->backend; + $this->assertEquals('simple', $backend::$id); + $GLOBALS['cfg']['DBG']['simple2fa'] = false; + } + + public function testLoad() + { + $object = new SecondFactor('user'); + $backend = $object->backend; + $this->assertEquals('', $backend::$id); + } + + public function testConfigureSimple() + { + $GLOBALS['cfg']['DBG']['simple2fa'] = true; + $object = new SecondFactor('user'); + $this->assertTrue($object->configure('simple')); + $backend = $object->backend; + $this->assertEquals('simple', $backend::$id); + $this->assertTrue($object->configure('')); + $backend = $object->backend; + $this->assertEquals('', $backend::$id); + $GLOBALS['cfg']['DBG']['simple2fa'] = false; + $object = new SecondFactor('user'); + $this->assertFalse($object->configure('simple')); + } +} From 869131f59cdc075f11008cba79e9a2b67e38fda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Tue, 31 Oct 2017 08:58:33 +0100 Subject: [PATCH 02/15] Add support for HOTP and TOTP authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This supports Google Authenticator and similar applications. Issue #6197 Signed-off-by: Michal Čihař --- composer.json | 8 +- .../Plugins/SecondFactor/Application.php | 154 ++++++++++++++++++ libraries/classes/SecondFactor.php | 2 + scripts/create-release.sh | 2 +- templates/login/second/application.twig | 4 + .../login/second/application_configure.twig | 10 ++ test/ci-install-test | 2 + test/classes/SecondFactorTest.php | 22 +++ 8 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 libraries/classes/Plugins/SecondFactor/Application.php create mode 100644 templates/login/second/application.twig create mode 100644 templates/login/second/application_configure.twig diff --git a/composer.json b/composer.json index fd98c5ba3e..3e49d49291 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,9 @@ "symfony/polyfill-mbstring": "^1.3" }, "conflict": { - "tecnickcom/tcpdf": "<6.2" + "tecnickcom/tcpdf": "<6.2", + "pragmarx/google2fa": "<2.0", + "bacon/bacon-qr-code": "<1.0" }, "suggest": { "ext-openssl": "Cookie encryption", @@ -68,7 +70,9 @@ "ext-zip": "For zip import and export", "ext-gd2": "For image transformations", "ext-mbstring": "For best performance", - "tecnickcom/tcpdf": "For PDF support" + "tecnickcom/tcpdf": "For PDF support", + "pragmarx/google2fa": "For 2FA authentication", + "bacon/bacon-qr-code": "For 2FA authentication" }, "require-dev": { "phpunit/phpunit": "~4.1", diff --git a/libraries/classes/Plugins/SecondFactor/Application.php b/libraries/classes/Plugins/SecondFactor/Application.php new file mode 100644 index 0000000000..aa262969d6 --- /dev/null +++ b/libraries/classes/Plugins/SecondFactor/Application.php @@ -0,0 +1,154 @@ +_google2fa = new Google2FA(); + $this->_google2fa->setWindow(8); + } + + /** + * Get any property of this class + * + * @param string $property name of the property + * + * @return mixed|void if property exist, value of the relevant property + */ + public function __get($property) + { + switch ($property) { + case 'google2fa': + return $this->_google2fa; + case 'config': + return $this->_config; + } + } + + /** + * Checks authentication, returns true on success + * + * @return boolean + */ + public function check() + { + $this->_provided = false; + if (!isset($_POST['2fa_code']) || !isset($this->_config['secret'])) { + return false; + } + $this->_provided = true; + return $this->_google2fa->verifyKey( + $this->_config['secret'], $_POST['2fa_code'] + ); + } + + /** + * Renders user interface to enter second factor + * + * @return string HTML code + */ + public function render() + { + if ($this->_provided) { + Message::rawError( + __('Two-factor authentication failed.') + )->display(); + } + return Template::get('login/second/application')->render(); + } + + /** + * Renders user interface to configure second factor + * + * @return string HTML code + */ + public function setup() + { + if ($this->_provided) { + Message::rawError( + __('Two-factor authentication failed.') + )->display(); + } + $inlineUrl = $this->_google2fa->getQRCodeInline( + 'phpMyAdmin', + $this->_user, + $this->_config['secret'] + ); + return Template::get('login/second/application_configure')->render([ + 'image' => $inlineUrl, + ]); + } + + /** + * Performs backend configuration + * + * @return boolean + */ + public function configure() + { + if (! isset($_SESSION['2fa_application_key'])) { + $_SESSION['2fa_application_key'] = $this->_google2fa->generateSecretKey(); + } + $this->_config['secret'] = $_SESSION['2fa_application_key']; + + $result = $this->check(); + if ($result) { + unset($_SESSION['2fa_application_key']); + } + return $result; + } + + /** + * Get user visible name + * + * @return string + */ + public static function getName() + { + return __('Authentication application'); + } + + /** + * Get user visible description + * + * @return string + */ + public static function getDescription() + { + return __('Provides authentication using HOTP and TOTP applications such as FreeOTP, Google Authenticator or Authy.'); + } +} + diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php index 7f47de5dc4..83bdf4f1b2 100644 --- a/libraries/classes/SecondFactor.php +++ b/libraries/classes/SecondFactor.php @@ -91,6 +91,8 @@ class SecondFactor return $this->_available; case 'writable': return $this->_writable; + case 'config': + return $this->_config; } } diff --git a/scripts/create-release.sh b/scripts/create-release.sh index de1c7e0626..7747c2f5f8 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -228,7 +228,7 @@ if [ ! -d libraries/tcpdf ] ; then # suggested package. Let's require it and then revert # composer.json to original state. cp composer.json composer.json.backup - composer require --update-no-dev tecnickcom/tcpdf + composer require --update-no-dev tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code mv composer.json.backup composer.json echo "* Cleanup of composer packages" rm -rf \ diff --git a/templates/login/second/application.twig b/templates/login/second/application.twig new file mode 100644 index 0000000000..1a919dc73b --- /dev/null +++ b/templates/login/second/application.twig @@ -0,0 +1,4 @@ +

+ +

+

{% trans "Open the two-factor authentication app on your device to view your authentication code and verify your identity." %}

diff --git a/templates/login/second/application_configure.twig b/templates/login/second/application_configure.twig new file mode 100644 index 0000000000..40a67ad552 --- /dev/null +++ b/templates/login/second/application_configure.twig @@ -0,0 +1,10 @@ +{{ Url_getHiddenInputs() }} +

+{% trans "Please scan following QR code into the two-factor authentication app on your device and enter authentication code it generates." %} +

+

+ +

+

+ +

diff --git a/test/ci-install-test b/test/ci-install-test index 4917bc7ed8..a32fbf74a3 100755 --- a/test/ci-install-test +++ b/test/ci-install-test @@ -2,3 +2,5 @@ ./test/install-runkit composer install --no-interaction +# Install optional deps +composer require tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 6c197c6977..1baef7da87 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -87,4 +87,26 @@ class SecondFactorTest extends PmaTestCase $object = new SecondFactor('user'); $this->assertFalse($object->configure('simple')); } + + public function testApplication() + { + $object = new SecondFactor('user'); + if (! in_array('application', $object->available)) { + $this->markTestSkipped('google2fa not available'); + } + /* Without providing code this should fail */ + $this->assertFalse($object->configure('application')); + + /* Invalid code */ + $_POST['2fa_code'] = 'invalid'; + $this->assertFalse($object->configure('application')); + + /* Generate valid code */ + $google2fa = $object->backend->google2fa; + $_POST['2fa_code'] = $google2fa->oathHotp( + $object->backend->config['secret'], + $google2fa->getTimestamp() + ); + $this->assertTrue($object->configure('application')); + } } From 8c9abb9888b37a3550ec80e87b7bbdc122d5ccf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Tue, 31 Oct 2017 13:31:09 +0100 Subject: [PATCH 03/15] Add configuration for second authentication factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- libraries/user_preferences.inc.php | 8 ++++ prefs_second.php | 63 +++++++++++++++++++++++++++ templates/prefs_second.twig | 53 ++++++++++++++++++++++ templates/prefs_second_configure.twig | 13 ++++++ templates/prefs_second_confirm.twig | 12 +++++ 5 files changed, 149 insertions(+) create mode 100644 prefs_second.php create mode 100644 templates/prefs_second.twig create mode 100644 templates/prefs_second_configure.twig create mode 100644 templates/prefs_second_confirm.twig diff --git a/libraries/user_preferences.inc.php b/libraries/user_preferences.inc.php index 693c2538bc..eabe371b94 100644 --- a/libraries/user_preferences.inc.php +++ b/libraries/user_preferences.inc.php @@ -9,6 +9,7 @@ use PhpMyAdmin\Config\Forms\User\UserFormList; use PhpMyAdmin\Message; use PhpMyAdmin\Relation; use PhpMyAdmin\Sanitize; +use PhpMyAdmin\SecondFactor; if (!defined('PHPMYADMIN')) { exit; @@ -30,6 +31,13 @@ $content = PhpMyAdmin\Util::getHtmlTab( 'text' => __('Manage your settings') ) ) . "\n"; +/* Second authentication factor */ +$content .= PhpMyAdmin\Util::getHtmlTab( + array( + 'link' => 'prefs_second.php', + 'text' => __('Two-factor authentication') + ) +) . "\n"; $script_name = basename($GLOBALS['PMA_PHP_SELF']); foreach (UserFormList::getAll() as $formset) { $formset_class = UserFormList::get($formset); diff --git a/prefs_second.php b/prefs_second.php new file mode 100644 index 0000000000..27e626a387 --- /dev/null +++ b/prefs_second.php @@ -0,0 +1,63 @@ +check(true)) { + echo Template::get('prefs_second_confirm')->render([ + 'form' => $second_factor->render(), + ]); + exit; + } else { + $second_factor->configure(''); + Message::rawNotice(__('Two-factor authentication has been removed.'))->display(); + } +} elseif (isset($_POST['2fa_configure'])) { + if (! $second_factor->configure($_POST['2fa_configure'])) { + echo Template::get('prefs_second_configure')->render([ + 'form' => $second_factor->setup(), + 'configure' => $_POST['2fa_configure'], + ]); + exit; + } else { + Message::rawNotice(__('Two-factor authentication has been configured.'))->display(); + } +} + +$all = array_merge([''], $second_factor->available); +$backends = []; +foreach ($all as $name) { + $cls = $second_factor->getBackendClass($name); + $backends[] = [ + 'id' => $cls::$id, + 'name' => $cls::getName(), + 'description' => $cls::getDescription(), + ]; +} + + +$backend = $second_factor->backend; +echo Template::get('prefs_second')->render([ + 'enabled' => $second_factor->writable, + 'num_backends' => count($second_factor->available), + 'backend_id' => $backend::$id, + 'backend_name' => $backend::getName(), + 'backend_description' => $backend::getDescription(), + 'backends' => $backends, +]); diff --git a/templates/prefs_second.twig b/templates/prefs_second.twig new file mode 100644 index 0000000000..ef3b83204e --- /dev/null +++ b/templates/prefs_second.twig @@ -0,0 +1,53 @@ +
+

+{% trans "Two-factor authentication status" %} +{{ Util_showDocu('second_factor') }} +

+
+{% if enabled %} +{% if num_backends == 0 %} +

{% trans "Two-factor authentication is not available, please install optional dependencies to enable authentication backends." %}

+{% else %} +{% if backend_id %} +

{% trans "Two-factor authentication is available and configured for this account." %}

+{% else %} +

{% trans "Two-factor authentication is available, but not configured for this account." %}

+{% endif %} +{% endif %} +{% else %} +

{% trans "Two-factor authentication is not available, enable phpMyAdmin configuration storage to use it." %}

+{% endif %} +
+
+ +{% if backend_id %} +
+

{{ backend_name }}

+
+

{% trans "You have enabled two factor authentication." %}

+

{{ backend_description }}

+
+{{ Url_getHiddenInputs() }} + +
+
+
+{% else %} +
+

{% trans "Configure two-factor authentication" %}

+
+
+{{ Url_getHiddenInputs() }} +
+
+
+{% endif %} diff --git a/templates/prefs_second_configure.twig b/templates/prefs_second_configure.twig new file mode 100644 index 0000000000..cffc6c9cc2 --- /dev/null +++ b/templates/prefs_second_configure.twig @@ -0,0 +1,13 @@ +
+

{% trans "Configure two-factor authentication" %}

+
+
+{{ Url_getHiddenInputs() }} + +{{ form|raw }} + +
+
+
+ + diff --git a/templates/prefs_second_confirm.twig b/templates/prefs_second_confirm.twig new file mode 100644 index 0000000000..091983e205 --- /dev/null +++ b/templates/prefs_second_confirm.twig @@ -0,0 +1,12 @@ +
+

{% trans "Confirm disabling two-factor authentication" %}

+
+
+{{ Message_notice("By disabling two factor authentication you will be again able to login using password only."|trans) }} +{{ Url_getHiddenInputs() }} +{{ form|raw }} + + +
+
+
From 047a6ac3f7ee53f9ff2324a60bd5a6678474aa32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Tue, 31 Oct 2017 13:31:32 +0100 Subject: [PATCH 04/15] Add documentation for second authentication factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- doc/config.rst | 7 +++++++ doc/second_factor.rst | 41 +++++++++++++++++++++++++++++++++++++++++ doc/setup.rst | 1 + doc/user.rst | 1 + 4 files changed, 50 insertions(+) create mode 100644 doc/second_factor.rst diff --git a/doc/config.rst b/doc/config.rst index 91365b1c41..a1255ae644 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -3345,6 +3345,13 @@ Developer * The setup script is enabled even with existing configuration. * The setup does not try to connect to the MySQL server. +.. config:option:: $cfg['DBG']['simple2fa'] + + :type: boolean + :default: false + + Can be used for testing second authentication factor. + .. _config-examples: Examples diff --git a/doc/second_factor.rst b/doc/second_factor.rst new file mode 100644 index 0000000000..a1a0d03c9e --- /dev/null +++ b/doc/second_factor.rst @@ -0,0 +1,41 @@ +.. _2fa: + +Second authentication factor +============================ + +.. versionadded:: 4.8.0 + +Since phpMyAdmin 4.8.0 you can configure second authentication factor to be +used when logging into it. To use this, you first need to configure +:ref:`linked-tables`. Once this is done, every user can opt-in for second +authentication factor in the :guilabel:`Settings`. + +Authentication Application +-------------------------- + +Using application for authentication is quite common approach based on HOTP and +TOTP. It is based on transmitting private key from phpMyAdmin to the +authentication application and the application is then able to generate one +time codes based on this key. + +There are dozens of applications available for mobile phones to implement these +standards, the most widely used include: + +* `FreeOTP for iOS, Android and Pebble `_ +* `Authy for iOS, Android, Chrome, OS X `_ +* `Google Authenticator for iOS `_ +* `Google Authenticator for Android `_ +* `Google Authenticator (port) on Windows Store `_ +* `Microsoft Authenticator for Windows Phone `_ +* `LastPass Authenticator for iOS, Android, OS X, Windows `_ +* `1Password for iOS, Android, OS X, Windows `_ + +Simple Second Factor +-------------------- + +This authentication is included for testing and demostration purposes only as +it really does not provide second factor, it just asks user to confirm login by +clicking on the button. + +It should not be used in the production and is disabled unless +:config:option:`$cfg['DBG']['simple2fa']` is set. diff --git a/doc/setup.rst b/doc/setup.rst index 6217a2c6dc..0c1a49cb54 100644 --- a/doc/setup.rst +++ b/doc/setup.rst @@ -1030,6 +1030,7 @@ are always ways to make your installation more secure: * In case you don't want all MySQL users to be able to access phpMyAdmin, you can use :config:option:`$cfg['Servers'][$i]['AllowDeny']['rules']` to limit them or :config:option:`$cfg['Servers'][$i]['AllowRoot']` to deny root user access. +* Enable :ref:`2fa` for your account. * Consider hiding phpMyAdmin behind an authentication proxy, so that users need to authenticate prior to providing MySQL credentials to phpMyAdmin. You can achieve this by configuring your web server to request diff --git a/doc/user.rst b/doc/user.rst index 1a89952291..7321989ced 100644 --- a/doc/user.rst +++ b/doc/user.rst @@ -5,6 +5,7 @@ User Guide :maxdepth: 2 settings + second_factor transformations bookmarks privileges From 540b78dc2ed78e77a6d7c33852a29297a758b3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Tue, 31 Oct 2017 16:12:51 +0100 Subject: [PATCH 05/15] Add support for FIDO U2F authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- composer.json | 6 +- doc/second_factor.rst | 19 +- js/messages.php | 4 + js/u2f.js | 55 ++ js/vendor/u2f-api.js | 748 ++++++++++++++++++ .../classes/Plugins/SecondFactor/Key.php | 241 ++++++ libraries/classes/SecondFactor.php | 3 + scripts/create-release.sh | 2 +- templates/login/second/key.twig | 4 + templates/login/second/key_configure.twig | 4 + test/ci-install-test | 2 +- test/classes/SecondFactorTest.php | 14 + 12 files changed, 1094 insertions(+), 8 deletions(-) create mode 100644 js/u2f.js create mode 100644 js/vendor/u2f-api.js create mode 100644 libraries/classes/Plugins/SecondFactor/Key.php create mode 100644 templates/login/second/key.twig create mode 100644 templates/login/second/key_configure.twig diff --git a/composer.json b/composer.json index 3e49d49291..1372c79eab 100644 --- a/composer.json +++ b/composer.json @@ -59,7 +59,8 @@ "conflict": { "tecnickcom/tcpdf": "<6.2", "pragmarx/google2fa": "<2.0", - "bacon/bacon-qr-code": "<1.0" + "bacon/bacon-qr-code": "<1.0", + "samyoul/u2f-php-server": "<1.1" }, "suggest": { "ext-openssl": "Cookie encryption", @@ -72,7 +73,8 @@ "ext-mbstring": "For best performance", "tecnickcom/tcpdf": "For PDF support", "pragmarx/google2fa": "For 2FA authentication", - "bacon/bacon-qr-code": "For 2FA authentication" + "bacon/bacon-qr-code": "For 2FA authentication", + "samyoul/u2f-php-server": "For FIDO U2F authentication" }, "require-dev": { "phpunit/phpunit": "~4.1", diff --git a/doc/second_factor.rst b/doc/second_factor.rst index a1a0d03c9e..aacfb3dc6e 100644 --- a/doc/second_factor.rst +++ b/doc/second_factor.rst @@ -23,12 +23,23 @@ standards, the most widely used include: * `FreeOTP for iOS, Android and Pebble `_ * `Authy for iOS, Android, Chrome, OS X `_ -* `Google Authenticator for iOS `_ +* `Google Authenticator for iOS `_ * `Google Authenticator for Android `_ -* `Google Authenticator (port) on Windows Store `_ -* `Microsoft Authenticator for Windows Phone `_ * `LastPass Authenticator for iOS, Android, OS X, Windows `_ -* `1Password for iOS, Android, OS X, Windows `_ + +Hardware Security Key +--------------------- + +Using hardware tokens is considered to be more secure than software based +solution. phpMyAdmin supports `FIDO U2F `_ +tokens. + +There are several manufacturers of these tokens, for example: + +* `youbico FIDO U2F Security Key `_ +* `HyperFIDO `_ +* `ePass FIDO USB `_ +* `TREZOR Bitcoin wallet `_ can `act as an U2F token `_ Simple Second Factor -------------------- diff --git a/js/messages.php b/js/messages.php index d3d57fb334..820deecce2 100644 --- a/js/messages.php +++ b/js/messages.php @@ -742,6 +742,10 @@ $js_messages['strWeak'] = __('Weak'); $js_messages['strGood'] = __('Good'); $js_messages['strStrong'] = __('Strong'); +/* U2F errors */ +$js_messages['strU2FTimeout'] = __('Timed out waiting for security key activation.'); +$js_messages['strU2FError'] = __('Failed security key activation (%s).'); + echo "var PMA_messages = new Array();\n"; foreach ($js_messages as $name => $js_message) { Sanitize::printJsValue("PMA_messages['" . $name . "']", $js_message); diff --git a/js/u2f.js b/js/u2f.js new file mode 100644 index 0000000000..cb2a59c1d6 --- /dev/null +++ b/js/u2f.js @@ -0,0 +1,55 @@ +/** global: AJAX */ +/** global: PMA_messages */ +/** global: u2f */ +AJAX.registerOnload('u2f.js', function () { + var $inputReg = $('#u2f_registration_response'); + if ($inputReg.length > 0) { + var $formReg = $inputReg.parents('form'); + $formReg.find('input[type=submit]').hide(); + setTimeout(function() { + // A magic JS function that talks to the USB device. This function will keep polling for the USB device until it finds one. + u2f.register([JSON.parse($inputReg.attr('data-request'))], JSON.parse($inputReg.attr('data-signatures')), function(data) { + // Handle returning error data + if(data.errorCode && data.errorCode !== 0) { + if (data.errorCode === 5) { + PMA_ajaxShowMessage(PMA_messages.strU2FTimeout, false); + } else { + PMA_ajaxShowMessage( + PMA_sprintf(PMA_messages.strU2FError, data.errorCode), false + ); + } + return; + } + + // Fill and submit form. + $inputReg.val(JSON.stringify(data)); + $formReg.submit(); + }); + }, 1000); + } + var $inputAuth = $('#u2f_authentication_response'); + if ($inputAuth.length > 0) { + var $formAuth = $inputAuth.parents('form'); + $formAuth.find('input[type=submit]').hide(); + setTimeout(function() { + // Magic JavaScript talking to your HID + u2f.sign(JSON.parse($inputAuth.attr('data-request')), function(data) { + // Handle returning error data + if(data.errorCode && data.errorCode !== 0) { + if (data.errorCode === 5) { + PMA_ajaxShowMessage(PMA_messages.strU2FTimeout, false); + } else { + PMA_ajaxShowMessage( + PMA_sprintf(PMA_messages.strU2FError, data.errorCode), false + ); + } + return; + } + + // Fill and submit form. + $inputAuth.val(JSON.stringify(data)); + $formAuth.submit(); + }); + }, 1000); + } +}); diff --git a/js/vendor/u2f-api.js b/js/vendor/u2f-api.js new file mode 100644 index 0000000000..ac478feffe --- /dev/null +++ b/js/vendor/u2f-api.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC, USB_INTERNAL}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php new file mode 100644 index 0000000000..aa29624150 --- /dev/null +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -0,0 +1,241 @@ +_config; + } + } + + /** + * Returns array of U2F registration objects + * + * @return array + */ + public function getRegistrations() + { + $result = []; + foreach ($this->_config['registrations'] as $data) { + $reg = new \StdClass; + $reg->keyHandle = $data['keyHandle']; + $reg->publicKey = $data['publicKey']; + $reg->certificate = $data['certificate']; + $reg->counter = $data['counter']; + $result[] = $reg; + } + return $result; + } + + /** + * Return FIDO U2F Application ID + * + * It has to be URL with hostname only, having https protocol + * + * @return string + */ + public function getAppId() + { + global $PMA_Config; + + $url = $PMA_Config->get('PmaAbsoluteUri'); + if (!empty($url)) { + $parsed = parse_url($url); + if (isset($parsed['scheme']) && isset($parsed['host'])) { + return $parsed['scheme'] . '://' . $parsed['host'] . (!empty($parsed['port']) ? ':' . $parsed['port'] : ''); + } + } + return ($PMA_Config->isHttps() ? 'https://' : 'http://') . Core::getenv('HTTP_HOST'); + } + + /** + * Checks authentication, returns true on success + * + * @return boolean + */ + public function check() + { + $this->_provided = false; + if (!isset($_POST['u2f_authentication_response']) || !isset($_SESSION['authenticationRequest'])) { + return false; + } + $this->_provided = true; + try { + $response = json_decode($_POST['u2f_authentication_response']); + if (is_null($response)) { + return false; + } + $authentication = U2FServer::authenticate( + $_SESSION['authenticationRequest'], + $this->getRegistrations(), + $response + ); + // TODO: Store counter + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Loads needed javascripts into the page + * + * @return void + */ + public function loadScripts() + { + $response = Response::getInstance(); + $scripts = $response->getHeader()->getScripts(); + $scripts->addFile('vendor/u2f-api.js'); + $scripts->addFile('u2f.js'); + } + + /** + * Renders user interface to enter second factor + * + * @return string HTML code + */ + public function render() + { + if ($this->_provided) { + Message::rawError( + __('Two-factor authentication failed.') + )->display(); + } + $request = U2FServer::makeAuthentication( + $this->getRegistrations(), + $this->getAppId() + ); + $_SESSION['authenticationRequest'] = $request; + $this->loadScripts(); + return Template::get('login/second/key')->render([ + 'request' => json_encode($request), + ]); + } + + /** + * Renders user interface to configure second factor + * + * @return string HTML code + */ + public function setup() + { + if ($this->_provided) { + Message::rawError( + __('Two-factor authentication failed.') + )->display(); + } + $registrationData = U2FServer::makeRegistration( + $this->getAppId(), + $this->getRegistrations() + ); + $_SESSION['registrationRequest'] = $registrationData['request']; + + $this->loadScripts(); + return Template::get('login/second/key_configure')->render([ + 'request' => json_encode($registrationData['request']), + 'signatures' => json_encode($registrationData['signatures']), + ]); + } + + /** + * Performs backend configuration + * + * @return boolean + */ + public function configure() + { + $this->_provided = false; + if (! isset($_POST['u2f_registration_response'])) { + return false; + } + $this->_provided = true; + try { + $response = json_decode($_POST['u2f_registration_response']); + if (is_null($response)) { + return false; + } + $registration = U2FServer::register( + $_SESSION['registrationRequest'], $response + ); + $this->_config['registrations'][] = [ + 'keyHandle' => $registration->getKeyHandle(), + 'publicKey' => $registration->getPublicKey(), + 'certificate' => $registration->getCertificate(), + 'counter' => $registration->getCounter(), + ]; + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Get user visible name + * + * @return string + */ + public static function getName() + { + return __('Security key'); + } + + /** + * Get user visible description + * + * @return string + */ + public static function getDescription() + { + return __('Provides authentication using hardware security tokens supporting FIDO U2F.'); + } +} diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php index 83bdf4f1b2..958b336ce0 100644 --- a/libraries/classes/SecondFactor.php +++ b/libraries/classes/SecondFactor.php @@ -110,6 +110,9 @@ class SecondFactor if (class_exists('PragmaRX\Google2FA\Google2FA') && class_exists('BaconQrCode\Renderer\Image\Png')) { $result[] = 'application'; } + if (class_exists('Samyoul\U2F\U2FServer\U2FServer')) { + $result[] = 'key'; + } return $result; } diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 7747c2f5f8..21385152e7 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -228,7 +228,7 @@ if [ ! -d libraries/tcpdf ] ; then # suggested package. Let's require it and then revert # composer.json to original state. cp composer.json composer.json.backup - composer require --update-no-dev tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code + composer require --update-no-dev tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code samyoul/u2f-php-server mv composer.json.backup composer.json echo "* Cleanup of composer packages" rm -rf \ diff --git a/templates/login/second/key.twig b/templates/login/second/key.twig new file mode 100644 index 0000000000..9e483a80a4 --- /dev/null +++ b/templates/login/second/key.twig @@ -0,0 +1,4 @@ +

+{% trans "Please connect your FIDO U2F device into your computer's USB port. Then confirm login on the device." %} +

+ diff --git a/templates/login/second/key_configure.twig b/templates/login/second/key_configure.twig new file mode 100644 index 0000000000..4272ebe88b --- /dev/null +++ b/templates/login/second/key_configure.twig @@ -0,0 +1,4 @@ +

+{% trans "Please connect your FIDO U2F device into your computer's USB port. Then confirm registration on the device." %} +

+ diff --git a/test/ci-install-test b/test/ci-install-test index a32fbf74a3..03df5da191 100755 --- a/test/ci-install-test +++ b/test/ci-install-test @@ -3,4 +3,4 @@ ./test/install-runkit composer install --no-interaction # Install optional deps -composer require tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code +composer require tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code samyoul/u2f-php-server diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 1baef7da87..4bac99a1f5 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -109,4 +109,18 @@ class SecondFactorTest extends PmaTestCase ); $this->assertTrue($object->configure('application')); } + + public function testKey() + { + $object = new SecondFactor('user'); + if (! in_array('key', $object->available)) { + $this->markTestSkipped('u2f-php-server not available'); + } + /* Without providing code this should fail */ + $this->assertFalse($object->configure('key')); + + /* Invalid code */ + $_POST['u2f_registration_response'] = 'invalid'; + $this->assertFalse($object->configure('key')); + } } From b5e5f4e8c84867b34ae6acbb9088797d46333e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 10:59:32 +0100 Subject: [PATCH 06/15] Simplify second factor auth API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now only pass SecondFactor object to plugins, not individual parameters. Signed-off-by: Michal Čihař --- .../Plugins/SecondFactor/Application.php | 23 +++++++------- .../classes/Plugins/SecondFactor/Key.php | 31 +++++-------------- .../classes/Plugins/SecondFactorPlugin.php | 28 +++++------------ libraries/classes/SecondFactor.php | 29 ++++++++--------- test/classes/SecondFactorTest.php | 2 +- 5 files changed, 41 insertions(+), 72 deletions(-) diff --git a/libraries/classes/Plugins/SecondFactor/Application.php b/libraries/classes/Plugins/SecondFactor/Application.php index aa262969d6..6853eadd28 100644 --- a/libraries/classes/Plugins/SecondFactor/Application.php +++ b/libraries/classes/Plugins/SecondFactor/Application.php @@ -8,6 +8,7 @@ namespace PhpMyAdmin\Plugins\SecondFactor; use PhpMyAdmin\Message; +use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Template; use PhpMyAdmin\Plugins\SecondFactorPlugin; use PragmaRX\Google2FA\Google2FA; @@ -31,14 +32,16 @@ class Application extends SecondFactorPlugin /** * Creates object * - * @param string $user User name - * @param array $config Second factor configuration + * @param SecondFactor $second SecondFactor instance */ - public function __construct($user, $config) + public function __construct(SecondFactor $second) { - parent::__construct($user, $config); + parent::__construct($second); $this->_google2fa = new Google2FA(); $this->_google2fa->setWindow(8); + if (!isset($this->_second->config['settings']['secret'])) { + $this->_second->config['settings']['secret'] = ''; + } } /** @@ -53,8 +56,6 @@ class Application extends SecondFactorPlugin switch ($property) { case 'google2fa': return $this->_google2fa; - case 'config': - return $this->_config; } } @@ -66,12 +67,12 @@ class Application extends SecondFactorPlugin public function check() { $this->_provided = false; - if (!isset($_POST['2fa_code']) || !isset($this->_config['secret'])) { + if (!isset($_POST['2fa_code'])) { return false; } $this->_provided = true; return $this->_google2fa->verifyKey( - $this->_config['secret'], $_POST['2fa_code'] + $this->_second->config['settings']['secret'], $_POST['2fa_code'] ); } @@ -104,8 +105,8 @@ class Application extends SecondFactorPlugin } $inlineUrl = $this->_google2fa->getQRCodeInline( 'phpMyAdmin', - $this->_user, - $this->_config['secret'] + $this->_second->user, + $this->_second->config['settings']['secret'] ); return Template::get('login/second/application_configure')->render([ 'image' => $inlineUrl, @@ -122,7 +123,7 @@ class Application extends SecondFactorPlugin if (! isset($_SESSION['2fa_application_key'])) { $_SESSION['2fa_application_key'] = $this->_google2fa->generateSecretKey(); } - $this->_config['secret'] = $_SESSION['2fa_application_key']; + $this->_second->config['settings']['secret'] = $_SESSION['2fa_application_key']; $result = $this->check(); if ($result) { diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php index aa29624150..f04f607c49 100644 --- a/libraries/classes/Plugins/SecondFactor/Key.php +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -10,6 +10,7 @@ namespace PhpMyAdmin\Plugins\SecondFactor; use PhpMyAdmin\Core; use PhpMyAdmin\Message; use PhpMyAdmin\Response; +use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Template; use PhpMyAdmin\Plugins\SecondFactorPlugin; use Samyoul\U2F\U2FServer\U2FServer; @@ -31,29 +32,13 @@ class Key extends SecondFactorPlugin /** * Creates object * - * @param string $user User name - * @param array $config Second factor configuration + * @param SecondFactor $second SecondFactor instance */ - public function __construct($user, $config) + public function __construct(SecondFactor $second) { - if (!isset($config['registrations'])) { - $config['registrations'] = []; - } - parent::__construct($user, $config); - } - - /** - * Get any property of this class - * - * @param string $property name of the property - * - * @return mixed|void if property exist, value of the relevant property - */ - public function __get($property) - { - switch ($property) { - case 'config': - return $this->_config; + parent::__construct($second); + if (!isset($this->_second->config['settings']['registrations'])) { + $this->_second->config['settings']['registrations'] = []; } } @@ -65,7 +50,7 @@ class Key extends SecondFactorPlugin public function getRegistrations() { $result = []; - foreach ($this->_config['registrations'] as $data) { + foreach ($this->_second->config['settings']['registrations'] as $data) { $reg = new \StdClass; $reg->keyHandle = $data['keyHandle']; $reg->publicKey = $data['publicKey']; @@ -207,7 +192,7 @@ class Key extends SecondFactorPlugin $registration = U2FServer::register( $_SESSION['registrationRequest'], $response ); - $this->_config['registrations'][] = [ + $this->_second->config['settings']['registrations'][] = [ 'keyHandle' => $registration->getKeyHandle(), 'publicKey' => $registration->getPublicKey(), 'certificate' => $registration->getCertificate(), diff --git a/libraries/classes/Plugins/SecondFactorPlugin.php b/libraries/classes/Plugins/SecondFactorPlugin.php index 8a8a534ded..fa22c580f5 100644 --- a/libraries/classes/Plugins/SecondFactorPlugin.php +++ b/libraries/classes/Plugins/SecondFactorPlugin.php @@ -7,6 +7,8 @@ */ namespace PhpMyAdmin\Plugins; +use PhpMyAdmin\SecondFactor; + /** * Second factor authentication plugin class * @@ -22,24 +24,18 @@ class SecondFactorPlugin public static $id = ''; /** - * @var string + * @var SecondFactor */ - protected $_user; - /** - * @var array - */ - protected $_config; + protected $_second; /** * Creates object * - * @param string $user User name - * @param array $config Second factor configuration + * @param SecondFactor $second SecondFactor instance */ - public function __construct($user, $config) + public function __construct(SecondFactor $second) { - $this->_user = $user; - $this->_config = $config; + $this->_second = $second; } /** @@ -82,16 +78,6 @@ class SecondFactorPlugin return true; } - /** - * Return current configuration - * - * @return array - */ - public function getConfig() - { - return $this->_config; - } - /** * Get user visible name * diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php index 958b336ce0..cb24d31cc3 100644 --- a/libraries/classes/SecondFactor.php +++ b/libraries/classes/SecondFactor.php @@ -17,12 +17,12 @@ class SecondFactor /** * @var string */ - protected $_user; + public $user; /** * @var array */ - protected $_config; + public $config; /** * @var boolean @@ -46,10 +46,10 @@ class SecondFactor */ public function __construct($user) { - $this->_user = $user; + $this->user = $user; $this->_available = $this->getAvailable(); - $this->_config = $this->readConfig(); - $this->_writable = ($this->_config['type'] == 'db'); + $this->config = $this->readConfig(); + $this->_writable = ($this->config['type'] == 'db'); $this->_backend = $this->getBackend(); } @@ -91,8 +91,6 @@ class SecondFactor return $this->_available; case 'writable': return $this->_writable; - case 'config': - return $this->_config; } } @@ -139,8 +137,8 @@ class SecondFactor */ public function getBackend() { - $name = $this->getBackendClass($this->_config['backend']); - return new $name($this->_user, $this->_config['settings']); + $name = $this->getBackendClass($this->config['backend']); + return new $name($this); } /** @@ -193,29 +191,28 @@ class SecondFactor */ public function configure($name) { - $config = [ + $this->config = [ 'backend' => $name ]; if ($name === '') { $cls = $this->getBackendClass($name); - $this->_backend = new $cls($this->_user, []); - $config['settings'] = []; + $this->config['settings'] = []; + $this->_backend = new $cls($this); } else { if (! in_array($name, $this->_available)) { return false; } $cls = $this->getBackendClass($name); - $this->_backend = new $cls($this->_user, []); + $this->config['settings'] = []; + $this->_backend = new $cls($this); if (! $this->_backend->configure()) { return false; } - $config['settings'] = $this->_backend->getConfig(); } - $result = UserPreferences::persistOption('2fa', $config, null); + $result = UserPreferences::persistOption('2fa', $this->config, null); if ($result !== true) { $result->display(); } - $this->_config = $config['settings']; return true; } } diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 4bac99a1f5..ce5e7183cc 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -104,7 +104,7 @@ class SecondFactorTest extends PmaTestCase /* Generate valid code */ $google2fa = $object->backend->google2fa; $_POST['2fa_code'] = $google2fa->oathHotp( - $object->backend->config['secret'], + $object->config['settings']['secret'], $google2fa->getTimestamp() ); $this->assertTrue($object->configure('application')); From dd3154b1036d40431a2d6dad94d72e2c6fdc7b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 14:30:14 +0100 Subject: [PATCH 07/15] Test check method for simple second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- test/classes/SecondFactorTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index ce5e7183cc..2b5ac768f8 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -64,6 +64,13 @@ class SecondFactorTest extends PmaTestCase $backend = $object->backend; $this->assertEquals('simple', $backend::$id); $GLOBALS['cfg']['DBG']['simple2fa'] = false; + + unset($_POST['2fa_confirm']); + $this->assertFalse($object->check()); + + $_POST['2fa_confirm'] = 1; + $this->assertTrue($object->check()); + unset($_POST['2fa_confirm']); } public function testLoad() @@ -95,6 +102,7 @@ class SecondFactorTest extends PmaTestCase $this->markTestSkipped('google2fa not available'); } /* Without providing code this should fail */ + unset($_POST['2fa_code']); $this->assertFalse($object->configure('application')); /* Invalid code */ @@ -108,6 +116,7 @@ class SecondFactorTest extends PmaTestCase $google2fa->getTimestamp() ); $this->assertTrue($object->configure('application')); + unset($_POST['2fa_code']); } public function testKey() @@ -117,6 +126,7 @@ class SecondFactorTest extends PmaTestCase $this->markTestSkipped('u2f-php-server not available'); } /* Without providing code this should fail */ + unset($_POST['u2f_registration_response']); $this->assertFalse($object->configure('key')); /* Invalid code */ From 0d14ba065ce3a925c2fb6bd3b03523d031ee54ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 14:33:20 +0100 Subject: [PATCH 08/15] Share and test code for listing backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- libraries/classes/SecondFactor.php | 20 ++++++++++++++++++++ prefs_second.php | 14 +------------- test/classes/SecondFactorTest.php | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php index cb24d31cc3..cb6d79b0de 100644 --- a/libraries/classes/SecondFactor.php +++ b/libraries/classes/SecondFactor.php @@ -215,4 +215,24 @@ class SecondFactor } return true; } + + /** + * Returns array with all available backends + * + * @return array + */ + public function getAllBackends() + { + $all = array_merge([''], $this->available); + $backends = []; + foreach ($all as $name) { + $cls = $this->getBackendClass($name); + $backends[] = [ + 'id' => $cls::$id, + 'name' => $cls::getName(), + 'description' => $cls::getDescription(), + ]; + } + return $backends; + } } diff --git a/prefs_second.php b/prefs_second.php index 27e626a387..d6ade2de04 100644 --- a/prefs_second.php +++ b/prefs_second.php @@ -40,18 +40,6 @@ if (isset($_POST['2fa_remove'])) { } } -$all = array_merge([''], $second_factor->available); -$backends = []; -foreach ($all as $name) { - $cls = $second_factor->getBackendClass($name); - $backends[] = [ - 'id' => $cls::$id, - 'name' => $cls::getName(), - 'description' => $cls::getDescription(), - ]; -} - - $backend = $second_factor->backend; echo Template::get('prefs_second')->render([ 'enabled' => $second_factor->writable, @@ -59,5 +47,5 @@ echo Template::get('prefs_second')->render([ 'backend_id' => $backend::$id, 'backend_name' => $backend::getName(), 'backend_description' => $backend::getDescription(), - 'backends' => $backends, + 'backends' => $second_factor->getAllBackends(), ]); diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 2b5ac768f8..93bfcb1eac 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -133,4 +133,19 @@ class SecondFactorTest extends PmaTestCase $_POST['u2f_registration_response'] = 'invalid'; $this->assertFalse($object->configure('key')); } + + /** + * Test listing of available backends. + */ + public function testBackends() + { + $GLOBALS['cfg']['DBG']['simple2fa'] = true; + $object = new SecondFactor('user'); + $backends = $object->getAllBackends(); + $this->assertEquals( + count($object->available) + 1, + count($backends) + ); + $GLOBALS['cfg']['DBG']['simple2fa'] = false; + } } From d6d84a76077d146354e2485a296396b86ca25846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 14:39:54 +0100 Subject: [PATCH 09/15] Share code for error report in second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ...and test it. Signed-off-by: Michal Čihař --- .../Plugins/SecondFactor/Application.php | 13 ----------- .../classes/Plugins/SecondFactor/Key.php | 13 ----------- .../classes/Plugins/SecondFactorPlugin.php | 22 +++++++++++++++++++ libraries/classes/SecondFactor.php | 4 ++-- test/classes/SecondFactorTest.php | 12 ++++++++++ 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/libraries/classes/Plugins/SecondFactor/Application.php b/libraries/classes/Plugins/SecondFactor/Application.php index 6853eadd28..5181e1e8b7 100644 --- a/libraries/classes/Plugins/SecondFactor/Application.php +++ b/libraries/classes/Plugins/SecondFactor/Application.php @@ -7,7 +7,6 @@ */ namespace PhpMyAdmin\Plugins\SecondFactor; -use PhpMyAdmin\Message; use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Template; use PhpMyAdmin\Plugins\SecondFactorPlugin; @@ -25,8 +24,6 @@ class Application extends SecondFactorPlugin */ public static $id = 'application'; - protected $_provided = false; - protected $_google2fa; /** @@ -83,11 +80,6 @@ class Application extends SecondFactorPlugin */ public function render() { - if ($this->_provided) { - Message::rawError( - __('Two-factor authentication failed.') - )->display(); - } return Template::get('login/second/application')->render(); } @@ -98,11 +90,6 @@ class Application extends SecondFactorPlugin */ public function setup() { - if ($this->_provided) { - Message::rawError( - __('Two-factor authentication failed.') - )->display(); - } $inlineUrl = $this->_google2fa->getQRCodeInline( 'phpMyAdmin', $this->_second->user, diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php index f04f607c49..673e6df43b 100644 --- a/libraries/classes/Plugins/SecondFactor/Key.php +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -8,7 +8,6 @@ namespace PhpMyAdmin\Plugins\SecondFactor; use PhpMyAdmin\Core; -use PhpMyAdmin\Message; use PhpMyAdmin\Response; use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Template; @@ -27,8 +26,6 @@ class Key extends SecondFactorPlugin */ public static $id = 'key'; - protected $_provided = false; - /** * Creates object * @@ -131,11 +128,6 @@ class Key extends SecondFactorPlugin */ public function render() { - if ($this->_provided) { - Message::rawError( - __('Two-factor authentication failed.') - )->display(); - } $request = U2FServer::makeAuthentication( $this->getRegistrations(), $this->getAppId() @@ -154,11 +146,6 @@ class Key extends SecondFactorPlugin */ public function setup() { - if ($this->_provided) { - Message::rawError( - __('Two-factor authentication failed.') - )->display(); - } $registrationData = U2FServer::makeRegistration( $this->getAppId(), $this->getRegistrations() diff --git a/libraries/classes/Plugins/SecondFactorPlugin.php b/libraries/classes/Plugins/SecondFactorPlugin.php index fa22c580f5..3fb074ee8d 100644 --- a/libraries/classes/Plugins/SecondFactorPlugin.php +++ b/libraries/classes/Plugins/SecondFactorPlugin.php @@ -7,6 +7,7 @@ */ namespace PhpMyAdmin\Plugins; +use PhpMyAdmin\Message; use PhpMyAdmin\SecondFactor; /** @@ -28,6 +29,11 @@ class SecondFactorPlugin */ protected $_second; + /** + * @var boolean + */ + protected $_provided; + /** * Creates object * @@ -36,6 +42,22 @@ class SecondFactorPlugin public function __construct(SecondFactor $second) { $this->_second = $second; + $this->_provided = false; + } + + /** + * Returns authentication error message + * + * @return string + */ + public function getError() + { + if ($this->_provided) { + return Message::rawError( + __('Two-factor authentication failed.') + )->getDisplay(); + } + return ''; } /** diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php index cb6d79b0de..d87188ce0f 100644 --- a/libraries/classes/SecondFactor.php +++ b/libraries/classes/SecondFactor.php @@ -166,7 +166,7 @@ class SecondFactor */ public function render() { - return $this->_backend->render(); + return $this->_backend->getError() . $this->_backend->render(); } /** @@ -176,7 +176,7 @@ class SecondFactor */ public function setup() { - return $this->_backend->setup(); + return $this->_backend->getError() . $this->_backend->setup(); } /** diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 93bfcb1eac..9edf4aa454 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -71,6 +71,10 @@ class SecondFactorTest extends PmaTestCase $_POST['2fa_confirm'] = 1; $this->assertTrue($object->check()); unset($_POST['2fa_confirm']); + + /* Test rendering */ + $this->assertNotEquals('', $object->render()); + $this->assertEquals('', $object->setup()); } public function testLoad() @@ -117,6 +121,10 @@ class SecondFactorTest extends PmaTestCase ); $this->assertTrue($object->configure('application')); unset($_POST['2fa_code']); + + /* Test rendering */ + $this->assertNotEquals('', $object->render()); + $this->assertNotEquals('', $object->setup()); } public function testKey() @@ -132,6 +140,10 @@ class SecondFactorTest extends PmaTestCase /* Invalid code */ $_POST['u2f_registration_response'] = 'invalid'; $this->assertFalse($object->configure('key')); + + /* Test rendering */ + $this->assertNotEquals('', $object->render()); + $this->assertNotEquals('', $object->setup()); } /** From c64e52c3c08eb0ee79d439f8e095f5c1649f0b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 15:10:35 +0100 Subject: [PATCH 10/15] Test registration and authentication for FIDO U2F MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- .../classes/Plugins/SecondFactor/Key.php | 2 +- test/classes/SecondFactorTest.php | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php index 673e6df43b..3573824d57 100644 --- a/libraries/classes/Plugins/SecondFactor/Key.php +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -167,7 +167,7 @@ class Key extends SecondFactorPlugin public function configure() { $this->_provided = false; - if (! isset($_POST['u2f_registration_response'])) { + if (! isset($_POST['u2f_registration_response']) || ! isset($_SESSION['registrationRequest'])) { return false; } $this->_provided = true; diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 9edf4aa454..f2aa54ffef 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -8,6 +8,8 @@ namespace PhpMyAdmin\Tests; use PhpMyAdmin\SecondFactor; +use Samyoul\U2F\U2FServer\RegistrationRequest; +use Samyoul\U2F\U2FServer\SignRequest; /** * Tests behaviour of SecondFactor class @@ -122,6 +124,18 @@ class SecondFactorTest extends PmaTestCase $this->assertTrue($object->configure('application')); unset($_POST['2fa_code']); + /* Check code */ + unset($_POST['2fa_code']); + $this->assertFalse($object->check()); + $_POST['2fa_code'] = 'invalid'; + $this->assertFalse($object->check()); + $_POST['2fa_code'] = $google2fa->oathHotp( + $object->config['settings']['secret'], + $google2fa->getTimestamp() + ); + $this->assertTrue($object->check()); + unset($_POST['2fa_code']); + /* Test rendering */ $this->assertNotEquals('', $object->render()); $this->assertNotEquals('', $object->setup()); @@ -133,6 +147,7 @@ class SecondFactorTest extends PmaTestCase if (! in_array('key', $object->available)) { $this->markTestSkipped('u2f-php-server not available'); } + $_SESSION['registrationRequest'] = null; /* Without providing code this should fail */ unset($_POST['u2f_registration_response']); $this->assertFalse($object->configure('key')); @@ -141,11 +156,64 @@ class SecondFactorTest extends PmaTestCase $_POST['u2f_registration_response'] = 'invalid'; $this->assertFalse($object->configure('key')); + /* Invalid code */ + $_POST['u2f_registration_response'] = '[]'; + $this->assertFalse($object->configure('key')); + + /* Without providing code this should fail */ + unset($_POST['u2f_authentication_response']); + $this->assertFalse($object->check()); + + /* Invalid code */ + $_POST['u2f_authentication_response'] = 'invalid'; + $this->assertFalse($object->check()); + + /* Invalid code */ + $_POST['u2f_authentication_response'] = '[]'; + $this->assertFalse($object->check()); + /* Test rendering */ $this->assertNotEquals('', $object->render()); $this->assertNotEquals('', $object->setup()); } + /** + * Test based on upstream test data: + * https://github.com/Yubico/php-u2flib-server + */ + public function testKeyAuthentication() + { + $GLOBALS['PMA_Config']->set('PmaAbsoluteUri', 'http://demo.example.com'); + $object = new SecondFactor('user'); + if (! in_array('key', $object->available)) { + $this->markTestSkipped('u2f-php-server not available'); + } + $_SESSION['registrationRequest'] = new RegistrationRequest('yKA0x075tjJ-GE7fKTfnzTOSaNUOWQxRd9TWz5aFOg8', 'http://demo.example.com'); + unset($_POST['u2f_registration_response']); + $this->assertFalse($object->configure('key')); + + $_POST['u2f_registration_response'] = ''; + $this->assertFalse($object->configure('key')); + + $_POST['u2f_registration_response'] = '{ "registrationData": "BQQtEmhWVgvbh-8GpjsHbj_d5FB9iNoRL8mNEq34-ANufKWUpVdIj6BSB_m3eMoZ3GqnaDy3RA5eWP8mhTkT1Ht3QAk1GsmaPIQgXgvrBkCQoQtMFvmwYPfW5jpRgoMPFxquHS7MTt8lofZkWAK2caHD-YQQdaRBgd22yWIjPuWnHOcwggLiMIHLAgEBMA0GCSqGSIb3DQEBCwUAMB0xGzAZBgNVBAMTEll1YmljbyBVMkYgVGVzdCBDQTAeFw0xNDA1MTUxMjU4NTRaFw0xNDA2MTQxMjU4NTRaMB0xGzAZBgNVBAMTEll1YmljbyBVMkYgVGVzdCBFRTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNsK2_Uhx1zOY9ym4eglBg2U5idUGU-dJK8mGr6tmUQflaNxkQo6IOc-kV4T6L44BXrVeqN-dpCPr-KKlLYw650wDQYJKoZIhvcNAQELBQADggIBAJVAa1Bhfa2Eo7TriA_jMA8togoA2SUE7nL6Z99YUQ8LRwKcPkEpSpOsKYWJLaR6gTIoV3EB76hCiBaWN5HV3-CPyTyNsM2JcILsedPGeHMpMuWrbL1Wn9VFkc7B3Y1k3OmcH1480q9RpYIYr-A35zKedgV3AnvmJKAxVhv9GcVx0_CewHMFTryFuFOe78W8nFajutknarupekDXR4tVcmvj_ihJcST0j_Qggeo4_3wKT98CgjmBgjvKCd3Kqg8n9aSDVWyaOZsVOhZj3Fv5rFu895--D4qiPDETozJIyliH-HugoQpqYJaTX10mnmMdCa6aQeW9CEf-5QmbIP0S4uZAf7pKYTNmDQ5z27DVopqaFw00MIVqQkae_zSPX4dsNeeoTTXrwUGqitLaGap5ol81LKD9JdP3nSUYLfq0vLsHNDyNgb306TfbOenRRVsgQS8tJyLcknSKktWD_Qn7E5vjOXprXPrmdp7g5OPvrbz9QkWa1JTRfo2n2AXV02LPFc-UfR9bWCBEIJBxvmbpmqt0MnBTHWnth2b0CU_KJTDCY3kAPLGbOT8A4KiI73pRW-e9SWTaQXskw3Ei_dHRILM_l9OXsqoYHJ4Dd3tbfvmjoNYggSw4j50l3unI9d1qR5xlBFpW5sLr8gKX4bnY4SR2nyNiOQNLyPc0B0nW502aMEUCIQDTGOX-i_QrffJDY8XvKbPwMuBVrOSO-ayvTnWs_WSuDQIgZ7fMAvD_Ezyy5jg6fQeuOkoJi8V2naCtzV-HTly8Nww=", "clientData": "eyAiY2hhbGxlbmdlIjogInlLQTB4MDc1dGpKLUdFN2ZLVGZuelRPU2FOVU9XUXhSZDlUV3o1YUZPZzgiLCAib3JpZ2luIjogImh0dHA6XC9cL2RlbW8uZXhhbXBsZS5jb20iLCAidHlwIjogIm5hdmlnYXRvci5pZC5maW5pc2hFbnJvbGxtZW50IiB9", "errorCode": 0 }'; + $this->assertTrue($object->configure('key')); + + unset($_POST['u2f_authentication_response']); + $this->assertFalse($object->check(true)); + + $_POST['u2f_authentication_response'] = ''; + $this->assertFalse($object->check(true)); + + $_SESSION['authenticationRequest'] = [new SignRequest([ + 'challenge' => 'fEnc9oV79EaBgK5BoNERU5gPKM2XGYWrz4fUjgc0Q7g', + 'keyHandle' => 'CTUayZo8hCBeC-sGQJChC0wW-bBg99bmOlGCgw8XGq4dLsxO3yWh9mRYArZxocP5hBB1pEGB3bbJYiM-5acc5w', + 'appId' => 'http://demo.example.com' + ])]; + $this->assertFalse($object->check()); + $_POST['u2f_authentication_response'] = '{ "signatureData": "AQAAAAQwRQIhAI6FSrMD3KUUtkpiP0jpIEakql-HNhwWFngyw553pS1CAiAKLjACPOhxzZXuZsVO8im-HStEcYGC50PKhsGp_SUAng==", "clientData": "eyAiY2hhbGxlbmdlIjogImZFbmM5b1Y3OUVhQmdLNUJvTkVSVTVnUEtNMlhHWVdyejRmVWpnYzBRN2ciLCAib3JpZ2luIjogImh0dHA6XC9cL2RlbW8uZXhhbXBsZS5jb20iLCAidHlwIjogIm5hdmlnYXRvci5pZC5nZXRBc3NlcnRpb24iIH0=", "keyHandle": "CTUayZo8hCBeC-sGQJChC0wW-bBg99bmOlGCgw8XGq4dLsxO3yWh9mRYArZxocP5hBB1pEGB3bbJYiM-5acc5w", "errorCode": 0 }'; + $this->assertTrue($object->check()); + } + /** * Test listing of available backends. */ From e67ea08eae5d89950726741fe70b6b9a3dc7d3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 15:22:08 +0100 Subject: [PATCH 11/15] Properly update FIDO U2F counter on login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- libraries/classes/Plugins/SecondFactor/Key.php | 6 ++++-- libraries/classes/SecondFactor.php | 12 +++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php index 3573824d57..9e6e43336b 100644 --- a/libraries/classes/Plugins/SecondFactor/Key.php +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -47,12 +47,13 @@ class Key extends SecondFactorPlugin public function getRegistrations() { $result = []; - foreach ($this->_second->config['settings']['registrations'] as $data) { + foreach ($this->_second->config['settings']['registrations'] as $index => $data) { $reg = new \StdClass; $reg->keyHandle = $data['keyHandle']; $reg->publicKey = $data['publicKey']; $reg->certificate = $data['certificate']; $reg->counter = $data['counter']; + $reg->index = $index; $result[] = $reg; } return $result; @@ -101,7 +102,8 @@ class Key extends SecondFactorPlugin $this->getRegistrations(), $response ); - // TODO: Store counter + $this->_second->config['settings']['registrations'][$authentication->index]['counter'] = $authentication->counter; + $this->_second->save(); return true; } catch (\Exception $e) { return false; diff --git a/libraries/classes/SecondFactor.php b/libraries/classes/SecondFactor.php index d87188ce0f..1cffa08a87 100644 --- a/libraries/classes/SecondFactor.php +++ b/libraries/classes/SecondFactor.php @@ -179,6 +179,16 @@ class SecondFactor return $this->_backend->getError() . $this->_backend->setup(); } + /** + * Saves current configuration. + * + * @return true|PhpMyAdmin\Message + */ + public function save() + { + return UserPreferences::persistOption('2fa', $this->config, null); + } + /** * Changes second factor settings * @@ -209,7 +219,7 @@ class SecondFactor return false; } } - $result = UserPreferences::persistOption('2fa', $this->config, null); + $result = $this->save(); if ($result !== true) { $result->display(); } From 2af38a3de8a3277bd1ad984a6fef8a2487f0346e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 15:30:06 +0100 Subject: [PATCH 12/15] Improved FIDO U2F error reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- libraries/classes/Plugins/SecondFactor/Key.php | 7 +++++-- libraries/classes/Plugins/SecondFactorPlugin.php | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php index 9e6e43336b..ead8abe8b8 100644 --- a/libraries/classes/Plugins/SecondFactor/Key.php +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -13,6 +13,7 @@ use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Template; use PhpMyAdmin\Plugins\SecondFactorPlugin; use Samyoul\U2F\U2FServer\U2FServer; +use Samyoul\U2F\U2FServer\U2FException; /** * Hardware key based second factor @@ -105,7 +106,8 @@ class Key extends SecondFactorPlugin $this->_second->config['settings']['registrations'][$authentication->index]['counter'] = $authentication->counter; $this->_second->save(); return true; - } catch (\Exception $e) { + } catch (U2FException $e) { + $this->_message = $e->getMessage(); return false; } } @@ -188,7 +190,8 @@ class Key extends SecondFactorPlugin 'counter' => $registration->getCounter(), ]; return true; - } catch (\Exception $e) { + } catch (U2FException $e) { + $this->_message = $e->getMessage(); return false; } } diff --git a/libraries/classes/Plugins/SecondFactorPlugin.php b/libraries/classes/Plugins/SecondFactorPlugin.php index 3fb074ee8d..29d4e028db 100644 --- a/libraries/classes/Plugins/SecondFactorPlugin.php +++ b/libraries/classes/Plugins/SecondFactorPlugin.php @@ -34,6 +34,11 @@ class SecondFactorPlugin */ protected $_provided; + /** + * @var string + */ + protected $_message; + /** * Creates object * @@ -43,6 +48,7 @@ class SecondFactorPlugin { $this->_second = $second; $this->_provided = false; + $this->_message = ''; } /** @@ -53,6 +59,11 @@ class SecondFactorPlugin public function getError() { if ($this->_provided) { + if (!empty($this->_message)) { + return Message::rawError( + sprintf(__('Two-factor authentication failed: %s'), $this->_message) + )->getDisplay(); + } return Message::rawError( __('Two-factor authentication failed.') )->getDisplay(); From 75c7d9b995b4c5d020d3b0746273c3ed491dbe3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 16:07:14 +0100 Subject: [PATCH 13/15] Install optional dependencies on Scrutinizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michal Čihař --- .scrutinizer.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.scrutinizer.yml b/.scrutinizer.yml index c4e2091253..8aa63a5582 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -10,6 +10,7 @@ build: dependencies: before: - composer install + - composer require tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code samyoul/u2f-php-server - ./vendor/bin/phpcs --config-set installed_paths `pwd`/vendor/phpmyadmin/coding-standard tests: override: From c5b0da38cd316ad9f8695a536c286864f854eea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 16:14:48 +0100 Subject: [PATCH 14/15] Properly test second factor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default code does session caching, we want to avoid it in the tests. Signed-off-by: Michal Čihař --- test/classes/SecondFactorTest.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index f2aa54ffef..1f3e88e4a6 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -53,7 +53,11 @@ class SecondFactorTest extends PmaTestCase $object = $this->getSecondFactorMock('user', ['type' => 'db']); $backend = $object->backend; $this->assertEquals('', $backend::$id); + // Is always valid $this->assertTrue($object->check(true)); + // Test session persistence + $this->assertTrue($object->check()); + $this->assertTrue($object->check()); $this->assertEquals('', $object->render()); $this->assertTrue($object->configure('')); $this->assertEquals('', $object->setup()); @@ -68,10 +72,10 @@ class SecondFactorTest extends PmaTestCase $GLOBALS['cfg']['DBG']['simple2fa'] = false; unset($_POST['2fa_confirm']); - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); $_POST['2fa_confirm'] = 1; - $this->assertTrue($object->check()); + $this->assertTrue($object->check(true)); unset($_POST['2fa_confirm']); /* Test rendering */ @@ -126,14 +130,14 @@ class SecondFactorTest extends PmaTestCase /* Check code */ unset($_POST['2fa_code']); - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); $_POST['2fa_code'] = 'invalid'; - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); $_POST['2fa_code'] = $google2fa->oathHotp( $object->config['settings']['secret'], $google2fa->getTimestamp() ); - $this->assertTrue($object->check()); + $this->assertTrue($object->check(true)); unset($_POST['2fa_code']); /* Test rendering */ @@ -162,15 +166,15 @@ class SecondFactorTest extends PmaTestCase /* Without providing code this should fail */ unset($_POST['u2f_authentication_response']); - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); /* Invalid code */ $_POST['u2f_authentication_response'] = 'invalid'; - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); /* Invalid code */ $_POST['u2f_authentication_response'] = '[]'; - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); /* Test rendering */ $this->assertNotEquals('', $object->render()); @@ -209,9 +213,9 @@ class SecondFactorTest extends PmaTestCase 'keyHandle' => 'CTUayZo8hCBeC-sGQJChC0wW-bBg99bmOlGCgw8XGq4dLsxO3yWh9mRYArZxocP5hBB1pEGB3bbJYiM-5acc5w', 'appId' => 'http://demo.example.com' ])]; - $this->assertFalse($object->check()); + $this->assertFalse($object->check(true)); $_POST['u2f_authentication_response'] = '{ "signatureData": "AQAAAAQwRQIhAI6FSrMD3KUUtkpiP0jpIEakql-HNhwWFngyw553pS1CAiAKLjACPOhxzZXuZsVO8im-HStEcYGC50PKhsGp_SUAng==", "clientData": "eyAiY2hhbGxlbmdlIjogImZFbmM5b1Y3OUVhQmdLNUJvTkVSVTVnUEtNMlhHWVdyejRmVWpnYzBRN2ciLCAib3JpZ2luIjogImh0dHA6XC9cL2RlbW8uZXhhbXBsZS5jb20iLCAidHlwIjogIm5hdmlnYXRvci5pZC5nZXRBc3NlcnRpb24iIH0=", "keyHandle": "CTUayZo8hCBeC-sGQJChC0wW-bBg99bmOlGCgw8XGq4dLsxO3yWh9mRYArZxocP5hBB1pEGB3bbJYiM-5acc5w", "errorCode": 0 }'; - $this->assertTrue($object->check()); + $this->assertTrue($object->check(true)); } /** From 947c1ace037c5bddac7b68c74342b636587564bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 1 Nov 2017 16:27:23 +0100 Subject: [PATCH 15/15] Share code for getting server URL and use it in 2FA as well MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This way multiple phpMyAdmin installations can be identified. Signed-off-by: Michal Čihař --- .../Plugins/SecondFactor/Application.php | 2 +- .../classes/Plugins/SecondFactor/Key.php | 26 ++------------- .../classes/Plugins/SecondFactorPlugin.php | 32 +++++++++++++++++++ test/classes/SecondFactorTest.php | 23 ++++++++++++- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/libraries/classes/Plugins/SecondFactor/Application.php b/libraries/classes/Plugins/SecondFactor/Application.php index 5181e1e8b7..127960597c 100644 --- a/libraries/classes/Plugins/SecondFactor/Application.php +++ b/libraries/classes/Plugins/SecondFactor/Application.php @@ -91,7 +91,7 @@ class Application extends SecondFactorPlugin public function setup() { $inlineUrl = $this->_google2fa->getQRCodeInline( - 'phpMyAdmin', + 'phpMyAdmin (' . $this->getAppId(false) . ')', $this->_second->user, $this->_second->config['settings']['secret'] ); diff --git a/libraries/classes/Plugins/SecondFactor/Key.php b/libraries/classes/Plugins/SecondFactor/Key.php index ead8abe8b8..9b8f37236d 100644 --- a/libraries/classes/Plugins/SecondFactor/Key.php +++ b/libraries/classes/Plugins/SecondFactor/Key.php @@ -7,7 +7,6 @@ */ namespace PhpMyAdmin\Plugins\SecondFactor; -use PhpMyAdmin\Core; use PhpMyAdmin\Response; use PhpMyAdmin\SecondFactor; use PhpMyAdmin\Template; @@ -60,27 +59,6 @@ class Key extends SecondFactorPlugin return $result; } - /** - * Return FIDO U2F Application ID - * - * It has to be URL with hostname only, having https protocol - * - * @return string - */ - public function getAppId() - { - global $PMA_Config; - - $url = $PMA_Config->get('PmaAbsoluteUri'); - if (!empty($url)) { - $parsed = parse_url($url); - if (isset($parsed['scheme']) && isset($parsed['host'])) { - return $parsed['scheme'] . '://' . $parsed['host'] . (!empty($parsed['port']) ? ':' . $parsed['port'] : ''); - } - } - return ($PMA_Config->isHttps() ? 'https://' : 'http://') . Core::getenv('HTTP_HOST'); - } - /** * Checks authentication, returns true on success * @@ -134,7 +112,7 @@ class Key extends SecondFactorPlugin { $request = U2FServer::makeAuthentication( $this->getRegistrations(), - $this->getAppId() + $this->getAppId(true) ); $_SESSION['authenticationRequest'] = $request; $this->loadScripts(); @@ -151,7 +129,7 @@ class Key extends SecondFactorPlugin public function setup() { $registrationData = U2FServer::makeRegistration( - $this->getAppId(), + $this->getAppId(true), $this->getRegistrations() ); $_SESSION['registrationRequest'] = $registrationData['request']; diff --git a/libraries/classes/Plugins/SecondFactorPlugin.php b/libraries/classes/Plugins/SecondFactorPlugin.php index 29d4e028db..a24a1ddc8a 100644 --- a/libraries/classes/Plugins/SecondFactorPlugin.php +++ b/libraries/classes/Plugins/SecondFactorPlugin.php @@ -7,6 +7,7 @@ */ namespace PhpMyAdmin\Plugins; +use PhpMyAdmin\Core; use PhpMyAdmin\Message; use PhpMyAdmin\SecondFactor; @@ -130,4 +131,35 @@ class SecondFactorPlugin { return __('Login using password only.'); } + + /** + * Return an applicaiton ID + * + * Either hostname or hostname with scheme. + * + * @param boolean $return_url Whether to generate URL + * + * @return string + */ + public function getAppId($return_url) + { + global $PMA_Config; + + $url = $PMA_Config->get('PmaAbsoluteUri'); + $parsed = []; + if (!empty($url)) { + $parsed = parse_url($url); + } + if (empty($parsed['scheme'])) { + $parsed['scheme'] = $PMA_Config->isHttps() ? 'https' : 'http'; + } + if (empty($parsed['host'])) { + $parsed['host'] = Core::getenv('HTTP_HOST'); + } + if ($return_url) { + return $parsed['scheme'] . '://' . $parsed['host'] . (!empty($parsed['port']) ? ':' . $parsed['port'] : ''); + } else { + return $parsed['host']; + } + } } diff --git a/test/classes/SecondFactorTest.php b/test/classes/SecondFactorTest.php index 1f3e88e4a6..639ffad141 100644 --- a/test/classes/SecondFactorTest.php +++ b/test/classes/SecondFactorTest.php @@ -181,13 +181,34 @@ class SecondFactorTest extends PmaTestCase $this->assertNotEquals('', $object->setup()); } + /** + * Test getting AppId + */ + public function testKeyAppId() + { + $object = new SecondFactor('user'); + $GLOBALS['PMA_Config']->set('PmaAbsoluteUri', 'http://demo.example.com'); + $this->assertEquals('http://demo.example.com', $object->backend->getAppId(true)); + $this->assertEquals('demo.example.com', $object->backend->getAppId(false)); + $GLOBALS['PMA_Config']->set('PmaAbsoluteUri', 'https://demo.example.com:123'); + $this->assertEquals('https://demo.example.com:123', $object->backend->getAppId(true)); + $this->assertEquals('demo.example.com', $object->backend->getAppId(false)); + $GLOBALS['PMA_Config']->set('PmaAbsoluteUri', ''); + $GLOBALS['PMA_Config']->set('is_https', true); + $_SERVER['HTTP_HOST'] = 'pma.example.com'; + $this->assertEquals('https://pma.example.com', $object->backend->getAppId(true)); + $this->assertEquals('pma.example.com', $object->backend->getAppId(false)); + $GLOBALS['PMA_Config']->set('is_https', false); + $this->assertEquals('http://pma.example.com', $object->backend->getAppId(true)); + $this->assertEquals('pma.example.com', $object->backend->getAppId(false)); + } + /** * Test based on upstream test data: * https://github.com/Yubico/php-u2flib-server */ public function testKeyAuthentication() { - $GLOBALS['PMA_Config']->set('PmaAbsoluteUri', 'http://demo.example.com'); $object = new SecondFactor('user'); if (! in_array('key', $object->available)) { $this->markTestSkipped('u2f-php-server not available');