diff --git a/includes/config/include.php b/includes/config/include.php
index a6c039116..c8ddd5778 100755
--- a/includes/config/include.php
+++ b/includes/config/include.php
@@ -27,7 +27,7 @@
*/
define('TP_VERSION', '3.1.2');
-define("UPGRADE_MIN_DATE", "1732264740");
+define("UPGRADE_MIN_DATE", "1732630844");
define('TP_VERSION_MINOR', '170');
define('TP_TOOL_NAME', 'Teampass');
define('TP_ONE_DAY_SECONDS', 86400);
diff --git a/includes/language/english.php b/includes/language/english.php
index 999b3ceca..e306945a2 100755
--- a/includes/language/english.php
+++ b/includes/language/english.php
@@ -1183,6 +1183,10 @@
'syslog_port' => 'Syslog port (default 514)',
'error_bad_credentials' => 'Login credentials do not correspond!',
'bruteforce_wait' => 'Too many failed attempts, your account is blocked until: ',
+ 'bruteforce_unlock_at' => 'Account unlocked at (anti bruteforce): ',
+ 'bruteforce_reset_account' => 'Reset anti bruteforce of user',
+ 'bruteforce_reset_mail_subject' => 'TEAMPASS - Your account is disabled',
+ 'bruteforce_reset_mail_body' => 'Hello #name#,
Your teampass account has been locked due to a large number of authentication failures.
You can unblock it by clicking on this link #reset_url#
Automatic unlock: #unlock_at#',
'settings_ldap_usergroup' => 'LDAP group to search',
'settings_ldap_usergroup_tip' => 'Enter the LDAP group in the directory where allowed user logins are stored. Example: cn=sysadmins,ou=groups,dc=example,dc=com',
'server_password_change_enable' => 'Enable changing password on distant server (using ssh connection)',
diff --git a/includes/language/french.php b/includes/language/french.php
index 4cc234e36..683274d53 100755
--- a/includes/language/french.php
+++ b/includes/language/french.php
@@ -881,6 +881,10 @@
'syslog_port' => 'Port Syslog',
'error_bad_credentials' => 'Informations de connexion erronées',
'bruteforce_wait' => 'Trop de tentatives échouées, votre compte est bloqué jusqu'à : ',
+ 'bruteforce_unlock_at' => 'Déblocage du compte (anti bruteforce) : ',
+ 'bruteforce_reset_account' => 'Réinitialiser l'anti bruteforce de l'utilisateur',
+ 'bruteforce_reset_mail_subject' => 'TEAMPASS - Votre compte est désactivé',
+ 'bruteforce_reset_mail_body' => 'Bonjour #name#,
Votre compte teampass a été verouillé en raison d'un grand nombre d'échecs d'authentification.
Vous pouvez le débloquer en cliquant sur ce lien #reset_url#
Déblocage automatique : #unlock_at#',
'settings_ldap_usergroup' => 'Groupe LDAP dans lequel faire la recherche',
'settings_ldap_usergroup_tip' => 'Groupe LDAP dans lequel les utilisateurs doivent être membre pour pouvoir se connecter. Exemple : cn=sysadmins,ou=groups,dc=example,dc=com',
'server_password_change_enable' => 'Activer le changement automatique du mot de passe du compte du serveur (en utilisant une connexion SSH)',
diff --git a/install/install.queries.php b/install/install.queries.php
index 5c9e2d843..1f0153890 100755
--- a/install/install.queries.php
+++ b/install/install.queries.php
@@ -1376,6 +1376,7 @@ function encryptFollowingDefuse($message, $ascii_key)
`value` VARCHAR(500) NOT NULL,
`date` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`unlock_at` TIMESTAMP NULL DEFAULT NULL,
+ `unlock_code` VARCHAR(50) NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) CHARSET=utf8;"
);
diff --git a/install/upgrade_run_3.1.php b/install/upgrade_run_3.1.php
index 12cc11126..dba4be0af 100755
--- a/install/upgrade_run_3.1.php
+++ b/install/upgrade_run_3.1.php
@@ -626,10 +626,24 @@
`value` VARCHAR(500) NOT NULL,
`date` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`unlock_at` TIMESTAMP NULL DEFAULT NULL,
+ `unlock_code` VARCHAR(50) NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) CHARSET=utf8;"
);
+// Add unlock_code column
+try {
+ $alter_table_query = "
+ ALTER TABLE `" . $pre . "auth_failures`
+ ADD COLUMN `unlock_code` VARCHAR(50) NULL DEFAULT NULL;";
+ mysqli_begin_transaction($db_link);
+ mysqli_query($db_link, $alter_table_query);
+ mysqli_commit($db_link);
+} catch (Exception $e) {
+ // Rollback transaction if index already exists.
+ mysqli_rollback($db_link);
+}
+
//---get('generate_new_otp'); ?>' :
''
) +
+ '
get('bruteforce_reset_account'); ?>
' +
'
get('see_logs'); ?>
' +
'
get('user_ga_code'); ?>
' +
'
get('user_folders_rights'); ?>
' +
@@ -1182,7 +1183,35 @@ function(data) {
}
);
- // ---
+ } else if ($(this).data('action') === 'reset-antibruteforce') {
+ toastr.remove();
+ toastr.info('get('in_progress'); ?> ... ');
+
+ const data = {
+ 'user_id': $(this).data('id'),
+ };
+
+ $.post(
+ "sources/users.queries.php", {
+ type: "reset_antibruteforce",
+ data: prepareExchangedData(JSON.stringify(data), 'encode', 'get('key'); ?>'),
+ key: "get('key'); ?>"
+ },
+ function(data) {
+ // Inform user
+ toastr.remove();
+ toastr.success(
+ 'get('done'); ?>',
+ '', {
+ timeOut: 1000
+ }
+ );
+
+ // refresh table content
+ oTable.ajax.reload();
+ }
+ );
+
} else if ($(this).data('action') === 'new-enc-code') {
// HIde
$('.content-header, .content').addClass('hidden');
@@ -1268,7 +1297,7 @@ function(data) {
$('input[type="checkbox"].flat-blue').iCheck({
checkboxClass: 'icheckbox_flat-blue',
});
- $(document).on('click', '#warningModalButtonAction', function() {
+ $(document).one('click', '#warningModalButtonAction', function() {
// Show spinner
toastr.remove();
@@ -1344,7 +1373,7 @@ function(data) {
$('input[type="checkbox"].flat-blue').iCheck({
checkboxClass: 'icheckbox_flat-blue',
});
- $(document).on('click', '#warningModalButtonAction', function() {
+ $(document).one('click', '#warningModalButtonAction', function() {
if ($('#user-to-delete').prop('checked') === false) {
$('#warningModal').modal('hide');
return false;
@@ -2443,7 +2472,7 @@ function(data) {
'get('perform'); ?>',
'get('cancel'); ?>'
);
- $(document).on('click', '#warningModalButtonAction', function(event) {
+ $(document).one('click', '#warningModalButtonAction', function(event) {
event.preventDefault();
event.stopPropagation();
if ($('#ldap-user-name').val() !== "" && $('#ldap-user-roles :selected').length > 0) {
diff --git a/self-unlock.php b/self-unlock.php
new file mode 100644
index 000000000..0178aab8e
--- /dev/null
+++ b/self-unlock.php
@@ -0,0 +1,81 @@
+.
+ *
+ * Certain components of this file may be under different licenses. For
+ * details, see the `licenses` directory or individual file headers.
+ * ---
+ * @file 2fa.js.php
+ * @author Nils Laumaillé (nils@teampass.net)
+ * @copyright 2009-2024 Teampass.net
+ * @license GPL-3.0
+ * @see https://www.teampass.net
+ */
+
+
+use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
+
+// Load functions
+require_once __DIR__. '/includes/config/include.php';
+require_once __DIR__.'/sources/main.functions.php';
+
+// init
+loadClasses();
+
+// Get username and OTP from GET parameters
+$request = SymfonyRequest::createFromGlobals();
+$username = $request->query->get('login', '');
+$otp = $request->query->get('otp', '');
+
+// Redirect user to teampass if username or otp is not provided
+if (empty($username) || empty($otp)) {
+ header('Location: ./index.php');
+ exit;
+}
+
+// Check for existing lock
+$result = DB::queryFirstField(
+ 'SELECT 1
+ FROM ' . prefixTable('auth_failures') . '
+ WHERE unlock_at = (
+ SELECT MAX(unlock_at)
+ FROM ' . prefixTable('auth_failures') . '
+ WHERE unlock_at > %s
+ AND source = %s AND value = %s)
+ AND unlock_code = %s',
+ date('Y-m-d H:i:s', time()),
+ 'login',
+ $username,
+ $otp
+);
+
+// Delete all logs for this user if provided OTP is correct
+if ($result) {
+ DB::delete(
+ prefixTable('auth_failures'),
+ 'source = %s AND value = %s',
+ 'login',
+ $username
+ );
+}
+
+// Redirect user to teampass
+header('Location: ./index.php');
+exit;
diff --git a/sources/identify.php b/sources/identify.php
index e8a625db3..aebf36ca7 100755
--- a/sources/identify.php
+++ b/sources/identify.php
@@ -2617,7 +2617,7 @@ function identifyDoAzureChecks(
* @param string $source - The source of the failed attempt (login or remote_ip).
* @param string $value - The value for this source (username or IP address).
* @param int $limit - The failure attempt limit after which the account/IP
- * will be locked.
+ * will be locked.
*/
function handleFailedAttempts($source, $value, $limit) {
// Count failed attempts from this source
@@ -2633,10 +2633,15 @@ function handleFailedAttempts($source, $value, $limit) {
$count++;
// Calculate unlock time if number of attempts exceeds limit
- $unlock_at = $count >= $limit
+ $unlock_at = $count >= $limit
? date('Y-m-d H:i:s', time() + (($count - $limit + 1) * 600))
: NULL;
+ // Unlock account one time code
+ $unlock_code = ($count >= $limit && $source === 'login')
+ ? generateQuickPassword(30, false)
+ : NULL;
+
// Insert the new failure into the database
DB::insert(
prefixTable('auth_failures'),
@@ -2644,8 +2649,41 @@ function handleFailedAttempts($source, $value, $limit) {
'source' => $source,
'value' => $value,
'unlock_at' => $unlock_at,
+ 'unlock_code' => $unlock_code,
]
);
+
+ if ($unlock_at !== null && $source === 'login') {
+ $configManager = new ConfigManager();
+ $SETTINGS = $configManager->getAllSettings();
+ $lang = new Language($SETTINGS['default_language']);
+
+ // Get user email
+ $userInfos = DB::QueryFirstRow(
+ 'SELECT email, name
+ FROM '.prefixTable('users').'
+ WHERE login = %s',
+ $value
+ );
+
+ // No valid email address for user
+ if (!$userInfos || !filter_var($userInfos['email'], FILTER_VALIDATE_EMAIL))
+ return;
+
+ $unlock_url = $SETTINGS['cpassman_url'].'/self-unlock.php?login='.$value.'&otp='.$unlock_code;
+
+ sendMailToUser(
+ $userInfos['email'],
+ $lang->get('bruteforce_reset_mail_body'),
+ $lang->get('bruteforce_reset_mail_subject'),
+ [
+ '#name#' => $userInfos['name'],
+ '#reset_url#' => $unlock_url,
+ '#unlock_at#' => $unlock_at,
+ ],
+ true
+ );
+ }
}
/**
@@ -2659,7 +2697,7 @@ function handleFailedAttempts($source, $value, $limit) {
*/
function addFailedAuthentication($username, $ip) {
$user_limit = 10;
- $ip_limit = 20;
+ $ip_limit = 30;
// Remove old logs (more than 24 hours)
DB::delete(
diff --git a/sources/main.functions.php b/sources/main.functions.php
index 2e137afbb..830c14e5c 100755
--- a/sources/main.functions.php
+++ b/sources/main.functions.php
@@ -4313,11 +4313,12 @@ function sendMailToUser(
global $SETTINGS;
$emailSettings = new EmailSettings($SETTINGS);
$emailService = new EmailService();
+ $antiXss = new AntiXSS();
// Sanitize inputs
$post_receipt = filter_var($post_receipt, FILTER_SANITIZE_EMAIL);
- $post_subject = htmlspecialchars($post_subject, ENT_QUOTES, 'UTF-8');
- $post_body = htmlspecialchars($post_body, ENT_QUOTES, 'UTF-8');
+ $post_subject = $antiXss->xss_clean($post_subject);
+ $post_body = $antiXss->xss_clean($post_body);
if (count($post_replace) > 0) {
$post_body = str_replace(
diff --git a/sources/users.datatable.php b/sources/users.datatable.php
index 9b85b3ce8..a9266395a 100755
--- a/sources/users.datatable.php
+++ b/sources/users.datatable.php
@@ -221,13 +221,6 @@
// Display Grid
if ($showUserFolders === true) {
- /*
- // Build list of available users
- if ((int) $record['admin'] !== 1 && (int) $record['disabled'] !== 1) {
- $listAvailableUsers .= '';
- }
- */
-
// Get list of allowed functions
$listAlloFcts = '';
if ((int) $record['admin'] !== 1) {
@@ -251,6 +244,16 @@
$record['id']
);
+ // Check for existing lock
+ $unlock_at = DB::queryFirstField(
+ 'SELECT MAX(unlock_at)
+ FROM ' . prefixTable('auth_failures') . '
+ WHERE unlock_at > %s AND source = %s AND value = %s',
+ date('Y-m-d H:i:s', time()),
+ 'login',
+ $record['login']
+ );
+
// Get some infos about user
$userDisplayInfos =
(isset($userDate['date']) ? 'get('creation_date').': '.date($SETTINGS['date_format'] . ' ' . $SETTINGS['time_format'], (int) $userDate['date']).'\">' : '')
@@ -265,7 +268,9 @@
((in_array($record['id'], [OTV_USER_ID, TP_USER_ID, SSH_USER_ID, API_USER_ID]) === false && (int) $record['admin'] !== 1 && ((int) $SETTINGS['duo'] === 1 || (int) $SETTINGS['google_authentication'] === 1)) ?
((int) $record['mfa_enabled'] === 1 ? '' : 'get('mfa_disabled_for_user').'\">') :
''
- );
+ )
+ .
+ (($unlock_at) ? 'get('bruteforce_unlock_at').$unlock_at.'\">' : '');
if ($request->query->filter('display_warnings', '', FILTER_VALIDATE_BOOLEAN) === true) {
$userDisplayInfos .= ' '.
((in_array($record['id'], [OTV_USER_ID, TP_USER_ID, SSH_USER_ID, API_USER_ID]) === false && (int) $record['admin'] !== 1 && is_null($record['keys_recovery_time']) === true) ?
diff --git a/sources/users.queries.php b/sources/users.queries.php
index 93292c515..69d1a563a 100755
--- a/sources/users.queries.php
+++ b/sources/users.queries.php
@@ -2712,6 +2712,32 @@
'encode'
);
+ break;
+
+ case "reset_antibruteforce":
+ // Check KEY
+ if ($post_key !== $session->get('key')) {
+ echo prepareExchangedData(
+ array(
+ 'error' => true,
+ 'message' => $lang->get('key_is_not_correct'),
+ ),
+ 'encode'
+ );
+ break;
+ }
+
+ // Prepare variables
+ $login = getFullUserInfos((int) $dataReceived['user_id'])['login'];
+
+ // Delete all logs for this user
+ DB::delete(
+ prefixTable('auth_failures'),
+ 'source = %s AND value = %s',
+ 'login',
+ $login
+ );
+
break;
}
// # NEW LOGIN FOR USER HAS BEEN DEFINED ##