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
+
+
+