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'); ?>' : '' ) + + '' + '' + '' + '' + @@ -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 ##