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 @@
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/preferences/templates/pixelegg/app.css b/preferences/templates/pixelegg/app.css
index 34f9e86faf..60864e63d7 100755
--- a/preferences/templates/pixelegg/app.css
+++ b/preferences/templates/pixelegg/app.css
@@ -103,6 +103,28 @@ textarea.prefValue {
#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%;
+}
/* #############################################################################
// iframe
// Rahmen + padding**/
|