[Web][Dovecot] Do not count denied logins as failed logins

This commit is contained in:
FreddleSpl0it committed 2026-11-04 10:39:32 +01:00
1 parent a8d1121f0e
commit 9293e4ce43
2 files changed
+49 -34

No files matched your search

+8 -2
View File
@@ -71,6 +71,8 @@ $isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
// real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client (for logs) // real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client (for logs)
$client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip']; $client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip'];
$result = false; $result = false;
// set when the password is right, but the account does not allow this login
$denied = false;
if ($isSOGoRequest) { if ($isSOGoRequest) {
// This is a SOGo Auth request. First check for SSO password. // This is a SOGo Auth request. First check for SSO password.
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass"); $sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
@@ -93,7 +95,7 @@ if ($result === false){
'service' => $post['service'], 'service' => $post['service'],
'is_internal' => true, 'is_internal' => true,
'remote_addr' => $post['real_rip'] 'remote_addr' => $post['real_rip']
)); ), $denied);
if ($result) { if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip); error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
set_sasl_log($post['username'], $client_ip, $service); set_sasl_log($post['username'], $client_ip, $service);
@@ -104,7 +106,7 @@ if ($result === false){
$iam_provider = identity_provider('init'); $iam_provider = identity_provider('init');
$iam_settings = identity_provider('get'); $iam_settings = identity_provider('get');
try { try {
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service'])); $result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']), $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) { } catch (\Mailcow\Auth\IdpUnavailable $e) {
// temporary failure for Dovecot, no failed login // temporary failure for Dovecot, no failed login
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip . ": " . $e->getMessage()); error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip . ": " . $e->getMessage());
@@ -122,6 +124,10 @@ if ($result === false){
if ($result) { if ($result) {
http_response_code(200); // OK http_response_code(200); // OK
$return['success'] = true; $return['success'] = true;
} elseif ($denied) {
// Logged, but no failed login for netfilter: e.g. a client of a shared mailbox or without IMAP access
error_log("MAILCOWAUTH: Login denied for user " . $post['username'] . " with service " . $service . " from IP " . $client_ip);
http_response_code(401); // Unauthorized
} else { } else {
// Same line for the log and netfilter, which counts it for IMAP, POP3, Sieve and SMTP (Postfix asks Dovecot); // Same line for the log and netfilter, which counts it for IMAP, POP3, Sieve and SMTP (Postfix asks Dovecot);
// SOGo logins have their own rule // SOGo logins have their own rule
+41 -32
View File
@@ -6,6 +6,8 @@ function check_login($user, $pass, $extra = null) {
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
$role = $extra['role']; $role = $extra['role'];
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service']; $extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
// set when the password is right, but the account does not allow this login
$denied = false;
// Try validate admin // Try validate admin
if (!isset($role) || $role == "admin") { if (!isset($role) || $role == "admin") {
@@ -26,7 +28,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate app password // Try validate app password
if (!isset($role) || $role == "app") { if (!isset($role) || $role == "app") {
$result = apppass_login($user, $pass, $extra); $result = apppass_login($user, $pass, $extra, $denied);
if ($result !== false) { if ($result !== false) {
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']); $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service'], $pass); set_sasl_log($user, $real_rip, $extra['service'], $pass);
@@ -37,7 +39,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate user // Try validate user
if (!isset($role) || $role == "user") { if (!isset($role) || $role == "user") {
try { try {
$result = user_login($user, $pass, $extra); $result = user_login($user, $pass, $extra, $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) { } catch (\Mailcow\Auth\IdpUnavailable $e) {
// no failed login, netfilter must not count it // no failed login, netfilter must not count it
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage()); error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
@@ -54,19 +56,20 @@ function check_login($user, $pass, $extra = null) {
return $result; return $result;
} }
} }
// skip log and only return false if it's an internal request // skip log and only return false if it's an internal request
if ($is_internal == true) return false; if ($is_internal == true) return false;
// A denied login is logged, but not counted by netfilter (e.g. EAS/DAV clients of accounts without access)
$msg = ($denied ? "mailcow UI: Login denied for " : "mailcow UI: Invalid password for ") . $user . " by " . $_SERVER['REMOTE_ADDR'];
if (!isset($_SESSION['ldelay'])) { if (!isset($_SESSION['ldelay'])) {
$_SESSION['ldelay'] = "0"; $_SESSION['ldelay'] = "0";
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); error_log($msg);
} }
elseif (!isset($_SESSION['mailcow_cc_username'])) { elseif (!isset($_SESSION['mailcow_cc_username'])) {
$_SESSION['ldelay'] = $_SESSION['ldelay']+0.5; $_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); error_log($msg);
} }
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'danger', 'type' => 'danger',
@@ -207,7 +210,7 @@ function domainadmin_login($user, $pass){
return false; return false;
} }
function user_login($user, $pass, $extra = null){ function user_login($user, $pass, $extra = null, &$denied = false){
global $pdo; global $pdo;
global $iam_provider; global $iam_provider;
global $iam_settings; global $iam_settings;
@@ -261,11 +264,14 @@ function user_login($user, $pass, $extra = null){
if ($extra['service'] != 'NONE') { if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access"; $key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
$denied = true;
return false; return false;
} }
} }
return true; return true;
} }
// the IdP accepted the password, the mailbox is inactive
$denied = true;
} }
clear_session(); clear_session();
return false; return false;
@@ -273,18 +279,16 @@ function user_login($user, $pass, $extra = null){
// check if user has access to service (imap, smtp, pop3, sieve) if service is set // check if user has access to service (imap, smtp, pop3, sieve) if service is set
$row['attributes'] = json_decode($row['attributes'], true); $row['attributes'] = json_decode($row['attributes'], true);
// Shared mailboxes have no login of their own, members open them through group shares
if (($row['attributes']['shared'] ?? '0') == '1') {
return false;
}
if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access"; $key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { // Shared mailboxes have no login of their own, members open them through group shares.
return false; // Checked before asking an IdP (no template side effects).
if (($row['attributes']['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && isset($row['attributes'][$key]) && $row['attributes'][$key] != '1')
|| $row['active'] != '1' || $row['d_active'] != '1') {
// Denied, not failed, unless the password is wrong; IdP passwords are not checked for that
if ($row['authsource'] != 'mailcow' || verify_hash($row['password'], $pass) !== false) {
$denied = true;
} }
}
// IdP accounts: refuse inactive mailboxes and domains before asking the IdP (no template side effects)
if ($row['authsource'] != 'mailcow' && ($row['active'] != '1' || $row['d_active'] != '1')) {
return false; return false;
} }
switch ($row['authsource']) { switch ($row['authsource']) {
@@ -303,6 +307,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) { if (empty($row)) {
$denied = true;
return false; return false;
} }
@@ -361,6 +366,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) { if (empty($row)) {
$denied = true;
return false; return false;
} }
@@ -403,9 +409,6 @@ function user_login($user, $pass, $extra = null){
return $result; return $result;
break; break;
case 'mailcow': case 'mailcow':
if ($row['active'] != 1 || $row['d_active'] != 1) {
return false;
}
// verify password // verify password
if (verify_hash($row['password'], $pass) !== false) { if (verify_hash($row['password'], $pass) !== false) {
@@ -446,13 +449,15 @@ function user_login($user, $pass, $extra = null){
} }
return "user"; return "user";
} }
// TFA accounts use app passwords in mail clients
$denied = true;
} }
break; break;
} }
return false; return false;
} }
function apppass_login($user, $pass, $extra = null){ function apppass_login($user, $pass, $extra = null, &$denied = false){
global $pdo; global $pdo;
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
@@ -473,15 +478,12 @@ function apppass_login($user, $pass, $extra = null){
return false; return false;
} }
// fetch app password data // fetch app password data, inactive ones too: their right password is denied, not failed
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd` $stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id`,
`mailbox`.`active` as `mailbox_active`, `domain`.`active` as `domain_active`, `mailbox`.`attributes` as `mailbox_attributes` FROM `app_passwd`
INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox` INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group' WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group'
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'
AND `app_passwd`.`active` = '1'
AND COALESCE(JSON_UNQUOTE(JSON_VALUE(`mailbox`.`attributes`, '$.shared')), '0') != '1'
AND `app_passwd`.`mailbox` = :user" AND `app_passwd`.`mailbox` = :user"
); );
// fetch password data // fetch password data
@@ -491,17 +493,24 @@ function apppass_login($user, $pass, $extra = null){
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) { foreach ($rows as $row) {
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){ // verify password
if (verify_hash($row['password'], $pass) === false) {
continue;
}
$mailbox_attributes = json_decode($row['mailbox_attributes'], true);
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|| ($mailbox_attributes['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1')) {
// right password, but the app password does not allow this login
$denied = true;
continue; continue;
} }
// verify password
if (verify_hash($row['password'], $pass) !== false) {
$_SESSION['app_passwd_id'] = $row['app_passwd_id']; $_SESSION['app_passwd_id'] = $row['app_passwd_id'];
unset($_SESSION['ldelay']); unset($_SESSION['ldelay']);
return "user"; return "user";
} }
}
return false; return false;
} }