diff --git a/data/conf/dovecot/auth/mailcowauth.php b/data/conf/dovecot/auth/mailcowauth.php index d1c7381f6..c8a73e39e 100644 --- a/data/conf/dovecot/auth/mailcowauth.php +++ b/data/conf/dovecot/auth/mailcowauth.php @@ -101,7 +101,16 @@ if ($result === false){ // Init Identity Provider $iam_provider = identity_provider('init'); $iam_settings = identity_provider('get'); - $result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service'])); + try { + $result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service'])); + } catch (\Mailcow\Auth\IdpUnavailable $e) { + // temporary failure for Dovecot, no failed login + error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage()); + http_response_code(503); // Service Unavailable + echo json_encode($return); + session_destroy(); + exit; + } if ($result) { error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); set_sasl_log($post['username'], $post['real_rip'], $post['service']); diff --git a/data/conf/dovecot/auth/passwd-verify.lua b/data/conf/dovecot/auth/passwd-verify.lua index a22488ea8..ddd5a316a 100644 --- a/data/conf/dovecot/auth/passwd-verify.lua +++ b/data/conf/dovecot/auth/passwd-verify.lua @@ -41,6 +41,12 @@ function auth_password_verify(request, password) return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable" end + -- 5xx: the backend or the identity provider cannot answer, a temporary failure and no failed login + if c >= 500 then + dovecot.i_info("HTTP request to auth backend failed with " .. tostring(c) .. " for user " .. request.user) + return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unavailable" + end + if c ~= 200 and c ~= 401 then dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user) return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error" diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf index 05d9264fa..c3789fb24 100644 --- a/data/conf/dovecot/dovecot.conf +++ b/data/conf/dovecot/dovecot.conf @@ -67,9 +67,11 @@ passdb { } # check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail # a return of the following passdb is mandatory +# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry +# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login passdb { driver = lua - args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes + args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w } # Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing) service doveadm { diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index a507bbfa4..87566af54 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -36,7 +36,18 @@ function check_login($user, $pass, $extra = null) { // Try validate user if (!isset($role) || $role == "user") { - $result = user_login($user, $pass, $extra); + try { + $result = user_login($user, $pass, $extra); + } catch (\Mailcow\Auth\IdpUnavailable $e) { + // no failed login, netfilter must not count it + error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage()); + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $user, '*', $e->getMessage()), + 'msg' => 'iam_unavailable' + ); + return false; + } if ($result !== false) { $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']); set_sasl_log($user, $real_rip, $extra['service']); @@ -516,11 +527,14 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ return false; } if (!$iam_provider) { - return false; + throw new \Mailcow\Auth\IdpUnavailable('Keycloak is not configured completely'); } // get access_token for service account of mailcow client $admin_token = identity_provider("get-keycloak-admin-token"); + if (!$admin_token) { + throw new \Mailcow\Auth\IdpUnavailable('Could not get an admin token from Keycloak'); + } // get the mailcow_password attribute from keycloak user $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users"; @@ -541,13 +555,9 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ $users = json_decode(curl_exec($curl), true); $code = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); + // the password is checked below, a failed request is never a wrong password if ($code != 200) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $user, '*', 'Identity Provider returned HTTP ' . $code), - 'msg' => 'generic_server_error' - ); - return false; + throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code); } // exact=true can still return several users with that email if (!is_array($users) || count($users) != 1) { @@ -687,7 +697,8 @@ function ldap_mbox_login($user, $pass, $extra = null){ return false; } if (!$iam_provider) { - return false; + // identity_provider('init') could not connect + throw new \Mailcow\Auth\IdpUnavailable('No connection to the LDAP server'); } try { @@ -700,33 +711,17 @@ function ldap_mbox_login($user, $pass, $extra = null){ \Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? ''))))); $user_res = $ldap_query->firstOrFail(); - } catch (Exception $e) { - // clear $_SESSION['return'] to not leak data - $_SESSION['return'] = array(); - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $user, '*', $e->getMessage()), - 'msg' => 'generic_server_error' - ); + } catch (\LdapRecord\Query\ObjectNotFoundException $e) { return false; - } - try { - if (!$iam_provider->auth()->attempt($user_res['dn'], $pass)) { - return false; - } } catch (Exception $e) { - // clear $_SESSION['return'] to not leak data - $_SESSION['return'] = array(); - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $user, '*', $e->getMessage()), - 'msg' => 'generic_server_error' - ); + throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e); + } + $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; + if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) { return false; } // get mapped template - $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; $mapped_template = $idp_login->mappedTemplate($user_res[$iam_settings['attribute_field']] ?? array()); $external_id = \Mailcow\Identity\Directory\LdapDirectory::entryId($user_res, $iam_settings['id_attribute'] ?? ''); diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 660cd67f0..79bc97b24 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1292,8 +1292,13 @@ function set_tfa($_data) { $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { if ($row['authsource'] == 'ldap'){ - if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true; - else $access_denied = false; + try { + if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true; + else $access_denied = false; + } catch (\Mailcow\Auth\IdpUnavailable $e) { + $_SESSION['return'][] = array('type' => 'danger', 'log' => array(__FUNCTION__, $_data_log, $e->getMessage()), 'msg' => 'iam_unavailable'); + return false; + } } else { if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true; else $access_denied = false; diff --git a/data/web/inc/lib/mailcow/Auth/IdpUnavailable.php b/data/web/inc/lib/mailcow/Auth/IdpUnavailable.php new file mode 100644 index 000000000..6dae16b48 --- /dev/null +++ b/data/web/inc/lib/mailcow/Auth/IdpUnavailable.php @@ -0,0 +1,15 @@ +auth(); + try { + $guard->bind($dn, $password); + $bound = true; + } catch (BindException $e) { + $code = $e->getDetailedError()?->getErrorCode(); + if ($code !== null && ($code < 0 || in_array($code, [51, 52], true))) { + throw new IdpUnavailable('LDAP bind failed: ' . $e->getMessage(), 0, $e); + } + $bound = false; + } catch (LdapRecordException $e) { + throw new IdpUnavailable('LDAP bind failed: ' . $e->getMessage(), 0, $e); + } + try { + $guard->bindAsConfiguredUser(); + } catch (LdapRecordException $e) { + // the login needs no further LDAP access + } + return $bound; + } + /** Template of the first attribute mapping matching one of the IdP attribute values, null if none */ public function mappedTemplate(mixed $values): ?string {