diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 79bc97b24..ab149b60a 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) { } } function verify_tfa_login($username, $_data) { + global $redis; + global $TFA_MAX_FAILS; + global $TFA_FAIL_WINDOW; + + if (empty($username)) { + return false; + } + // Count failed second factors per account, so codes cannot be guessed from many addresses + $fails_key = 'TFA_FAILS_' . $username; + if (intval($redis->get($fails_key)) >= $TFA_MAX_FAILS) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $username, '*'), + 'msg' => 'tfa_login_locked' + ); + $verified = false; + } + else { + $verified = verify_tfa_token($username, $_data); + } + if ($verified !== true) { + // The window starts with the first failure and is not extended by further attempts + if ($redis->incr($fails_key) == 1) { + $redis->expire($fails_key, $TFA_FAIL_WINDOW); + } + // Same message format as failed passwords, so the default fail2ban rule counts it + error_log("mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']); + $redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']); + return false; + } + $redis->del($fails_key); + return true; +} +function verify_tfa_token($username, $_data) { global $pdo; global $yubi; global $u2f; diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 451704565..32f6f6645 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID"; // Label for OTP devices $OTP_LABEL = "mailcow UI"; +// Failed second factor logins (TOTP, Yubico OTP, WebAuthn) of an account before its second factor is locked +$TFA_MAX_FAILS = 10; + +// Time (in s) from the first failed second factor login until the counter and the lock are reset +$TFA_FAIL_WINDOW = 900; + // How long to wait (in s) for cURL Docker requests $DOCKER_TIMEOUT = 60; diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index ca9a093b8..23dfd5c05 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -641,6 +641,7 @@ "temp_error": "Temporärer Fehler", "text_empty": "Text darf nicht leer sein", "tfa_token_invalid": "TFA-Token ungültig", + "tfa_login_locked": "Zu viele fehlgeschlagene Zwei-Faktor-Anmeldungen, bitte später erneut versuchen", "tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.", "tls_policy_map_dest_invalid": "Ziel ist ungültig", "tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 7ba74740f..dc13af0a6 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -517,6 +517,7 @@ "group_send_as_invalid": "Send-as address %s is invalid or unknown", "iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", + "tfa_login_locked": "Too many failed two-factor logins, please try again later", "tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.", "alias_domain_invalid": "Alias domain %s is invalid", "alias_empty": "Alias address must not be empty",