mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-28 20:23:26 +00:00
[Web][Dovecot] Do not count logins as failed while the identity provider is unavailable
This commit is contained in:
7 files changed
+97
-30
No files matched your search
@@ -101,7 +101,16 @@ if ($result === false){
|
|||||||
// Init Identity Provider
|
// Init Identity Provider
|
||||||
$iam_provider = identity_provider('init');
|
$iam_provider = identity_provider('init');
|
||||||
$iam_settings = identity_provider('get');
|
$iam_settings = identity_provider('get');
|
||||||
|
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']));
|
||||||
|
} 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) {
|
if ($result) {
|
||||||
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
|
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']);
|
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ function auth_password_verify(request, password)
|
|||||||
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable"
|
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable"
|
||||||
end
|
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
|
if c ~= 200 and c ~= 401 then
|
||||||
dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user)
|
dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user)
|
||||||
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
|
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ passdb {
|
|||||||
}
|
}
|
||||||
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
|
# 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
|
# 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 {
|
passdb {
|
||||||
driver = lua
|
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)
|
# Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing)
|
||||||
service doveadm {
|
service doveadm {
|
||||||
|
|||||||
@@ -36,7 +36,18 @@ function check_login($user, $pass, $extra = null) {
|
|||||||
|
|
||||||
// Try validate user
|
// Try validate user
|
||||||
if (!isset($role) || $role == "user") {
|
if (!isset($role) || $role == "user") {
|
||||||
|
try {
|
||||||
$result = user_login($user, $pass, $extra);
|
$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) {
|
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']);
|
set_sasl_log($user, $real_rip, $extra['service']);
|
||||||
@@ -516,11 +527,14 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!$iam_provider) {
|
if (!$iam_provider) {
|
||||||
return false;
|
throw new \Mailcow\Auth\IdpUnavailable('Keycloak is not configured completely');
|
||||||
}
|
}
|
||||||
|
|
||||||
// get access_token for service account of mailcow client
|
// get access_token for service account of mailcow client
|
||||||
$admin_token = identity_provider("get-keycloak-admin-token");
|
$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
|
// get the mailcow_password attribute from keycloak user
|
||||||
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
|
$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);
|
$users = json_decode(curl_exec($curl), true);
|
||||||
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||||
curl_close($curl);
|
curl_close($curl);
|
||||||
|
// the password is checked below, a failed request is never a wrong password
|
||||||
if ($code != 200) {
|
if ($code != 200) {
|
||||||
$_SESSION['return'][] = array(
|
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
|
||||||
'type' => 'danger',
|
|
||||||
'log' => array(__FUNCTION__, $user, '*', 'Identity Provider returned HTTP ' . $code),
|
|
||||||
'msg' => 'generic_server_error'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
// exact=true can still return several users with that email
|
// exact=true can still return several users with that email
|
||||||
if (!is_array($users) || count($users) != 1) {
|
if (!is_array($users) || count($users) != 1) {
|
||||||
@@ -687,7 +697,8 @@ function ldap_mbox_login($user, $pass, $extra = null){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!$iam_provider) {
|
if (!$iam_provider) {
|
||||||
return false;
|
// identity_provider('init') could not connect
|
||||||
|
throw new \Mailcow\Auth\IdpUnavailable('No connection to the LDAP server');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -700,33 +711,17 @@ function ldap_mbox_login($user, $pass, $extra = null){
|
|||||||
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
|
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
|
||||||
|
|
||||||
$user_res = $ldap_query->firstOrFail();
|
$user_res = $ldap_query->firstOrFail();
|
||||||
} catch (Exception $e) {
|
} catch (\LdapRecord\Query\ObjectNotFoundException $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'
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (!$iam_provider->auth()->attempt($user_res['dn'], $pass)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// clear $_SESSION['return'] to not leak data
|
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
|
||||||
$_SESSION['return'] = array();
|
}
|
||||||
$_SESSION['return'][] = array(
|
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
|
||||||
'type' => 'danger',
|
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
|
||||||
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
|
|
||||||
'msg' => 'generic_server_error'
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get mapped template
|
// get mapped template
|
||||||
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
|
|
||||||
$mapped_template = $idp_login->mappedTemplate($user_res[$iam_settings['attribute_field']] ?? array());
|
$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'] ?? '');
|
$external_id = \Mailcow\Identity\Directory\LdapDirectory::entryId($user_res, $iam_settings['id_attribute'] ?? '');
|
||||||
|
|
||||||
|
|||||||
@@ -1292,8 +1292,13 @@ function set_tfa($_data) {
|
|||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
if ($row) {
|
if ($row) {
|
||||||
if ($row['authsource'] == 'ldap'){
|
if ($row['authsource'] == 'ldap'){
|
||||||
|
try {
|
||||||
if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
|
if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
|
||||||
else $access_denied = false;
|
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 {
|
} else {
|
||||||
if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
|
if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
|
||||||
else $access_denied = false;
|
else $access_denied = false;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Auth;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity provider could not answer a login (unreachable, timeout, server error). No failed login:
|
||||||
|
* callers report a temporary failure and must not count it as an attempt for netfilter.
|
||||||
|
*/
|
||||||
|
class IdpUnavailable extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Mailcow\Auth\Service;
|
namespace Mailcow\Auth\Service;
|
||||||
|
|
||||||
|
use LdapRecord\Auth\BindException;
|
||||||
|
use LdapRecord\Connection;
|
||||||
|
use LdapRecord\LdapRecordException;
|
||||||
|
use Mailcow\Auth\IdpUnavailable;
|
||||||
use Mailcow\Identity\Model\Settings;
|
use Mailcow\Identity\Model\Settings;
|
||||||
use Mailcow\Identity\Service\LinkService;
|
use Mailcow\Identity\Service\LinkService;
|
||||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||||
@@ -19,6 +23,37 @@ class IdpLoginService
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds as the user to check the password, then again as the configured user. False for wrong credentials,
|
||||||
|
* IdpUnavailable when the server cannot answer (client side codes are negative: server down, timeout, ...).
|
||||||
|
*/
|
||||||
|
public function verifyLdapPassword(Connection $ldap, string $dn, string $password): bool
|
||||||
|
{
|
||||||
|
// an empty password would be an unauthenticated bind, which succeeds
|
||||||
|
if ($dn === '' || $password === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$guard = $ldap->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 */
|
/** Template of the first attribute mapping matching one of the IdP attribute values, null if none */
|
||||||
public function mappedTemplate(mixed $values): ?string
|
public function mappedTemplate(mixed $values): ?string
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user