From 44a0079b9dd5d5642795bc8069283aba517656b4 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Wed, 5 Jun 2019 13:10:25 +0200 Subject: [PATCH] new user security popup incl. 2FA and token revokation --- api/src/Framework.php | 4 +- api/src/Framework/Login.php | 3 + api/src/Header/UserAgent.php | 21 + api/src/Mail/Credentials.php | 9 +- api/templates/default/login.tpl | 10 +- composer.json | 3 +- composer.lock | 422 +++++++++++++++++- login.php | 2 +- pixelegg/login.tpl | 12 +- pixelegg/login_mobile.tpl | 6 + .../inc/class.preferences_password.inc.php | 292 ++++++++++-- preferences/templates/default/app.css | 25 +- preferences/templates/default/password.xet | 111 +++-- preferences/templates/pixelegg/app.css | 22 + 14 files changed, 873 insertions(+), 69 deletions(-) diff --git a/api/src/Framework.php b/api/src/Framework.php index be121c8abb..f68a2892b1 100644 --- a/api/src/Framework.php +++ b/api/src/Framework.php @@ -1164,9 +1164,9 @@ abstract class Framework extends Framework\Extra $this->_add_topmenu_item(array( 'id' => 'password', 'name' => 'preferences', - 'title' => lang('Password'), + 'title' => lang('Security & Password'), 'url' => "javascript:egw.open_link('". - self::link('/index.php?menuaction=preferences.preferences_password.change')."','_blank','400x270')", + self::link('/index.php?menuaction=preferences.preferences_password.change')."','_blank','850x580')", )); } /* disable help until content is reworked diff --git a/api/src/Framework/Login.php b/api/src/Framework/Login.php index 7570965393..b0a0022473 100644 --- a/api/src/Framework/Login.php +++ b/api/src/Framework/Login.php @@ -67,6 +67,9 @@ class Login $tmpl->set_block('login_form','change_password'); $tmpl->set_var('change_password', ''); $tmpl->set_var('lang_password',lang('password')); + $tmpl->set_var('lang_2fa',lang('2-Factor-Authentication')); + $tmpl->set_var('lang_2fa_help', htmlspecialchars( + lang('If you use "2-Factor-Authentication", please enter the code here.'))); // display login-message depending on $_GET[cd] and what's in database/header for "login_message" $cd_msg = self::check_logoutcode($_GET['cd']); diff --git a/api/src/Header/UserAgent.php b/api/src/Header/UserAgent.php index b4beadce0a..4682344591 100644 --- a/api/src/Header/UserAgent.php +++ b/api/src/Header/UserAgent.php @@ -49,6 +49,27 @@ class UserAgent return self::$ua_mobile; } + /** + * Convert user-agent string to OS and Browser + * + * @param string $user_agent =null + */ + public static function osBrowser($user_agent=null) + { + $matches = $os_matches = null; + if (preg_match_all('#([^/]+)/([0-9.]+)( \([^)]+\))? ?#i', $user_agent, $matches) && count($matches) >= 4) + { + if (preg_match('/((Windows|Linux|Mac OS X)( NT)?) ([0-9._]+)/', $os=$matches[3][0], $os_matches)) + { + $os = $os_matches[1].' '.str_replace('_', '.', $os_matches[4]); + } + $browser = $matches[1][2] === 'Version' ? $matches[1][3] : $matches[1][2]; + $browser_version = $matches[2][2]; + return "$os\n$browser $browser_version"; + } + return $user_agent; + } + /** * user-agent: 'firefox', 'msie', 'edge', 'safari' (incl. iPhone), 'chrome', 'opera', 'konqueror', 'mozilla' * diff --git a/api/src/Mail/Credentials.php b/api/src/Mail/Credentials.php index 3ead67a225..c7f8d45c05 100644 --- a/api/src/Mail/Credentials.php +++ b/api/src/Mail/Credentials.php @@ -63,9 +63,13 @@ class Credentials */ const SMIME = 16; /** - * All credentials IMAP|SMTP|ADMIN|SMIME + * Two factor auth secret key */ - const ALL = 27; + const TWOFA = 32; + /** + * All credentials + */ + const ALL = self::IMAP|self::SMTP|self::ADMIN|self::SMIME|self::TWOFA; /** * Password in cleartext @@ -113,6 +117,7 @@ class Credentials self::SMTP => 'acc_smtp_', self::ADMIN => 'acc_imap_admin_', self::SMIME => 'acc_smime_', + self::TWOFA => '2fa_', ); /** diff --git a/api/templates/default/login.tpl b/api/templates/default/login.tpl index b99952de50..fe32b316b7 100644 --- a/api/templates/default/login.tpl +++ b/api/templates/default/login.tpl @@ -50,20 +50,24 @@ {lang_password}:  + + {lang_2fa}:  + + {lang_new_password}:  - + {lang_repeat_password}:  - +   - + diff --git a/composer.json b/composer.json index 1aa57977df..8653eff4d5 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,8 @@ "egroupware/activesync": "self.version", "egroupware/adodb-php": "self.version", "bower-asset/diff2html": "^2.7", - "tinymce/tinymce": "^5.0" + "tinymce/tinymce": "^5.0", + "pragmarx/google2fa-qrcode": "^1.0" }, "require-dev": { }, diff --git a/composer.lock b/composer.lock index 0e961eb137..1697513a09 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ebdf3fe200cf9fe22536ceb0054a5fba", + "content-hash": "61dbe936707f756396f1c438b9bfa02b", "packages": [ { "name": "adldap2/adldap2", @@ -56,6 +56,55 @@ ], "time": "2016-07-14T18:11:24+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "eaac909da3ccc32b748a65b127acd8918f58d9b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/eaac909da3ccc32b748a65b127acd8918f58d9b0", + "reference": "eaac909da3ccc32b748a65b127acd8918f58d9b0", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0", + "ext-iconv": "*", + "php": "^7.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^1.4", + "phpunit/phpunit": "^6.4", + "squizlabs/php_codesniffer": "^3.1" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "http://www.dasprids.de", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "time": "2018-04-25T17:53:56+00:00" + }, { "name": "bower-asset/cropper", "version": "v2.3.4", @@ -456,6 +505,48 @@ ], "time": "2018-08-27T06:10:37+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "631ef6e638e9494b0310837fa531bedd908fc22b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/631ef6e638e9494b0310837fa531bedd908fc22b", + "reference": "631ef6e638e9494b0310837fa531bedd908fc22b", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^6.4", + "squizlabs/php_codesniffer": "^3.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "time": "2017-10-25T22:45:27+00:00" + }, { "name": "egroupware/activesync", "version": "dev-master", @@ -1015,6 +1106,113 @@ "homepage": "http://www.oomphinc.com/", "time": "2017-03-31T16:57:39+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.2.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "55af0dc01992b4d0da7f6372e2eac097bbbaffdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/55af0dc01992b4d0da7f6372e2eac097bbbaffdb", + "reference": "55af0dc01992b4d0da7f6372e2eac097bbbaffdb", + "shasum": "" + }, + "require": { + "php": "^7" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7", + "vimeo/psalm": "^1|^2" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "time": "2019-01-03T20:26:31+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.99", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "shasum": "" + }, + "require": { + "php": "^7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "time": "2018-07-02T15:55:56+00:00" + }, { "name": "pear-pear.horde.org/Horde_Compress", "version": "2.2.2", @@ -2358,6 +2556,228 @@ "homepage": "http://pear.php.net/package/XML_Util", "time": "2017-06-28T19:21:19+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "17c969c82f427dd916afe4be50bafc6299aef1b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/17c969c82f427dd916afe4be50bafc6299aef1b4", + "reference": "17c969c82f427dd916afe4be50bafc6299aef1b4", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "~1.0|~2.0", + "paragonie/random_compat": ">=1", + "php": ">=5.4", + "symfony/polyfill-php56": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~4|~5|~6" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/", + "PragmaRX\\Google2FA\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "time": "2019-03-19T22:44:16+00:00" + }, + { + "name": "pragmarx/google2fa-qrcode", + "version": "v1.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-qrcode.git", + "reference": "fd5ff0531a48b193a659309cc5fb882c14dbd03f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/fd5ff0531a48b193a659309cc5fb882c14dbd03f", + "reference": "fd5ff0531a48b193a659309cc5fb882c14dbd03f", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "~1.0|~2.0", + "php": ">=5.4", + "pragmarx/google2fa": ">=4.0" + }, + "require-dev": { + "khanamiryan/qrcode-detector-decoder": "^1.0", + "phpunit/phpunit": "~4|~5|~6|~7" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FAQRCode\\": "src/", + "PragmaRX\\Google2FAQRCode\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "QR Code package for Google2FA", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa", + "qr code", + "qrcode" + ], + "time": "2019-03-20T16:42:58+00:00" + }, + { + "name": "symfony/polyfill-php56", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "f4dddbc5c3471e1b700a147a20ae17cdb72dbe42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/f4dddbc5c3471e1b700a147a20ae17cdb72dbe42", + "reference": "f4dddbc5c3471e1b700a147a20ae17cdb72dbe42", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/polyfill-util": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php56\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "symfony/polyfill-util", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-util.git", + "reference": "b46c6cae28a3106735323f00a0c38eccf2328897" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-util/zipball/b46c6cae28a3106735323f00a0c38eccf2328897", + "reference": "b46c6cae28a3106735323f00a0c38eccf2328897", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Util\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony utilities for portability of PHP codes", + "homepage": "https://symfony.com", + "keywords": [ + "compat", + "compatibility", + "polyfill", + "shim" + ], + "time": "2019-02-08T14:16:39+00:00" + }, { "name": "tinymce/tinymce", "version": "5.0.3", diff --git a/login.php b/login.php index 950543dac8..b49815f5a1 100755 --- a/login.php +++ b/login.php @@ -252,7 +252,7 @@ else } } $GLOBALS['sessionid'] = $GLOBALS['egw']->session->create($login, $passwd, - $passwd_type, false, true, true); // true = let session fail on forced password change + $passwd_type, false, true, true, $_POST['2fa_code']); // true = let session fail on forced password change if (!$GLOBALS['sessionid'] && $GLOBALS['egw']->session->cd_reason == Api\Session::CD_FORCE_PASSWORD_CHANGE) { diff --git a/pixelegg/login.tpl b/pixelegg/login.tpl index 81d3bb67a4..682834cfb7 100644 --- a/pixelegg/login.tpl +++ b/pixelegg/login.tpl @@ -40,6 +40,12 @@ + + + + + + @@ -68,19 +74,19 @@ - + - + - + diff --git a/pixelegg/login_mobile.tpl b/pixelegg/login_mobile.tpl index 38babbd303..e1f66cb03b 100644 --- a/pixelegg/login_mobile.tpl +++ b/pixelegg/login_mobile.tpl @@ -43,6 +43,12 @@ + + + + + + diff --git a/preferences/inc/class.preferences_password.inc.php b/preferences/inc/class.preferences_password.inc.php index 0fc3d1777d..33588dcddb 100644 --- a/preferences/inc/class.preferences_password.inc.php +++ b/preferences/inc/class.preferences_password.inc.php @@ -6,22 +6,27 @@ * @link http://www.egroupware.org * @author Joseph Engo * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License - * @version $Id$ */ use EGroupware\Api; use EGroupware\Api\Framework; use EGroupware\Api\Etemplate; +use PragmaRX\Google2FAQRCode\Google2FA; +use EGroupware\Api\Mail\Credentials; +use EGroupware\OpenID\Repositories\AccessTokenRepository; +use EGroupware\OpenID\Repositories\ScopeRepository; +use EGroupware\OpenID\Repositories\RefreshTokenRepository; class preferences_password { var $public_functions = array( 'change' => True ); + const GAUTH_ANDROID = 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2'; + const GAUTH_IOS = 'https://appstore.com/googleauthenticator'; /** - * Change password function - * process change password form + * Change password, two factor auth or revoke tokens * * @param type $content */ @@ -29,34 +34,267 @@ class preferences_password { if ($GLOBALS['egw']->acl->check('nopasswordchange', 1)) { - Framework::window_close('There was no password change!'); + Framework::window_close('Password change is disabled!'); } - - if (!is_array($content)) - { - $content= array(); - } - else - { - if ($content['button']['change']) - { - if (($errors = self::do_change($content['o_passwd_2'], $content['n_passwd'], $content['n_passwd_2']))) - { - Framework::message(implode("\n", $errors), 'error'); - $content = array(); - } - else - { - Framework::refresh_opener(lang('Password changed'), 'preferences'); - Framework::window_close(); - } - } - } - $GLOBALS['egw_info']['flags']['app_header'] = lang('Change your password'); $tmpl = new Etemplate('preferences.password'); - $tmpl->exec('preferences.preferences_password.change', $content,array(),array(),array(),2); + $readonlys = $sel_options = []; + try { + // PHP 7.1+: using SVG image backend (requiring XMLWriter) and not ImageMagic extension + if (class_exists('BaconQrCode\Renderer\Image\SvgImageBackEnd')) + { + $image_backend = new \BaconQrCode\Renderer\Image\SvgImageBackEnd; + } + $google2fa = new Google2FA($image_backend); + $prefs = new Api\Preferences($GLOBALS['egw_info']['user']['account_id']); + $prefs->read_repository(); + + if (!is_array($content)) + { + $content = []; + $content['2fa'] = $this->generateQRCode($google2fa)+[ + 'gauth_android' => self::GAUTH_ANDROID, + 'gauth_ios' => self::GAUTH_IOS, + ]; + } + else + { + $secret_key = $content['2fa']['secret_key']; + unset($content['2fa']['secret_key']); + + switch($content['tabs']) + { + case 'change_password': + if ($content['button']['save']) + { + if (($errors = self::do_change($content['password'], $content['n_passwd'], $content['n_passwd_2']))) + { + Framework::message(implode("\n", $errors), 'error'); + $content = array(); + } + else + { + Framework::refresh_opener(lang('Password changed'), 'preferences'); + Framework::window_close(); + } + } + break; + + case 'two_factor_auth': + $auth = new Api\Auth(); + if (!$auth->authenticate($GLOBALS['egw_info']['user']['account_lid'], $content['password'])) + { + $tmpl->set_validation_error('password', lang('Password is invalid'), '2fa'); + break; + } + switch(key($content['2fa']['action'])) + { + case 'show': + $content['2fa'] = $this->generateQRCode($google2fa, false); + break; + case 'reset': + $content['2fa'] = $this->generateQRCode($google2fa, true); + Framework::message(lang('New secret generated, you need to save it to disable the old one!')); + break; + case 'disable': + if (Credentials::delete(0, $GLOBALS['egw_info']['user']['account_id'], Credentials::TWOFA)) + { + Framework::refresh_opener(lang('Secret deleted, two factor authentication disabled.'), 'preferences'); + Framework::window_close(); + } + else + { + Framework::message(lang('Failed to delete secret!'), 'error'); + } + break; + default: // no action, save secret + if (!$google2fa->verifyKey($secret_key, $content['2fa']['code'])) + { + $tmpl->set_validation_error('code', lang('Code is invalid'), '2fa'); + break 2; + } + if (($content['2fa']['cred_id'] = Credentials::write(0, + $GLOBALS['egw_info']['user']['account_lid'], + $secret_key, Credentials::TWOFA, + $GLOBALS['egw_info']['user']['account_id'], + $content['2fa']['cred_id']))) + { + Framework::refresh_opener(lang('Two Factor Auth enabled.'), 'preferences'); + Framework::window_close(); + } + else + { + Framework::message(lang('Failed to store secret!'), 'error'); + } + break; + } + unset($content['2fa']['action']); + break; + + case 'tokens': + if (is_array($content) && $content['nm']['selected']) + { + try { + switch($content['nm']['action']) + { + case 'delete': + $token_repo = new AccessTokenRepository(); + $token_repo->revokeAccessToken(['access_token_id' => $content['nm']['selected']]); + $refresh_token_repo = new RefreshTokenRepository(); + $refresh_token_repo->revokeRefreshToken(['access_token_id' => $content['nm']['selected']]); + $msg = (count($content['nm']['selected']) > 1 ? + count($content['nm']['selected']).' ' : ''). + lang('Access Token revoked.'); + break; + } + } + catch(\Exception $e) { + $msg = lang('Error').': '.$e->getMessage(); + break; + } + } + break; + } + } + } + catch (Exception $e) { + Framework::message($e->getMessage(), 'error'); + } + + // display tokens, if we have openid installed (currently no run-rights needed!) + if ($GLOBALS['egw_info']['apps']['openid'] && class_exists(AccessTokenRepository::class)) + { + $content['nm'] = [ + 'get_rows' => 'preferences.'.__CLASS__.'.getTokens', + 'no_cat' => true, + 'no_filter' => true, + 'no_filter2' => true, + 'filter_no_lang' => true, + 'order' => 'access_token_updated', + 'sort' => 'DESC', + 'row_id' => 'access_token_id', + 'default_cols' => '!client_id', + 'actions' => self::tokenActions(), + ]; + $sel_options += [ + 'client_status' => ['Disabled', 'Active'], + 'access_token_revoked' => ['Active', 'Revoked'], + 'access_token_scopes' => (new ScopeRepository())->selOptions(), + ]; + } + else + { + $readonlys['tabs']['tokens'] = true; + } + + $tmpl->exec('preferences.preferences_password.change', $content, $sel_options, $readonlys, [ + '2fa' => $content['2fa']+[ + 'secret_key' => $secret_key, + ], + ], 2); + } + + /** + * Query tokens for nextmatch widget + * + * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter' + * For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class. + * @param array &$rows returned rows/competitions + * @param array &$readonlys eg. to disable buttons based on acl, not use here, maybe in a derived class + * @return int number of rows found + */ + public function getTokens(array $query, array &$rows, array &$readonlys) + { + if (!class_exists(AccessTokenRepository::class)) return; + + $token_repo = new AccessTokenRepository(); + if (($ret = $token_repo->get_rows($query, $rows, $readonlys))) + { + foreach($rows as $key => &$row) + { + if (!is_int($key)) continue; + + // boolean does NOT work as key for select-box + $row['access_token_revoked'] = (string)(int)$row['access_token_revoked']; + $row['client_status'] = (string)(int)$row['client_status']; + + // dont send token itself to UI + unset($row['access_token_identifier']); + + // format user-agent as "OS Version\nBrowser Version" prefering auth-code over access-token + // as for implicit grant auth-code contains real user-agent, access-token container the server + if (!empty($row['auth_code_user_agent'])) + { + $row['user_agent'] = Api\Header\UserAgent::osBrowser($row['auth_code_user_agent']); + $row['user_ip'] = $row['auth_code_ip']; + $row['user_agent_tooltip'] = Api\Header\UserAgent::osBrowser($row['access_token_user_agent']); + $row['user_ip_tooltip'] = $row['access_token_ip']; + } + else + { + $row['user_agent'] = Api\Header\UserAgent::osBrowser($row['access_token_user_agent']); + $row['user_ip'] = $row['access_token_ip']; + } + } + } + return $ret; + } + + /** + * Get actions for tokens + */ + protected function tokenActions() + { + return [ + 'delete' => array( + 'caption' => 'Revoke', + 'allowOnMultiple' => true, + 'confirm' => 'Revoke this token', + ), + ]; + } + + /** + * Generate QRCode and optional new secret + * + * @param Google2FA $google2fa + * @param boolean|null $generate =null null: generate new qrCode/secret, if none exists + * true: allways generate new qrCode (to reset existing one) + * false: use existing secret, but generate qrCode + * @return array with keys "qrc" and "cred_id" + */ + protected function generateQRCode(Google2FA $google2fa, $generate=null) + { + $creds = Credentials::read(0, Credentials::TWOFA, $GLOBALS['egw_info']['user']['account_id']); + + if (!$generate && $creds && strlen($creds['2fa_password']) >= 16) + { + $secret_key = $creds['2fa_password']; + } + else + { + $secret_key = $google2fa->generateSecretKey();//16, $GLOBALS['egw_info']['user']['account_lid']); + } + $qrc = ''; + if (isset($generate) || empty($creds)) + { + $image = $google2fa->getQRCodeInline( + !empty($GLOBALS['egw_info']['server']['site_title']) ? + $GLOBALS['egw_info']['server']['site_title'] : 'EGroupware', + $GLOBALS['egw_info']['user']['account_email'], + $secret_key + ); + $qrc = 'data:image/'.(substr($image, 0, 5) === ' $qrc, + 'hide_qrc' => empty($qrc), + 'cred_id' => !empty($creds) ? $creds['2fa_cred_id'] : null, + 'secret_key' => $secret_key, + 'status' => !empty($creds) ? lang('Two Factor Auth is already setup.') : '', + ]; } /** diff --git a/preferences/templates/default/app.css b/preferences/templates/default/app.css index ec5afd25c2..fb406911ae 100644 --- a/preferences/templates/default/app.css +++ b/preferences/templates/default/app.css @@ -81,4 +81,27 @@ tr.prefRow:hover .prefHelp { white-space: pre-line; } -#preferences_settings_country_chzn {width:49% !important;} \ No newline at end of file +#preferences_settings_country_chzn {width:49% !important;} + +/** + * 2FA setup + */ +.securityHeader { + margin-top: 1em; + font-size: 120%; +} +img.qrCode { + position: relative; + left: -14px; +} +.toptApp { + display: list-item !important; + list-style-type: disc; + list-style-position: inside; + white-space: nowrap; +} +.toptStatus { + margin-top: 1em; + font-style: italic; + font-size: 120%; +} \ No newline at end of file diff --git a/preferences/templates/default/password.xet b/preferences/templates/default/password.xet index 562cdcef4b..e6e9abfc15 100644 --- a/preferences/templates/default/password.xet +++ b/preferences/templates/default/password.xet @@ -2,41 +2,96 @@ -