From 69495b94af47aa6cea24a641ff10d571dcc3f75e Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Fri, 30 Nov 2018 13:45:17 +0100 Subject: [PATCH] add 2FA mechanism, fixes #547 Signed-off-by: Michael Kaufmann --- 2fa.php | 87 +++ actions/admin/settings/110.accounts.php | 8 + index.php | 634 ++++++++++++------ install/froxlor.sql | 7 +- .../updates/froxlor/0.10/update_0.10.inc.php | 19 + lib/classes/2FA/.gitignore | 189 ++++++ .../2FA/class.FroxlorTwoFactorAuth.php | 38 ++ .../Providers/Qr/BaseHTTPQRCodeProvider.php | 27 + .../lib/Providers/Qr/GoogleQRCodeProvider.php | 39 ++ .../2FA/lib/Providers/Qr/IQRCodeProvider.php | 9 + .../2FA/lib/Providers/Qr/QRException.php | 5 + .../2FA/lib/Providers/Qr/QRServerProvider.php | 71 ++ .../2FA/lib/Providers/Qr/QRicketProvider.php | 54 ++ .../2FA/lib/Providers/Rng/CSRNGProvider.php | 14 + .../2FA/lib/Providers/Rng/HashRNGProvider.php | 28 + .../2FA/lib/Providers/Rng/IRNGProvider.php | 9 + .../lib/Providers/Rng/MCryptRNGProvider.php | 23 + .../lib/Providers/Rng/OpenSSLRNGProvider.php | 25 + .../2FA/lib/Providers/Rng/RNGException.php | 5 + .../lib/Providers/Time/HttpTimeProvider.php | 54 ++ .../2FA/lib/Providers/Time/ITimeProvider.php | 8 + .../Time/LocalMachineTimeProvider.php | 9 + .../lib/Providers/Time/NTPTimeProvider.php | 52 ++ .../2FA/lib/Providers/Time/TimeException.php | 5 + lib/classes/2FA/lib/TwoFactorAuth.php | 256 +++++++ .../2FA/lib/TwoFactorAuthException.php | 7 + lib/version.inc.php | 2 +- lng/english.lng.php | 14 + lng/german.lng.php | 14 + templates/Sparkle/2fa/entercode.tpl | 26 + templates/Sparkle/2fa/overview.tpl | 40 ++ templates/Sparkle/header.tpl | 3 + 32 files changed, 1563 insertions(+), 218 deletions(-) create mode 100644 2fa.php create mode 100644 lib/classes/2FA/.gitignore create mode 100644 lib/classes/2FA/class.FroxlorTwoFactorAuth.php create mode 100644 lib/classes/2FA/lib/Providers/Qr/BaseHTTPQRCodeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Qr/GoogleQRCodeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Qr/IQRCodeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Qr/QRException.php create mode 100644 lib/classes/2FA/lib/Providers/Qr/QRServerProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Qr/QRicketProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Rng/CSRNGProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Rng/HashRNGProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Rng/IRNGProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Rng/MCryptRNGProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Rng/OpenSSLRNGProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Rng/RNGException.php create mode 100644 lib/classes/2FA/lib/Providers/Time/HttpTimeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Time/ITimeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Time/LocalMachineTimeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Time/NTPTimeProvider.php create mode 100644 lib/classes/2FA/lib/Providers/Time/TimeException.php create mode 100644 lib/classes/2FA/lib/TwoFactorAuth.php create mode 100644 lib/classes/2FA/lib/TwoFactorAuthException.php create mode 100644 templates/Sparkle/2fa/entercode.tpl create mode 100644 templates/Sparkle/2fa/overview.tpl diff --git a/2fa.php b/2fa.php new file mode 100644 index 00000000..36968589 --- /dev/null +++ b/2fa.php @@ -0,0 +1,87 @@ + (2018-) + * @license GPLv2 http://files.froxlor.org/misc/COPYING.txt + * @package Panel + * @since 0.10.0 + * + */ + +// This file is being included in admin_index and customer_index +// and therefore does not need to require lib/init.php +if (AREA == 'admin') { + $upd_stmt = Database::prepare("UPDATE `" . TABLE_PANEL_ADMINS . "` SET `type_2fa` = :t2fa, `data_2fa` = :d2fa WHERE adminid = :id"); + $uid = $userinfo['adminid']; +} elseif (AREA == 'customer') { + $upd_stmt = Database::prepare("UPDATE `" . TABLE_PANEL_CUSTOMERS . "` SET `type_2fa` = :t2fa, `data_2fa` = :d2fa WHERE customerid = :id"); + $uid = $userinfo['customerid']; +} +$success_message = ""; + +$tfa = new FroxlorTwoFactorAuth('Froxlor'); + +// do the delete and then just show a success-message +if ($action == 'delete') { + Database::pexecute($upd_stmt, array( + 't2fa' => 0, + 'd2fa' => "", + 'id' => $uid + )); + standard_success($lng['2fa']['2fa_removed']); +} elseif ($action == 'add') { + $type = isset($_POST['type_2fa']) ? $_POST['type_2fa'] : '0'; + + if ($type == 0 || $type == 1) { + $data = ""; + } + if ($type == 2) { + // generate secret for TOTP + $data = $tfa->createSecret(); + } + Database::pexecute($upd_stmt, array( + 't2fa' => $type, + 'd2fa' => $data, + 'id' => $uid + )); + standard_success($lng['2fa']['2fa_added']); +} + +$log->logAction(USR_ACTION, LOG_NOTICE, "viewed 2fa::overview"); + +if ($userinfo['type_2fa'] == '0') { + + // available types + $type_select_values = array( + 0 => '-', + 1 => 'E-Mail', + 2 => 'Authenticator' + ); + asort($type_select_values); + foreach ($type_select_values as $_val => $_type) { + $type_select .= makeoption($_type, $_val); + } +} +elseif ($userinfo['type_2fa'] == '1') { + // email 2fa enabled +} +elseif ($userinfo['type_2fa'] == '2') { + // authenticator 2fa enabled + $ga_qrcode = $tfa->getQRCodeImageAsDataUri($userinfo['loginname'], $userinfo['data_2fa']); +} +eval("echo \"" . getTemplate("2fa/overview", true) . "\";"); diff --git a/actions/admin/settings/110.accounts.php b/actions/admin/settings/110.accounts.php index 73890e4c..211bb723 100644 --- a/actions/admin/settings/110.accounts.php +++ b/actions/admin/settings/110.accounts.php @@ -62,6 +62,14 @@ return array( 'default' => 900, 'save_method' => 'storeSettingField', ), + '2fa_enabled' => array( + 'label' => $lng['2fa']['2fa_enabled'], + 'settinggroup' => '2fa', + 'varname' => 'enabled', + 'type' => 'bool', + 'default' => true, + 'save_method' => 'storeSettingField', + ), 'panel_password_min_length' => array( 'label' => $lng['serversettings']['panel_password_min_length'], 'settinggroup' => 'panel', diff --git a/index.php b/index.php index a970c53b..8d896d34 100644 --- a/index.php +++ b/index.php @@ -16,7 +16,6 @@ * @package Panel * */ - define('AREA', 'login'); require './lib/init.php'; @@ -24,15 +23,89 @@ if ($action == '') { $action = 'login'; } -if ($action == 'login') { +session_start(); + +if ($action == '2fa_entercode') { + // page for entering the 2FA code after successful login + if (! isset($_SESSION) || ! isset($_SESSION['secret_2fa'])) { + // no session - redirect to index + redirectTo('index.php'); + exit(); + } + // show template to enter code + eval("echo \"" . getTemplate('2fa/entercode', true) . "\";"); +} elseif ($action == '2fa_verify') { + // verify code from 2fa code-enter form + if (! isset($_SESSION) || ! isset($_SESSION['secret_2fa'])) { + // no session - redirect to index + redirectTo('index.php'); + exit(); + } + $code = isset($_POST['2fa_code']) ? $_POST['2fa_code'] : null; + // verify entered code + $tfa = new FroxlorTwoFactorAuth('Froxlor'); + $result = ($_SESSION['secret_2fa'] == 'email' ? true : $tfa->verifyCode($_SESSION['secret_2fa'], $code, 3)); + // either the code is valid when using authenticator-app, or we will select userdata by id and entered code + // which is temporarily stored for the customer when using email-2fa + if ($result) { + // get user-data + $table = $_SESSION['uidtable_2fa']; + $field = $_SESSION['uidfield_2fa']; + $uid = $_SESSION['uid_2fa']; + $isadmin = $_SESSION['unfo_2fa']; + $sel_param = array( + 'uid' => $uid + ); + if ($_SESSION['secret_2fa'] == 'email') { + // verify code by selecting user by id and the temp. stored code, + // so only if it's the correct code, we get the user-data + $sel_stmt = Database::prepare("SELECT * FROM $table WHERE `" . $field . "` = :uid AND `data_2fa` = :code"); + $sel_param['code'] = $code; + } else { + // Authenticator-verification has already happened at this point, so just get the user-data + $sel_stmt = Database::prepare("SELECT * FROM $table WHERE `" . $field . "` = :uid"); + } + $userinfo = Database::pexecute_first($sel_stmt, $sel_param); + // whoops, no (valid) user? Start again + if (empty($userinfo)) { + redirectTo('index.php', array( + 'showmessage' => '2' + )); + } + // set fields in $userinfo required for finishLogin() + $userinfo['adminsession'] = $isadmin; + $userinfo['userid'] = $uid; + + // if not successful somehow - start again + if (! finishLogin($userinfo)) { + redirectTo('index.php', array( + 'showmessage' => '2' + )); + } + + // when using email-2fa, remove the one-time-code + if ($userinfo['type_2fa'] == '1') { + $del_stmt = Database::prepare("UPDATE $table SET `data_2fa` = '' WHERE `" . $field . "` = :uid"); + $userinfo = Database::pexecute_first($del_stmt, array( + 'uid' => $uid + )); + } + exit(); + } + redirectTo('index.php', array( + 'showmessage' => '2' + )); + exit(); +} elseif ($action == 'login') { if (isset($_POST['send']) && $_POST['send'] == 'send') { $loginname = validate($_POST['loginname'], 'loginname'); $password = validate($_POST['password'], 'password'); $stmt = Database::prepare("SELECT `loginname` AS `customer` FROM `" . TABLE_PANEL_CUSTOMERS . "` - WHERE `loginname`= :loginname" - ); - Database::pexecute($stmt, array("loginname" => $loginname)); + WHERE `loginname`= :loginname"); + Database::pexecute($stmt, array( + "loginname" => $loginname + )); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row['customer'] == $loginname) { @@ -42,21 +115,26 @@ if ($action == 'login') { $is_admin = false; } else { $is_admin = true; - if ((int)Settings::Get('login.domain_login') == 1) { - $domainname = $idna_convert->encode(preg_replace(array('/\:(\d)+$/', '/^https?\:\/\//'), '', $loginname)); + if ((int) Settings::Get('login.domain_login') == 1) { + $domainname = $idna_convert->encode(preg_replace(array( + '/\:(\d)+$/', + '/^https?\:\/\//' + ), '', $loginname)); $stmt = Database::prepare("SELECT `customerid` FROM `" . TABLE_PANEL_DOMAINS . "` - WHERE `domain` = :domain" - ); - Database::pexecute($stmt, array("domain" => $domainname)); + WHERE `domain` = :domain"); + Database::pexecute($stmt, array( + "domain" => $domainname + )); $row2 = $stmt->fetch(PDO::FETCH_ASSOC); if (isset($row2['customerid']) && $row2['customerid'] > 0) { $loginname = getCustomerDetail($row2['customerid'], 'loginname'); if ($loginname !== false) { $stmt = Database::prepare("SELECT `loginname` AS `customer` FROM `" . TABLE_PANEL_CUSTOMERS . "` - WHERE `loginname`= :loginname" - ); - Database::pexecute($stmt, array("loginname" => $loginname)); + WHERE `loginname`= :loginname"); + Database::pexecute($stmt, array( + "loginname" => $loginname + )); $row3 = $stmt->fetch(PDO::FETCH_ASSOC); if ($row3['customer'] == $loginname) { $table = "`" . TABLE_PANEL_CUSTOMERS . "`"; @@ -71,27 +149,29 @@ if ($action == 'login') { if ((hasUpdates($version) || hasDbUpdates($dbversion)) && $is_admin == false) { redirectTo('index.php'); - exit; + exit(); } if ($is_admin) { if (hasUpdates($version) || hasDbUpdates($dbversion)) { $stmt = Database::prepare("SELECT `loginname` AS `admin` FROM `" . TABLE_PANEL_ADMINS . "` WHERE `loginname`= :loginname - AND `change_serversettings` = '1'" - ); - Database::pexecute($stmt, array("loginname" => $loginname)); + AND `change_serversettings` = '1'"); + Database::pexecute($stmt, array( + "loginname" => $loginname + )); $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!isset($row['admin'])) { + if (! isset($row['admin'])) { // not an admin who can see updates redirectTo('index.php'); - exit; + exit(); } } else { $stmt = Database::prepare("SELECT `loginname` AS `admin` FROM `" . TABLE_PANEL_ADMINS . "` - WHERE `loginname`= :loginname" - ); - Database::pexecute($stmt, array("loginname" => $loginname)); + WHERE `loginname`= :loginname"); + Database::pexecute($stmt, array( + "loginname" => $loginname + )); $row = $stmt->fetch(PDO::FETCH_ASSOC); } @@ -101,151 +181,142 @@ if ($action == 'login') { $adminsession = '1'; } else { // Log failed login - $rstlog = FroxlorLogger::getInstanceOf(array('loginname' => $_SERVER['REMOTE_ADDR'])); + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => $_SERVER['REMOTE_ADDR'] + )); $rstlog->logAction(LOGIN_ACTION, LOG_WARNING, "Unknown user '" . $loginname . "' tried to login."); - redirectTo('index.php', array('showmessage' => '2')); - exit; + redirectTo('index.php', array( + 'showmessage' => '2' + )); + exit(); } } $userinfo_stmt = Database::prepare("SELECT * FROM $table - WHERE `loginname`= :loginname" - ); - Database::pexecute($userinfo_stmt, array("loginname" => $loginname)); + WHERE `loginname`= :loginname"); + Database::pexecute($userinfo_stmt, array( + "loginname" => $loginname + )); $userinfo = $userinfo_stmt->fetch(PDO::FETCH_ASSOC); if ($userinfo['loginfail_count'] >= Settings::Get('login.maxloginattempts') && $userinfo['lastlogin_fail'] > (time() - Settings::Get('login.deactivatetime'))) { - redirectTo('index.php', array('showmessage' => '3')); - exit; + redirectTo('index.php', array( + 'showmessage' => '3' + )); + exit(); } elseif (validatePasswordLogin($userinfo, $password, $table, $uid)) { - // only show "you're banned" if the login was successful - // because we don't want to publish that the user does exist - if ($userinfo['deactivated']) { - unset($userinfo); - redirectTo('index.php', array('showmessage' => '5')); - exit; - } else { - // login correct - // reset loginfail_counter, set lastlogin_succ - $stmt = Database::prepare("UPDATE $table + // only show "you're banned" if the login was successful + // because we don't want to publish that the user does exist + if ($userinfo['deactivated']) { + unset($userinfo); + redirectTo('index.php', array( + 'showmessage' => '5' + )); + exit(); + } else { + // login correct + // reset loginfail_counter, set lastlogin_succ + $stmt = Database::prepare("UPDATE $table SET `lastlogin_succ`= :lastlogin_succ, `loginfail_count`='0' - WHERE `$uid`= :uid" - ); - Database::pexecute($stmt, array("lastlogin_succ" => time(), "uid" => $userinfo[$uid])); - $userinfo['userid'] = $userinfo[$uid]; - $userinfo['adminsession'] = $adminsession; - } + WHERE `$uid`= :uid"); + Database::pexecute($stmt, array( + "lastlogin_succ" => time(), + "uid" => $userinfo[$uid] + )); + $userinfo['userid'] = $userinfo[$uid]; + $userinfo['adminsession'] = $adminsession; + } } else { // login incorrect $stmt = Database::prepare("UPDATE $table SET `lastlogin_fail`= :lastlogin_fail, `loginfail_count`=`loginfail_count`+1 - WHERE `$uid`= :uid" - ); - Database::pexecute($stmt, array("lastlogin_fail" => time(), "uid" => $userinfo[$uid])); + WHERE `$uid`= :uid"); + Database::pexecute($stmt, array( + "lastlogin_fail" => time(), + "uid" => $userinfo[$uid] + )); // Log failed login - $rstlog = FroxlorLogger::getInstanceOf(array('loginname' => $_SERVER['REMOTE_ADDR'])); + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => $_SERVER['REMOTE_ADDR'] + )); $rstlog->logAction(LOGIN_ACTION, LOG_WARNING, "User '" . $loginname . "' tried to login with wrong password."); unset($userinfo); - redirectTo('index.php', array('showmessage' => '2')); - exit; + redirectTo('index.php', array( + 'showmessage' => '2' + )); + exit(); } - if (isset($userinfo['userid']) && $userinfo['userid'] != '') { - $s = md5(uniqid(microtime(), 1)); - - if (isset($_POST['language'])) { - $language = validate($_POST['language'], 'language'); - if ($language == 'profile') { - $language = $userinfo['def_language']; - } elseif (!isset($languages[$language])) { - $language = Settings::Get('panel.standardlanguage'); - } - } else { - $language = Settings::Get('panel.standardlanguage'); - } - - if (isset($userinfo['theme']) && $userinfo['theme'] != '') { - $theme = $userinfo['theme']; - } else { - $theme = Settings::Get('panel.default_theme'); - } - - if (Settings::Get('session.allow_multiple_login') != '1') { - $stmt = Database::prepare("DELETE FROM `" . TABLE_PANEL_SESSIONS . "` - WHERE `userid` = :uid - AND `adminsession` = :adminsession" + // 2FA activated + if (Settings::Get('2fa.enabled') == '1' && $userinfo['type_2fa'] > 0) { + // redirect to code-enter-page + $_SESSION['secret_2fa'] = ($userinfo['type_2fa'] == 2 ? $userinfo['data_2fa'] : 'email'); + $_SESSION['uid_2fa'] = $userinfo[$uid]; + $_SESSION['uidfield_2fa'] = $uid; + $_SESSION['uidtable_2fa'] = $table; + $_SESSION['unfo_2fa'] = $is_admin; + // send mail if type_2fa = 1 (email) + if ($userinfo['type_2fa'] == 1) { + // generate code + $tfa = new FroxlorTwoFactorAuth('Froxlor'); + $code = $tfa->getCode($tfa->createSecret()); + // set code for user + $stmt = Database::prepare("UPDATE $table SET `data_2fa` = :d2fa WHERE `$uid` = :uid"); + Database::pexecute($stmt, array( + "d2fa" => $code, + "uid" => $userinfo[$uid] + )); + // build up & send email + $_mailerror = false; + $mailerr_msg = ""; + $replace_arr = array( + 'CODE' => $code ); - Database::pexecute($stmt, array("uid" => $userinfo['userid'], "adminsession" => $userinfo['adminsession'])); - } + $mail_body = html_entity_decode(replace_variables($lng['mails']['2fa']['mailbody'], $replace_arr)); - // check for field 'theme' in session-table, refs #607 - // Changed with #1287 to new method - $theme_field = false; - $stmt = Database::query("SHOW COLUMNS FROM panel_sessions LIKE 'theme'"); - while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { - if ($row['Field'] == "theme") { - $has_theme = true; + try { + $mail->Subject = $lng['mails']['2fa']['subject']; + $mail->AltBody = $mail_body; + $mail->MsgHTML(str_replace("\n", "
", $mail_body)); + $mail->AddAddress($userinfo['email'], getCorrectUserSalutation($userinfo)); + $mail->Send(); + } catch (phpmailerException $e) { + $mailerr_msg = $e->errorMessage(); + $_mailerror = true; + } catch (Exception $e) { + $mailerr_msg = $e->getMessage(); + $_mailerror = true; } - } - $params = array( - "hash" => $s, - "userid" => $userinfo['userid'], - "ipaddress" => $remote_addr, - "useragent" => $http_user_agent, - "lastactivity" => time(), - "language" => $language, - "adminsession" => $userinfo['adminsession'] - ); - - if ($has_theme) { - $params["theme"] = $theme; - $stmt = Database::prepare("INSERT INTO `" . TABLE_PANEL_SESSIONS . "` - (`hash`, `userid`, `ipaddress`, `useragent`, `lastactivity`, `language`, `adminsession`, `theme`) - VALUES (:hash, :userid, :ipaddress, :useragent, :lastactivity, :language, :adminsession, :theme)" - ); - } else { - $stmt = Database::prepare("INSERT INTO `" . TABLE_PANEL_SESSIONS . "` - (`hash`, `userid`, `ipaddress`, `useragent`, `lastactivity`, `language`, `adminsession`) - VALUES (:hash, :userid, :ipaddress, :useragent, :lastactivity, :language, :adminsession)" - ); - } - Database::pexecute($stmt, $params); - - $qryparams = array(); - if (isset($_POST['qrystr']) && $_POST['qrystr'] != "") { - parse_str(urldecode($_POST['qrystr']), $qryparams); - } - $qryparams['s'] = $s; - - if ($userinfo['adminsession'] == '1') { - if (hasUpdates($version) || hasDbUpdates($dbversion)) { - redirectTo('admin_updates.php', array('s' => $s)); - } else { - if (isset($_POST['script']) && $_POST['script'] != "") { - if (preg_match("/customer\_/", $_POST['script']) === 1) { - redirectTo('admin_customers.php', array("page" => "customers")); - } else { - redirectTo($_POST['script'], $qryparams); - } - } else { - redirectTo('admin_index.php', $qryparams); - } - } - } else { - if (isset($_POST['script']) && $_POST['script'] != "") { - redirectTo($_POST['script'], $qryparams); - } else { - redirectTo('customer_index.php', $qryparams); + if ($_mailerror) { + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => '2fa code-sending' + )); + $rstlog->logAction(ADM_ACTION, LOG_ERR, "Error sending mail: " . $mailerr_msg); + redirectTo('index.php', array( + 'showmessage' => '4', + 'customermail' => $userinfo['email'] + )); + exit(); } + + $mail->ClearAddresses(); } - } else { - redirectTo('index.php', array('showmessage' => '2')); + redirectTo('index.php', array( + 'action' => '2fa_entercode' + )); + exit(); } - exit; + + if (! finishLogin($userinfo)) { + redirectTo('index.php', array( + 'showmessage' => '2' + )); + } + exit(); } else { $language_options = ''; $language_options .= makeoption($lng['login']['profile_lng'], 'profile', 'profile', true, true); @@ -254,49 +325,49 @@ if ($action == 'login') { $language_options .= makeoption($language_name, $language_file, 'profile', true); } - $smessage = isset($_GET['showmessage']) ? (int)$_GET['showmessage'] : 0; + $smessage = isset($_GET['showmessage']) ? (int) $_GET['showmessage'] : 0; $message = ''; $successmessage = ''; switch ($smessage) { - case 1: - $successmessage = $lng['pwdreminder']['success']; - break; - case 2: - $message = $lng['error']['login']; - break; - case 3: - $message = sprintf($lng['error']['login_blocked'], Settings::Get('login.deactivatetime')); - break; - case 4: - $cmail = isset($_GET['customermail']) ? $_GET['customermail'] : 'unknown'; - $message = str_replace('%s', $cmail, $lng['error']['errorsendingmail']); - break; - case 5: - $message = $lng['error']['user_banned']; - break; - case 6: - $successmessage = $lng['pwdreminder']['changed']; - break; - case 7: - $message = $lng['pwdreminder']['wrongcode']; - break; - case 8: - $message = $lng['pwdreminder']['notallowed']; - break; + case 1: + $successmessage = $lng['pwdreminder']['success']; + break; + case 2: + $message = $lng['error']['login']; + break; + case 3: + $message = sprintf($lng['error']['login_blocked'], Settings::Get('login.deactivatetime')); + break; + case 4: + $cmail = isset($_GET['customermail']) ? $_GET['customermail'] : 'unknown'; + $message = str_replace('%s', $cmail, $lng['error']['errorsendingmail']); + break; + case 5: + $message = $lng['error']['user_banned']; + break; + case 6: + $successmessage = $lng['pwdreminder']['changed']; + break; + case 7: + $message = $lng['pwdreminder']['wrongcode']; + break; + case 8: + $message = $lng['pwdreminder']['notallowed']; + break; } $update_in_progress = ''; if (hasUpdates($version) || hasDbUpdates($dbversion)) { $update_in_progress = $lng['update']['updateinprogress_onlyadmincanlogin']; } - + // Pass the last used page if needed $lastscript = ""; if (isset($_REQUEST['script']) && $_REQUEST['script'] != "") { $lastscript = $_REQUEST['script']; - if (!file_exists(__DIR__."/".$lastscript)) { + if (! file_exists(__DIR__ . "/" . $lastscript)) { $lastscript = ""; } } @@ -318,16 +389,20 @@ if ($action == 'forgotpwd') { $email = validateEmail($_POST['loginemail'], 'email'); $result_stmt = Database::prepare("SELECT `adminid`, `customerid`, `firstname`, `name`, `company`, `email`, `loginname`, `def_language`, `deactivated` FROM `" . TABLE_PANEL_CUSTOMERS . "` WHERE `loginname`= :loginname - AND `email`= :email" - ); - Database::pexecute($result_stmt, array("loginname" => $loginname, "email" => $email)); + AND `email`= :email"); + Database::pexecute($result_stmt, array( + "loginname" => $loginname, + "email" => $email + )); if (Database::num_rows() == 0) { $result_stmt = Database::prepare("SELECT `adminid`, `name`, `email`, `loginname`, `def_language`, `deactivated` FROM `" . TABLE_PANEL_ADMINS . "` WHERE `loginname`= :loginname - AND `email`= :email" - ); - Database::pexecute($result_stmt, array("loginname" => $loginname, "email" => $email)); + AND `email`= :email"); + Database::pexecute($result_stmt, array( + "loginname" => $loginname, + "email" => $email + )); if (Database::num_rows() > 0) { $adminchecked = true; @@ -341,8 +416,10 @@ if ($action == 'forgotpwd') { /* Check whether user is banned */ if ($user['deactivated']) { - redirectTo('index.php', array('showmessage' => '8')); - exit; + redirectTo('index.php', array( + 'showmessage' => '8' + )); + exit(); } if (($adminchecked && Settings::Get('panel.allow_preset_admin') == '1') || $adminchecked == false) { @@ -350,14 +427,13 @@ if ($action == 'forgotpwd') { // build a activation code $timestamp = time(); $first = substr(md5($user['loginname'] . $timestamp . randomStr(16)), 0, 15); - $third = substr(md5($user['email'] . $timestamp . randomStr(16)), -15); + $third = substr(md5($user['email'] . $timestamp . randomStr(16)), - 15); $activationcode = $first . $timestamp . $third . substr(md5($third . $timestamp), 0, 10); // Drop all existing activation codes for this user $stmt = Database::prepare("DELETE FROM `" . TABLE_PANEL_ACTIVATION . "` WHERE `userid` = :userid - AND `admin` = :admin" - ); + AND `admin` = :admin"); $params = array( "userid" => $adminchecked ? $user['adminid'] : $user['customerid'], "admin" => $adminchecked ? 1 : 0 @@ -367,8 +443,7 @@ if ($action == 'forgotpwd') { // Add new activation code to database $stmt = Database::prepare("INSERT INTO `" . TABLE_PANEL_ACTIVATION . "` (userid, admin, creation, activationcode) - VALUES (:userid, :admin, :creation, :activationcode)" - ); + VALUES (:userid, :admin, :creation, :activationcode)"); $params = array( "userid" => $adminchecked ? $user['adminid'] : $user['customerid'], "admin" => $adminchecked ? 1 : 0, @@ -377,11 +452,13 @@ if ($action == 'forgotpwd') { ); Database::pexecute($stmt, $params); - $rstlog = FroxlorLogger::getInstanceOf(array('loginname' => 'password_reset')); + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => 'password_reset' + )); $rstlog->logAction(USR_ACTION, LOG_WARNING, "User '" . $user['loginname'] . "' requested a link for setting a new password."); // Set together our activation link - $protocol = empty( $_SERVER['HTTPS'] ) ? 'http' : 'https'; + $protocol = empty($_SERVER['HTTPS']) ? 'http' : 'https'; // this can be a fixed value to avoid potential exploiting by modifying headers $host = Settings::Get('system.hostname'); // $_SERVER['HTTP_HOST']; $port = $_SERVER['SERVER_PORT'] != 80 ? ':' . $_SERVER['SERVER_PORT'] : ''; @@ -392,7 +469,7 @@ if ($action == 'forgotpwd') { // there can be only one script to handle this so we can use a fixed value here $script = "/index.php"; // $_SERVER['SCRIPT_NAME']; if (Settings::Get('system.froxlordirectlyviahostname') == 0) { - $script = makeCorrectFile("/".basename(__DIR__)."/".$script); + $script = makeCorrectFile("/" . basename(__DIR__) . "/" . $script); } $activationlink = $protocol . '://' . $host . $port . $script . '?action=resetpwd&resetcode=' . $activationcode; @@ -407,9 +484,11 @@ if ($action == 'forgotpwd') { WHERE `adminid`= :adminid AND `language`= :lang AND `templategroup`=\'mails\' - AND `varname`=\'password_reset_subject\'' - ); - Database::pexecute($result_stmt, array("adminid" => $user['adminid'], "lang" => $def_language)); + AND `varname`=\'password_reset_subject\''); + Database::pexecute($result_stmt, array( + "adminid" => $user['adminid'], + "lang" => $def_language + )); $result = $result_stmt->fetch(PDO::FETCH_ASSOC); $mail_subject = html_entity_decode(replace_variables((($result['value'] != '') ? $result['value'] : $lng['mails']['password_reset']['subject']), $replace_arr)); @@ -417,20 +496,23 @@ if ($action == 'forgotpwd') { WHERE `adminid`= :adminid AND `language`= :lang AND `templategroup`=\'mails\' - AND `varname`=\'password_reset_mailbody\'' - ); - Database::pexecute($result_stmt, array("adminid" => $user['adminid'], "lang" => $def_language)); + AND `varname`=\'password_reset_mailbody\''); + Database::pexecute($result_stmt, array( + "adminid" => $user['adminid'], + "lang" => $def_language + )); $result = $result_stmt->fetch(PDO::FETCH_ASSOC); $mail_body = html_entity_decode(replace_variables((($result['value'] != '') ? $result['value'] : $lng['mails']['password_reset']['mailbody']), $replace_arr)); $_mailerror = false; + $mailerr_msg = ""; try { $mail->Subject = $mail_subject; $mail->AltBody = $mail_body; $mail->MsgHTML(str_replace("\n", "
", $mail_body)); $mail->AddAddress($user['email'], getCorrectUserSalutation($user)); $mail->Send(); - } catch(phpmailerException $e) { + } catch (phpmailerException $e) { $mailerr_msg = $e->errorMessage(); $_mailerror = true; } catch (Exception $e) { @@ -439,17 +521,26 @@ if ($action == 'forgotpwd') { } if ($_mailerror) { - $rstlog = FroxlorLogger::getInstanceOf(array('loginname' => 'password_reset')); + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => 'password_reset' + )); $rstlog->logAction(ADM_ACTION, LOG_ERR, "Error sending mail: " . $mailerr_msg); - redirectTo('index.php', array('showmessage' => '4', 'customermail' => $user['email'])); - exit; + redirectTo('index.php', array( + 'showmessage' => '4', + 'customermail' => $user['email'] + )); + exit(); } $mail->ClearAddresses(); - redirectTo('index.php', array('showmessage' => '1')); - exit; + redirectTo('index.php', array( + 'showmessage' => '1' + )); + exit(); } else { - $rstlog = FroxlorLogger::getInstanceOf(array('loginname' => 'password_reset')); + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => 'password_reset' + )); $rstlog->logAction(USR_ACTION, LOG_WARNING, "User '" . $loginname . "' requested to set a new password, but was not found in database!"); $message = $lng['login']['combination_not_found']; } @@ -464,7 +555,7 @@ if ($action == 'forgotpwd') { if ($adminchecked) { if (Settings::Get('panel.allow_preset_admin') != '1') { $message = $lng['pwdreminder']['notallowed']; - unset ($adminchecked); + unset($adminchecked); } } else { if (Settings::Get('panel.allow_preset') != '1') { @@ -480,9 +571,10 @@ if ($action == 'resetpwd') { // Remove old activation codes $stmt = Database::prepare("DELETE FROM `" . TABLE_PANEL_ACTIVATION . "` - WHERE creation < :oldest" - ); - Database::pexecute($stmt, array("oldest" => time() - 86400)); + WHERE creation < :oldest"); + Database::pexecute($stmt, array( + "oldest" => time() - 86400 + )); if (isset($_GET['resetcode']) && strlen($_GET['resetcode']) == 50) { // Check if activation code is valid @@ -494,9 +586,10 @@ if ($action == 'resetpwd') { if (substr(md5($third . $timestamp), 0, 10) == $check && $timestamp >= time() - 86400) { if (isset($_POST['send']) && $_POST['send'] == 'send') { $stmt = Database::prepare("SELECT `userid`, `admin` FROM `" . TABLE_PANEL_ACTIVATION . "` - WHERE `activationcode` = :activationcode" - ); - $result = Database::pexecute_first($stmt, array("activationcode" => $activationcode)); + WHERE `activationcode` = :activationcode"); + $result = Database::pexecute_first($stmt, array( + "activationcode" => $activationcode + )); if ($result !== false) { if ($result['admin'] == 1) { @@ -518,39 +611,148 @@ if ($action == 'resetpwd') { if ($result['admin'] == 1) { $stmt = Database::prepare("UPDATE `" . TABLE_PANEL_ADMINS . "` SET `password` = :newpassword - WHERE `adminid` = :userid" - ); + WHERE `adminid` = :userid"); } else { $stmt = Database::prepare("UPDATE `" . TABLE_PANEL_CUSTOMERS . "` SET `password` = :newpassword - WHERE `customerid` = :userid" - ); + WHERE `customerid` = :userid"); } - Database::pexecute($stmt, array("newpassword" => makeCryptPassword($new_password), "userid" => $result['userid'])); + Database::pexecute($stmt, array( + "newpassword" => makeCryptPassword($new_password), + "userid" => $result['userid'] + )); - $rstlog = FroxlorLogger::getInstanceOf(array('loginname' => 'password_reset')); + $rstlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => 'password_reset' + )); $rstlog->logAction(USR_ACTION, LOG_NOTICE, "changed password using password reset."); // Remove activation code from DB $stmt = Database::prepare("DELETE FROM `" . TABLE_PANEL_ACTIVATION . "` WHERE `activationcode` = :activationcode - AND `userid` = :userid" - ); - Database::pexecute($stmt, array("activationcode" => $activationcode, "userid" => $result['userid'])); - redirectTo('index.php', array("showmessage" => '6')); + AND `userid` = :userid"); + Database::pexecute($stmt, array( + "activationcode" => $activationcode, + "userid" => $result['userid'] + )); + redirectTo('index.php', array( + "showmessage" => '6' + )); } } else { - redirectTo('index.php', array("showmessage" => '7')); + redirectTo('index.php', array( + "showmessage" => '7' + )); } } eval("echo \"" . getTemplate('rpwd') . "\";"); - } else { - redirectTo('index.php', array("showmessage" => '7')); + redirectTo('index.php', array( + "showmessage" => '7' + )); } - } else { redirectTo('index.php'); } } + +function finishLogin($userinfo) +{ + global $version, $dbversion, $remote_addr, $http_user_agent, $languages; + + if (isset($userinfo['userid']) && $userinfo['userid'] != '') { + $s = md5(uniqid(microtime(), 1)); + + if (isset($_POST['language'])) { + $language = validate($_POST['language'], 'language'); + if ($language == 'profile') { + $language = $userinfo['def_language']; + } elseif (! isset($languages[$language])) { + $language = Settings::Get('panel.standardlanguage'); + } + } else { + $language = Settings::Get('panel.standardlanguage'); + } + + if (isset($userinfo['theme']) && $userinfo['theme'] != '') { + $theme = $userinfo['theme']; + } else { + $theme = Settings::Get('panel.default_theme'); + } + + if (Settings::Get('session.allow_multiple_login') != '1') { + $stmt = Database::prepare("DELETE FROM `" . TABLE_PANEL_SESSIONS . "` + WHERE `userid` = :uid + AND `adminsession` = :adminsession"); + Database::pexecute($stmt, array( + "uid" => $userinfo['userid'], + "adminsession" => $userinfo['adminsession'] + )); + } + + // check for field 'theme' in session-table, refs #607 + // Changed with #1287 to new method + $stmt = Database::query("SHOW COLUMNS FROM panel_sessions LIKE 'theme'"); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if ($row['Field'] == "theme") { + $has_theme = true; + } + } + + $params = array( + "hash" => $s, + "userid" => $userinfo['userid'], + "ipaddress" => $remote_addr, + "useragent" => $http_user_agent, + "lastactivity" => time(), + "language" => $language, + "adminsession" => $userinfo['adminsession'] + ); + + if ($has_theme) { + $params["theme"] = $theme; + $stmt = Database::prepare("INSERT INTO `" . TABLE_PANEL_SESSIONS . "` + (`hash`, `userid`, `ipaddress`, `useragent`, `lastactivity`, `language`, `adminsession`, `theme`) + VALUES (:hash, :userid, :ipaddress, :useragent, :lastactivity, :language, :adminsession, :theme)"); + } else { + $stmt = Database::prepare("INSERT INTO `" . TABLE_PANEL_SESSIONS . "` + (`hash`, `userid`, `ipaddress`, `useragent`, `lastactivity`, `language`, `adminsession`) + VALUES (:hash, :userid, :ipaddress, :useragent, :lastactivity, :language, :adminsession)"); + } + Database::pexecute($stmt, $params); + + $qryparams = array(); + if (isset($_POST['qrystr']) && $_POST['qrystr'] != "") { + parse_str(urldecode($_POST['qrystr']), $qryparams); + } + $qryparams['s'] = $s; + + if ($userinfo['adminsession'] == '1') { + if (hasUpdates($version) || hasDbUpdates($dbversion)) { + redirectTo('admin_updates.php', array( + 's' => $s + )); + } else { + if (isset($_POST['script']) && $_POST['script'] != "") { + if (preg_match("/customer\_/", $_POST['script']) === 1) { + redirectTo('admin_customers.php', array( + "page" => "customers" + )); + } else { + redirectTo($_POST['script'], $qryparams); + } + } else { + redirectTo('admin_index.php', $qryparams); + } + } + } else { + if (isset($_POST['script']) && $_POST['script'] != "") { + redirectTo($_POST['script'], $qryparams); + } else { + redirectTo('customer_index.php', $qryparams); + } + } + } + return false; +} \ No newline at end of file diff --git a/install/froxlor.sql b/install/froxlor.sql index 4b362704..9801cfd5 100644 --- a/install/froxlor.sql +++ b/install/froxlor.sql @@ -133,6 +133,8 @@ CREATE TABLE `panel_admins` ( `theme` varchar(255) NOT NULL default 'Sparkle', `custom_notes` text, `custom_notes_show` tinyint(1) NOT NULL default '0', + `type_2fa` tinyint(1) NOT NULL default '0', + `data_2fa` varchar(500) NOT NULL default '', PRIMARY KEY (`adminid`), UNIQUE KEY `loginname` (`loginname`) ) ENGINE=MyISAM CHARSET=utf8 COLLATE=utf8_general_ci; @@ -200,6 +202,8 @@ CREATE TABLE `panel_customers` ( `leregistered` tinyint(1) NOT NULL default '0', `leaccount` varchar(255) default '', `allowed_phpconfigs` varchar(500) NOT NULL default '', + `type_2fa` tinyint(1) NOT NULL default '0', + `data_2fa` varchar(500) NOT NULL default '', PRIMARY KEY (`customerid`), UNIQUE KEY `loginname` (`loginname`) ) ENGINE=MyISAM CHARSET=utf8 COLLATE=utf8_general_ci; @@ -656,6 +660,7 @@ opcache.interned_strings_buffer'), ('system', 'logfiles_script', ''), ('system', 'dhparams_file', ''), ('api', 'enabled', '0'), + ('2fa', 'enabled', '1'), ('panel', 'decimal_places', '4'), ('panel', 'adminmail', 'admin@SERVERNAME'), ('panel', 'phpmyadmin_url', ''), @@ -688,7 +693,7 @@ opcache.interned_strings_buffer'), ('panel', 'password_special_char', '!?<>§$%+#=@'), ('panel', 'customer_hide_options', ''), ('panel', 'version', '0.10.0'), - ('panel', 'db_version', '201811180'); + ('panel', 'db_version', '201811300'); DROP TABLE IF EXISTS `panel_tasks`; diff --git a/install/updates/froxlor/0.10/update_0.10.inc.php b/install/updates/froxlor/0.10/update_0.10.inc.php index 713a397d..7ff7a986 100644 --- a/install/updates/froxlor/0.10/update_0.10.inc.php +++ b/install/updates/froxlor/0.10/update_0.10.inc.php @@ -77,3 +77,22 @@ if (isDatabaseVersion('201809280')) { updateToDbVersion('201811180'); } + +if (isDatabaseVersion('201811180')) { + + showUpdateStep("Adding new settings for 2FA"); + Settings::Add('2fa.enabled', '1', true); + lastStepStatus(0); + + showUpdateStep("Adding new fields to admin-table for 2FA"); + Database::query("ALTER TABLE `" . TABLE_PANEL_ADMINS . "` ADD `type_2fa` tinyint(1) NOT NULL default '0';"); + Database::query("ALTER TABLE `" . TABLE_PANEL_ADMINS . "` ADD `data_2fa` varchar(500) NOT NULL default '' AFTER `type_2fa`;"); + lastStepStatus(0); + + showUpdateStep("Adding new fields to customer-table for 2FA"); + Database::query("ALTER TABLE `" . TABLE_PANEL_CUSTOMERS . "` ADD `type_2fa` tinyint(1) NOT NULL default '0';"); + Database::query("ALTER TABLE `" . TABLE_PANEL_CUSTOMERS . "` ADD `data_2fa` varchar(500) NOT NULL default '' AFTER `type_2fa`;"); + lastStepStatus(0); + + updateToDbVersion('201811300'); +} diff --git a/lib/classes/2FA/.gitignore b/lib/classes/2FA/.gitignore new file mode 100644 index 00000000..8a25841c --- /dev/null +++ b/lib/classes/2FA/.gitignore @@ -0,0 +1,189 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# If using the old MSBuild-Integrated Package Restore, uncomment this: +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Composer +/vendor + +# .vs +.vs/ \ No newline at end of file diff --git a/lib/classes/2FA/class.FroxlorTwoFactorAuth.php b/lib/classes/2FA/class.FroxlorTwoFactorAuth.php new file mode 100644 index 00000000..b22700dc --- /dev/null +++ b/lib/classes/2FA/class.FroxlorTwoFactorAuth.php @@ -0,0 +1,38 @@ + (2010-) + * @license GPLv2 http://files.froxlor.org/misc/COPYING.txt + * @package API + * @since 0.10.0 + * + */ +require_once __DIR__ . '/lib/TwoFactorAuthException.php'; +require_once __DIR__ . '/lib/Providers/Rng/RNGException.php'; +require_once __DIR__ . '/lib/Providers/Rng/IRNGProvider.php'; +require_once __DIR__ . '/lib/Providers/Rng/CSRNGProvider.php'; +require_once __DIR__ . '/lib/Providers/Rng/HashRNGProvider.php'; +require_once __DIR__ . '/lib/Providers/Rng/MCryptRNGProvider.php'; +require_once __DIR__ . '/lib/Providers/Rng/OpenSSLRNGProvider.php'; +require_once __DIR__ . '/lib/Providers/Qr/QRException.php'; +require_once __DIR__ . '/lib/Providers/Qr/IQRCodeProvider.php'; +require_once __DIR__ . '/lib/Providers/Qr/BaseHTTPQRCodeProvider.php'; +require_once __DIR__ . '/lib/Providers/Qr/GoogleQRCodeProvider.php'; +require_once __DIR__ . '/lib/Providers/Time/TimeException.php'; +require_once __DIR__ . '/lib/Providers/Time/ITimeProvider.php'; +require_once __DIR__ . '/lib/Providers/Time/LocalMachineTimeProvider.php'; +require_once __DIR__ . '/lib/Providers/Time/HttpTimeProvider.php'; +require_once __DIR__ . '/lib/Providers/Time/NTPTimeProvider.php'; +require_once __DIR__ . '/lib/TwoFactorAuth.php'; + +class FroxlorTwoFactorAuth extends \RobThree\Auth\TwoFactorAuth +{ +} diff --git a/lib/classes/2FA/lib/Providers/Qr/BaseHTTPQRCodeProvider.php b/lib/classes/2FA/lib/Providers/Qr/BaseHTTPQRCodeProvider.php new file mode 100644 index 00000000..5cb3adda --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Qr/BaseHTTPQRCodeProvider.php @@ -0,0 +1,27 @@ + $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_DNS_CACHE_TIMEOUT => 10, + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => $this->verifyssl, + CURLOPT_USERAGENT => 'TwoFactorAuth' + )); + $data = curl_exec($curlhandle); + + curl_close($curlhandle); + return $data; + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Qr/GoogleQRCodeProvider.php b/lib/classes/2FA/lib/Providers/Qr/GoogleQRCodeProvider.php new file mode 100644 index 00000000..1b77ae99 --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Qr/GoogleQRCodeProvider.php @@ -0,0 +1,39 @@ +verifyssl = $verifyssl; + + $this->errorcorrectionlevel = $errorcorrectionlevel; + $this->margin = $margin; + } + + public function getMimeType() + { + return 'image/png'; + } + + public function getQRCodeImage($qrtext, $size) + { + return $this->getContent($this->getUrl($qrtext, $size)); + } + + public function getUrl($qrtext, $size) + { + return 'https://www.google.com/chart?cht=qr' + . '&chs=' . $size . 'x' . $size + . '&chld=' . $this->errorcorrectionlevel . '|' . $this->margin + . '&chl=' . rawurlencode($qrtext); + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Qr/IQRCodeProvider.php b/lib/classes/2FA/lib/Providers/Qr/IQRCodeProvider.php new file mode 100644 index 00000000..83ed67ba --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Qr/IQRCodeProvider.php @@ -0,0 +1,9 @@ +verifyssl = $verifyssl; + + $this->errorcorrectionlevel = $errorcorrectionlevel; + $this->margin = $margin; + $this->qzone = $qzone; + $this->bgcolor = $bgcolor; + $this->color = $color; + $this->format = $format; + } + + public function getMimeType() + { + switch (strtolower($this->format)) + { + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'svg': + return 'image/svg+xml'; + case 'eps': + return 'application/postscript'; + } + throw new \QRException(sprintf('Unknown MIME-type: %s', $this->format)); + } + + public function getQRCodeImage($qrtext, $size) + { + return $this->getContent($this->getUrl($qrtext, $size)); + } + + private function decodeColor($value) + { + return vsprintf('%d-%d-%d', sscanf($value, "%02x%02x%02x")); + } + + public function getUrl($qrtext, $size) + { + return 'https://api.qrserver.com/v1/create-qr-code/' + . '?size=' . $size . 'x' . $size + . '&ecc=' . strtoupper($this->errorcorrectionlevel) + . '&margin=' . $this->margin + . '&qzone=' . $this->qzone + . '&bgcolor=' . $this->decodeColor($this->bgcolor) + . '&color=' . $this->decodeColor($this->color) + . '&format=' . strtolower($this->format) + . '&data=' . rawurlencode($qrtext); + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Qr/QRicketProvider.php b/lib/classes/2FA/lib/Providers/Qr/QRicketProvider.php new file mode 100644 index 00000000..59e27ccd --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Qr/QRicketProvider.php @@ -0,0 +1,54 @@ +verifyssl = false; + + $this->errorcorrectionlevel = $errorcorrectionlevel; + $this->bgcolor = $bgcolor; + $this->color = $color; + $this->format = $format; + } + + public function getMimeType() + { + switch (strtolower($this->format)) + { + case 'p': + return 'image/png'; + case 'g': + return 'image/gif'; + case 'j': + return 'image/jpeg'; + } + throw new \QRException(sprintf('Unknown MIME-type: %s', $this->format)); + } + + public function getQRCodeImage($qrtext, $size) + { + return $this->getContent($this->getUrl($qrtext, $size)); + } + + public function getUrl($qrtext, $size) + { + return 'http://qrickit.com/api/qr' + . '?qrsize=' . $size + . '&e=' . strtolower($this->errorcorrectionlevel) + . '&bgdcolor=' . $this->bgcolor + . '&fgdcolor=' . $this->color + . '&t=' . strtolower($this->format) + . '&d=' . rawurlencode($qrtext); + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Rng/CSRNGProvider.php b/lib/classes/2FA/lib/Providers/Rng/CSRNGProvider.php new file mode 100644 index 00000000..8dba7fc9 --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Rng/CSRNGProvider.php @@ -0,0 +1,14 @@ +algorithm = $algorithm; + } + + public function getRandomBytes($bytecount) { + $result = ''; + $hash = mt_rand(); + for ($i = 0; $i < $bytecount; $i++) { + $hash = hash($this->algorithm, $hash.mt_rand(), true); + $result .= $hash[mt_rand(0, strlen($hash)-1)]; + } + return $result; + } + + public function isCryptographicallySecure() { + return false; + } +} diff --git a/lib/classes/2FA/lib/Providers/Rng/IRNGProvider.php b/lib/classes/2FA/lib/Providers/Rng/IRNGProvider.php new file mode 100644 index 00000000..6be28006 --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Rng/IRNGProvider.php @@ -0,0 +1,9 @@ +source = $source; + } + + public function getRandomBytes($bytecount) { + $result = @mcrypt_create_iv($bytecount, $this->source); + if ($result === false) + throw new \RNGException('mcrypt_create_iv returned an invalid value'); + return $result; + } + + public function isCryptographicallySecure() { + return true; + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Rng/OpenSSLRNGProvider.php b/lib/classes/2FA/lib/Providers/Rng/OpenSSLRNGProvider.php new file mode 100644 index 00000000..dc66c64a --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Rng/OpenSSLRNGProvider.php @@ -0,0 +1,25 @@ +requirestrong = $requirestrong; + } + + public function getRandomBytes($bytecount) { + $result = openssl_random_pseudo_bytes($bytecount, $crypto_strong); + if ($this->requirestrong && ($crypto_strong === false)) + throw new \RNGException('openssl_random_pseudo_bytes returned non-cryptographically strong value'); + if ($result === false) + throw new \RNGException('openssl_random_pseudo_bytes returned an invalid value'); + return $result; + } + + public function isCryptographicallySecure() { + return $this->requirestrong; + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Rng/RNGException.php b/lib/classes/2FA/lib/Providers/Rng/RNGException.php new file mode 100644 index 00000000..eb5e913d --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Rng/RNGException.php @@ -0,0 +1,5 @@ +url = $url; + $this->expectedtimeformat = $expectedtimeformat; + $this->options = $options; + if ($this->options === null) { + $this->options = array( + 'http' => array( + 'method' => 'HEAD', + 'follow_location' => false, + 'ignore_errors' => true, + 'max_redirects' => 0, + 'request_fulluri' => true, + 'header' => array( + 'Connection: close', + 'User-agent: TwoFactorAuth HttpTimeProvider (https://github.com/RobThree/TwoFactorAuth)', + 'Cache-Control: no-cache' + ) + ) + ); + } + } + + public function getTime() { + try { + $context = stream_context_create($this->options); + $fd = fopen($this->url, 'rb', false, $context); + $headers = stream_get_meta_data($fd); + fclose($fd); + + foreach ($headers['wrapper_data'] as $h) { + if (strcasecmp(substr($h, 0, 5), 'Date:') === 0) + return \DateTime::createFromFormat($this->expectedtimeformat, trim(substr($h,5)))->getTimestamp(); + } + throw new \TimeException(sprintf('Unable to retrieve time from %s (Invalid or no "Date:" header found)', $this->url)); + } + catch (Exception $ex) { + throw new \TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->url, $ex->getMessage())); + } + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Time/ITimeProvider.php b/lib/classes/2FA/lib/Providers/Time/ITimeProvider.php new file mode 100644 index 00000000..a3b87a20 --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Time/ITimeProvider.php @@ -0,0 +1,8 @@ +host = $host; + + if (!is_int($port) || $port <= 0 || $port > 65535) + throw new \TimeException('Port must be 0 < port < 65535'); + $this->port = $port; + + if (!is_int($timeout) || $timeout < 0) + throw new \TimeException('Timeout must be >= 0'); + $this->timeout = $timeout; + } + + public function getTime() { + try { + /* Create a socket and connect to NTP server */ + $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_connect($sock, $this->host, $this->port); + + /* Send request */ + $msg = "\010" . str_repeat("\0", 47); + socket_send($sock, $msg, strlen($msg), 0); + + /* Receive response and close socket */ + socket_recv($sock, $recv, 48, MSG_WAITALL); + socket_close($sock); + + /* Interpret response */ + $data = unpack('N12', $recv); + $timestamp = sprintf('%u', $data[9]); + + /* NTP is number of seconds since 0000 UT on 1 January 1900 Unix time is seconds since 0000 UT on 1 January 1970 */ + return $timestamp - 2208988800; + } + catch (Exception $ex) { + throw new \TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->host, $ex->getMessage())); + } + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/Providers/Time/TimeException.php b/lib/classes/2FA/lib/Providers/Time/TimeException.php new file mode 100644 index 00000000..8de544d0 --- /dev/null +++ b/lib/classes/2FA/lib/Providers/Time/TimeException.php @@ -0,0 +1,5 @@ +issuer = $issuer; + if (!is_int($digits) || $digits <= 0) + throw new TwoFactorAuthException('Digits must be int > 0'); + $this->digits = $digits; + + if (!is_int($period) || $period <= 0) + throw new TwoFactorAuthException('Period must be int > 0'); + $this->period = $period; + + $algorithm = strtolower(trim($algorithm)); + if (!in_array($algorithm, self::$_supportedalgos)) + throw new TwoFactorAuthException('Unsupported algorithm: ' . $algorithm); + $this->algorithm = $algorithm; + $this->qrcodeprovider = $qrcodeprovider; + $this->rngprovider = $rngprovider; + $this->timeprovider = $timeprovider; + + self::$_base32 = str_split(self::$_base32dict); + self::$_base32lookup = array_flip(self::$_base32); + } + + /** + * Create a new secret + */ + public function createSecret($bits = 80, $requirecryptosecure = true) + { + $secret = ''; + $bytes = ceil($bits / 5); //We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32) + $rngprovider = $this->getRngprovider(); + if ($requirecryptosecure && !$rngprovider->isCryptographicallySecure()) + throw new TwoFactorAuthException('RNG provider is not cryptographically secure'); + $rnd = $rngprovider->getRandomBytes($bytes); + for ($i = 0; $i < $bytes; $i++) + $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values + return $secret; + } + + /** + * Calculate the code with given secret and point in time + */ + public function getCode($secret, $time = null) + { + $secretkey = $this->base32Decode($secret); + + $timestamp = chr(0).chr(0).chr(0).chr(0) . pack('N*', $this->getTimeSlice($this->getTime($time))); // Pack time into binary string + $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key + $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result + $value = unpack('N', $hashpart); // Unpack binary value + $value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits + + return str_pad($value % pow(10, $this->digits), $this->digits, '0', STR_PAD_LEFT); + } + + /** + * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now + */ + public function verifyCode($secret, $code, $discrepancy = 1, $time = null, &$timeslice = 0) + { + $timetamp = $this->getTime($time); + + $timeslice = 0; + + // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have + // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice + // of the match. Each iteration we either set the timeslice variable to the timeslice of the match + // or set the value to itself. This is an effort to maintain constant execution time for the code. + for ($i = -$discrepancy; $i <= $discrepancy; $i++) { + $ts = $timetamp + ($i * $this->period); + $slice = $this->getTimeSlice($ts); + $timeslice = $this->codeEquals($this->getCode($secret, $ts), $code) ? $slice : $timeslice; + } + + return $timeslice > 0; + } + + /** + * Timing-attack safe comparison of 2 codes (see http://blog.ircmaxell.com/2014/11/its-all-about-time.html) + */ + private function codeEquals($safe, $user) { + if (function_exists('hash_equals')) { + return hash_equals($safe, $user); + } + // In general, it's not possible to prevent length leaks. So it's OK to leak the length. The important part is that + // we don't leak information about the difference of the two strings. + if (strlen($safe)===strlen($user)) { + $result = 0; + for ($i = 0; $i < strlen($safe); $i++) + $result |= (ord($safe[$i]) ^ ord($user[$i])); + return $result === 0; + } + return false; + } + + /** + * Get data-uri of QRCode + */ + public function getQRCodeImageAsDataUri($label, $secret, $size = 200) + { + if (!is_int($size) || $size <= 0) + throw new TwoFactorAuthException('Size must be int > 0'); + + $qrcodeprovider = $this->getQrCodeProvider(); + return 'data:' + . $qrcodeprovider->getMimeType() + . ';base64,' + . base64_encode($qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size)); + } + + /** + * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency) + */ + public function ensureCorrectTime(array $timeproviders = null, $leniency = 5) + { + if ($timeproviders != null && !is_array($timeproviders)) + throw new TwoFactorAuthException('No timeproviders specified'); + + if ($timeproviders == null) + $timeproviders = array( + new Providers\Time\NTPTimeProvider(), + new Providers\Time\HttpTimeProvider() + ); + + // Get default time provider + $timeprovider = $this->getTimeProvider(); + + // Iterate specified time providers + foreach ($timeproviders as $t) { + if (!($t instanceof ITimeProvider)) + throw new TwoFactorAuthException('Object does not implement ITimeProvider'); + + // Get time from default time provider and compare to specific time provider and throw if time difference is more than specified number of seconds leniency + if (abs($timeprovider->getTime() - $t->getTime()) > $leniency) + throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t))); + } + } + + private function getTime($time) + { + return ($time === null) ? $this->getTimeProvider()->getTime() : $time; + } + + private function getTimeSlice($time = null, $offset = 0) + { + return (int)floor($time / $this->period) + ($offset * $this->period); + } + + /** + * Builds a string to be encoded in a QR code + */ + public function getQRText($label, $secret) + { + return 'otpauth://totp/' . rawurlencode($label) + . '?secret=' . rawurlencode($secret) + . '&issuer=' . rawurlencode($this->issuer) + . '&period=' . intval($this->period) + . '&algorithm=' . rawurlencode(strtoupper($this->algorithm)) + . '&digits=' . intval($this->digits); + } + + private function base32Decode($value) + { + if (strlen($value)==0) return ''; + + if (preg_match('/[^'.preg_quote(self::$_base32dict).']/', $value) !== 0) + throw new TwoFactorAuthException('Invalid base32 string'); + + $buffer = ''; + foreach (str_split($value) as $char) + { + if ($char !== '=') + $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, 0, STR_PAD_LEFT); + } + $length = strlen($buffer); + $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' ')); + + $output = ''; + foreach (explode(' ', $blocks) as $block) + $output .= chr(bindec(str_pad($block, 8, 0, STR_PAD_RIGHT))); + return $output; + } + + /** + * @return IQRCodeProvider + * @throws TwoFactorAuthException + */ + public function getQrCodeProvider() + { + // Set default QR Code provider if none was specified + if (null === $this->qrcodeprovider) { + return $this->qrcodeprovider = new Providers\Qr\GoogleQRCodeProvider(); + } + return $this->qrcodeprovider; + } + + /** + * @return IRNGProvider + * @throws TwoFactorAuthException + */ + public function getRngprovider() + { + if (null !== $this->rngprovider) { + return $this->rngprovider; + } + if (function_exists('random_bytes')) { + return $this->rngprovider = new Providers\Rng\CSRNGProvider(); + } + if (function_exists('mcrypt_create_iv')) { + return $this->rngprovider = new Providers\Rng\MCryptRNGProvider(); + } + if (function_exists('openssl_random_pseudo_bytes')) { + return $this->rngprovider = new Providers\Rng\OpenSSLRNGProvider(); + } + if (function_exists('hash')) { + return $this->rngprovider = new Providers\Rng\HashRNGProvider(); + } + throw new TwoFactorAuthException('Unable to find a suited RNGProvider'); + } + + /** + * @return ITimeProvider + * @throws TwoFactorAuthException + */ + public function getTimeProvider() + { + // Set default time provider if none was specified + if (null === $this->timeprovider) { + return $this->timeprovider = new Providers\Time\LocalMachineTimeProvider(); + } + return $this->timeprovider; + } +} \ No newline at end of file diff --git a/lib/classes/2FA/lib/TwoFactorAuthException.php b/lib/classes/2FA/lib/TwoFactorAuthException.php new file mode 100644 index 00000000..af51b748 --- /dev/null +++ b/lib/classes/2FA/lib/TwoFactorAuthException.php @@ -0,0 +1,7 @@ +https://api.froxlor.org/'; $lng['serversettings']['dhparams_file']['title'] = 'DHParams file (Diffie–Hellman key exchange)'; $lng['serversettings']['dhparams_file']['description'] = 'If a dhparams.pem file is specified here it will be included in the webserver configuration. Leave empty to disable.
Example: /etc/apache2/ssl/dhparams.pem

If the file does not exist, it will be created automatically with the following command: openssl dhparam -out /etc/apache2/ssl/dhparams.pem 4096. It is recommended to create the file prior to specifying it here as the creation takes quite a while and blocks the cronjob.'; +$lng['2fa']['2fa'] = '2FA options'; +$lng['2fa']['2fa_enabled'] = 'Activate Two-factor authentication (2FA)'; +$lng['login']['2fa'] = 'Two-factor authentication (2FA)'; +$lng['login']['2facode'] = 'Please enter 2FA code'; +$lng['2fa']['2fa_removed'] = '2FA removed successfully'; +$lng['2fa']['2fa_added'] = '2FA activated successfully
View 2FA details'; +$lng['2fa']['2fa_add'] = 'Activate 2FA'; +$lng['2fa']['2fa_delete'] = 'Deactivate 2FA'; +$lng['2fa']['2fa_verify'] = 'Verify code'; +$lng['mails']['2fa']['mailbody'] = 'Hello,\n\nyour 2FA login-code is: {CODE}.\n\nThis is an automatically created\ne-mail, please do not answer!\n\nYours sincerely, your administrator'; +$lng['mails']['2fa']['subject'] = 'Froxlor - 2FA Code'; +$lng['2fa']['2fa_overview_desc'] = 'Here you can activate a two-factor authentication for your account.

You can either use an authenticator-app (time-based one-time password / TOTP) or let froxlor send you an email to your account-address after each successful login with a one-time password.'; +$lng['2fa']['2fa_email_desc'] = 'Your account is set up to use one-time passwords via e-mail. To deactivate, click on "'.$lng['2fa']['2fa_delete'].'"'; +$lng['2fa']['2fa_ga_desc'] = 'Your account is set up to use time-based one-time passwords via authenticator-app. Please scan the QR code below with your desired authenticator app to generate the codes. To deactivate, click on "'.$lng['2fa']['2fa_delete'].'"'; diff --git a/lng/german.lng.php b/lng/german.lng.php index 455588fd..b8cb3cda 100644 --- a/lng/german.lng.php +++ b/lng/german.lng.php @@ -1799,3 +1799,17 @@ $lng['serversettings']['enable_api']['title'] = 'Aktiviere externe API Nutzung'; $lng['serversettings']['enable_api']['description'] = 'Um die froxlor API nutzen zu können, muss diese Option aktiviert sein. Für detaillierte Informationen siehe https://api.froxlor.org/'; $lng['serversettings']['dhparams_file']['title'] = 'DHParams Datei (Diffie–Hellman key exchange)'; $lng['serversettings']['dhparams_file']['description'] = 'Wird eine dhparams.pem Datei hier angegeben, wir sie in die Webserver Konfiguration mit eingefügt.
Beispiel: /etc/apache2/ssl/dhparams.pem

Existiert die Datei nicht, wird sie wie folgt erstellt: openssl dhparam -out /etc/apache2/ssl/dhparams.pem 4096. Es wird empfohlen die Datei zu erstellen, bevor sie hier angegeben wird, da die Erstellung längere Zeit in Anspruch nimmt und den Cronjob blockiert.'; +$lng['2fa']['2fa'] = '2FA Optionen'; +$lng['2fa']['2fa_enabled'] = 'Aktiviere Zwei-Faktor Authentifizierung (2FA)'; +$lng['login']['2fa'] = 'Zwei-Faktor Authentifizierung (2FA)'; +$lng['login']['2facode'] = 'Bitte 2FA Code angeben'; +$lng['2fa']['2fa_removed'] = '2FA erfolgreich gelöscht'; +$lng['2fa']['2fa_added'] = '2FA erfolgreich aktiviert
2FA Details öffnen'; +$lng['2fa']['2fa_add'] = '2FA aktivieren'; +$lng['2fa']['2fa_delete'] = '2FA deaktivieren'; +$lng['2fa']['2fa_verify'] = 'Code verifizieren'; +$lng['mails']['2fa']['mailbody'] = 'Hallo,\n\nihr 2FA-Login Code lautet: {CODE}\n\nDies ist eine automatisch generierte\neMail, bitte antworten Sie nicht auf\ndiese Mitteilung.\n\nIhr Administrator'; +$lng['mails']['2fa']['subject'] = 'Froxlor - 2FA Code'; +$lng['2fa']['2fa_overview_desc'] = 'Hier kann für das Konto eine Zwei-Faktor-Authentisierung aktiviert werden.

Es kann entweder eine Authenticator-App (time-based one-time password / TOTP) genutzt werden oder ein Einmalpasswort, welches nach erfolgreichem Login an die hinterlegte E-Mail Adresse gesendet wird.'; +$lng['2fa']['2fa_email_desc'] = 'Das Konto ist eingerichtet, um Einmalpasswörter per E-Mail zu erhalten. Zum Deaktivieren, klicke auf "'.$lng['2fa']['2fa_delete'].'"'; +$lng['2fa']['2fa_ga_desc'] = 'Das Konto ist eingerichtet, um zeitbasierte Einmalpasswörter via Authenticator-App zu erhalten. Um die gewünschte Authenticator-App einzurichten, scanne bitte den untenstehenden QR-Code. Zum Deaktivieren, klicke auf "'.$lng['2fa']['2fa_delete'].'"'; diff --git a/templates/Sparkle/2fa/entercode.tpl b/templates/Sparkle/2fa/entercode.tpl new file mode 100644 index 00000000..674049a9 --- /dev/null +++ b/templates/Sparkle/2fa/entercode.tpl @@ -0,0 +1,26 @@ +$header + +$footer diff --git a/templates/Sparkle/2fa/overview.tpl b/templates/Sparkle/2fa/overview.tpl new file mode 100644 index 00000000..165e9874 --- /dev/null +++ b/templates/Sparkle/2fa/overview.tpl @@ -0,0 +1,40 @@ +$header +
+
+

+   {$lng['login']['2fa']} +

+
+ +
+ +
+

{$lng['2fa']['2fa_overview_desc']}


+   +
+
+ + +
+

{$lng['2fa']['2fa_email_desc']}


+ +
+
+ + +
+

{$lng['2fa']['2fa_ga_desc']}


+ QRCode

+ +
+
+ +
+ +
+$footer diff --git a/templates/Sparkle/header.tpl b/templates/Sparkle/header.tpl index a5a9be66..db30b098 100644 --- a/templates/Sparkle/header.tpl +++ b/templates/Sparkle/header.tpl @@ -61,6 +61,9 @@