From 06f308a9349f2e24710a8e8495e4934054249a99 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:25:47 +0100 Subject: [PATCH] [Web] Sync User enabled/disabled status from IDP --- data/conf/phpfpm/crons/keycloak-sync.php | 32 ++++++++-- data/conf/phpfpm/crons/ldap-sync.php | 64 +++++++++++++++++-- data/web/inc/functions.inc.php | 41 ++++++------ data/web/inc/functions.mailbox.inc.php | 5 ++ data/web/lang/lang.de-de.json | 2 + data/web/lang/lang.en-gb.json | 2 + .../admin/tab-config-identity-provider.twig | 22 +++++++ 7 files changed, 140 insertions(+), 28 deletions(-) diff --git a/data/conf/phpfpm/crons/keycloak-sync.php b/data/conf/phpfpm/crons/keycloak-sync.php index f09a47d79..236cacf2f 100644 --- a/data/conf/phpfpm/crons/keycloak-sync.php +++ b/data/conf/phpfpm/crons/keycloak-sync.php @@ -155,6 +155,11 @@ while (true) { continue; } + // Determine if account is enabled in Keycloak + $keycloak_account_active = isset($user['enabled']) ? intval($user['enabled']) : 1; + $status = ($keycloak_account_active == 1) ? "enabled" : "disabled"; + logMsg("info", "User " . $user['email'] . " is {$status} in Keycloak"); + // try get mailbox user $stmt = $pdo->prepare("SELECT mailbox.*, @@ -172,6 +177,12 @@ while (true) { $_SESSION['access_all_exception'] = '1'; if (!$row && intval($iam_settings['import_users']) == 1){ + // Skip disabled users during import if sync_disabled_users is enabled + if (intval($iam_settings['sync_disabled_users']) == 1 && $keycloak_account_active == 0) { + logMsg("info", "Skipping import of disabled user " . $user['email']); + continue; + } + if ($mapper_key === false){ if (!empty($iam_settings['default_template'])) { $mbox_template = $iam_settings['default_template']; @@ -202,13 +213,26 @@ while (true) { continue; } $mbox_template = $iam_settings['templates'][$mapper_key]; - // mailbox user does exist, sync attribtues... - logMsg("info", "Syncing attributes for user " . $user['email']); - mailbox('edit', 'mailbox_from_template', array( + + // Prepare update data with active status + $update_data = array( 'username' => $user['email'], 'name' => $user['firstName'] . " " . $user['lastName'], 'template' => $mbox_template - )); + ); + + // Add active status if sync_disabled_users is enabled + if (intval($iam_settings['sync_disabled_users']) == 1) { + if ($row['active'] != $keycloak_account_active) { + $update_data['active'] = $keycloak_account_active; + $status_change = ($keycloak_account_active == 1) ? "enabled" : "disabled"; + logMsg("info", "Changing active status for user " . $user['email'] . " to {$status_change}"); + } + } + + // mailbox user does exist, sync attributes... + logMsg("info", "Syncing attributes for user " . $user['email']); + mailbox('edit', 'mailbox_from_template', $update_data); } else { // skip mailbox user logMsg("info", "Skipping user " . $user['email']); diff --git a/data/conf/phpfpm/crons/ldap-sync.php b/data/conf/phpfpm/crons/ldap-sync.php index 32026c071..f0099b3cc 100644 --- a/data/conf/phpfpm/crons/ldap-sync.php +++ b/data/conf/phpfpm/crons/ldap-sync.php @@ -118,7 +118,7 @@ if (!empty($iam_settings['filter'])) { } $response = $ldap_query->where($iam_settings['username_field'], "*") ->where($iam_settings['attribute_field'], "*") - ->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname']) + ->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'userAccountControl', 'pwdAccountLockedTime']) ->paginate($max); // Process the users @@ -138,6 +138,41 @@ foreach ($response as $user) { $user_template = $user[$iam_settings['attribute_field']][0]; $mapper_key = array_search($user_template, $iam_settings['mappers']); + // Determine if account is disabled in LDAP (multi-provider support) + $ldap_account_active = 1; // Default to active + $has_disabled_attr = false; + $disabled_check_method = "none"; + + // Try Active Directory userAccountControl first + if (isset($user['useraccountcontrol'][0])) { + $has_disabled_attr = true; + $disabled_check_method = "AD-userAccountControl"; + $uac = intval($user['useraccountcontrol'][0]); + + // UAC flag 0x0002 indicates ACCOUNTDISABLE + // If bit is set, account is disabled + $ldap_account_active = ($uac & 0x0002) ? 0 : 1; + + $uac_status = ($ldap_account_active == 1) ? "enabled" : "disabled"; + logMsg("info", "User " . $user[$iam_settings['username_field']][0] . " is {$uac_status} (AD UAC: {$uac})"); + } + // Try OpenLDAP/389DS/FreeIPA pwdAccountLockedTime + elseif (isset($user['pwdaccountlockedtime'])) { + $has_disabled_attr = true; + $disabled_check_method = "OpenLDAP-pwdAccountLockedTime"; + + // If pwdAccountLockedTime attribute exists and has a value, account is locked/disabled + $ldap_account_active = (!empty($user['pwdaccountlockedtime'][0])) ? 0 : 1; + + $status = ($ldap_account_active == 1) ? "enabled" : "disabled"; + logMsg("info", "User " . $user[$iam_settings['username_field']][0] . " is {$status} (OpenLDAP/389DS)"); + } + else { + // No disabled attribute found - this is normal for some LDAP implementations + // We'll skip disabled state sync for this user + logMsg("debug", "User " . $user[$iam_settings['username_field']][0] . " - no disabled attribute found (userAccountControl or pwdAccountLockedTime), skipping status sync"); + } + if (empty($user[$iam_settings['username_field']][0])){ logMsg("warning", "Skipping user " . $user['displayname'][0] . " due to empty LDAP ". $iam_settings['username_field'] . " property."); continue; @@ -145,6 +180,12 @@ foreach ($response as $user) { $_SESSION['access_all_exception'] = '1'; if (!$row && intval($iam_settings['import_users']) == 1){ + // Skip disabled users during import if sync_disabled_users is enabled + if (intval($iam_settings['sync_disabled_users']) == 1 && $has_disabled_attr && $ldap_account_active == 0) { + logMsg("info", "Skipping import of disabled user " . $user[$iam_settings['username_field']][0] . " (method: {$disabled_check_method})"); + continue; + } + if ($mapper_key === false){ if (!empty($iam_settings['default_template'])) { $mbox_template = $iam_settings['default_template']; @@ -174,13 +215,26 @@ foreach ($response as $user) { continue; } $mbox_template = $iam_settings['templates'][$mapper_key]; - // mailbox user does exist, sync attribtues... - logMsg("info", "Syncing attributes for user " . $user[$iam_settings['username_field']][0]); - mailbox('edit', 'mailbox_from_template', array( + + // Prepare update data with active status + $update_data = array( 'username' => $user[$iam_settings['username_field']][0], 'name' => $user['displayname'][0], 'template' => $mbox_template - )); + ); + + // Add active status if sync_disabled_users is enabled and a disabled attribute was found + if (intval($iam_settings['sync_disabled_users']) == 1 && $has_disabled_attr) { + if ($row['active'] != $ldap_account_active) { + $update_data['active'] = $ldap_account_active; + $status_change = ($ldap_account_active == 1) ? "enabled" : "disabled"; + logMsg("info", "Changing active status for user " . $user[$iam_settings['username_field']][0] . " to {$status_change} (method: {$disabled_check_method})"); + } + } + + // mailbox user does exist, sync attributes... + logMsg("info", "Syncing attributes for user " . $user[$iam_settings['username_field']][0]); + mailbox('edit', 'mailbox_from_template', $update_data); } else { // skip mailbox user logMsg("info", "Skipping user " . $user[$iam_settings['username_field']][0]); diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 23b8d701d..522abc079 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2383,6 +2383,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { case "use_tls": case "login_provisioning": case "ignore_ssl_errors": + case "sync_disabled_users": $settings[$row["key"]] = boolval($row["value"]); break; default: @@ -2462,13 +2463,14 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['login_provisioning'] = isset($_data['login_provisioning']) ? boolval($_data['login_provisioning']) : false; switch ($_data['authsource']) { case "keycloak": - $_data['server_url'] = (!empty($_data['server_url'])) ? rtrim($_data['server_url'], '/') : null; - $_data['mailpassword_flow'] = isset($_data['mailpassword_flow']) ? intval($_data['mailpassword_flow']) : 0; - $_data['periodic_sync'] = isset($_data['periodic_sync']) ? intval($_data['periodic_sync']) : 0; - $_data['import_users'] = isset($_data['import_users']) ? intval($_data['import_users']) : 0; - $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; - $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; - $required_settings = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval', 'ignore_ssl_error', 'login_provisioning'); + $_data['server_url'] = (!empty($_data['server_url'])) ? rtrim($_data['server_url'], '/') : null; + $_data['mailpassword_flow'] = isset($_data['mailpassword_flow']) ? intval($_data['mailpassword_flow']) : 0; + $_data['periodic_sync'] = isset($_data['periodic_sync']) ? intval($_data['periodic_sync']) : 0; + $_data['import_users'] = isset($_data['import_users']) ? intval($_data['import_users']) : 0; + $_data['sync_disabled_users'] = isset($_data['sync_disabled_users']) ? intval($_data['sync_disabled_users']) : 0; + $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; + $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; + $required_settings = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_disabled_users', 'sync_interval', 'ignore_ssl_error', 'login_provisioning'); break; case "generic-oidc": $_data['authorize_url'] = (!empty($_data['authorize_url'])) ? $_data['authorize_url'] : null; @@ -2478,18 +2480,19 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $required_settings = array('authsource', 'authorize_url', 'token_url', 'client_id', 'client_secret', 'redirect_url', 'userinfo_url', 'client_scopes', 'ignore_ssl_error', 'login_provisioning'); break; case "ldap": - $_data['host'] = (!empty($_data['host'])) ? str_replace(" ", "", $_data['host']) : ""; - $_data['port'] = (!empty($_data['port'])) ? intval($_data['port']) : 389; - $_data['username_field'] = (!empty($_data['username_field'])) ? strtolower($_data['username_field']) : "mail"; - $_data['attribute_field'] = (!empty($_data['attribute_field'])) ? strtolower($_data['attribute_field']) : ""; - $_data['filter'] = (!empty($_data['filter'])) ? $_data['filter'] : ""; - $_data['periodic_sync'] = isset($_data['periodic_sync']) ? intval($_data['periodic_sync']) : 0; - $_data['import_users'] = isset($_data['import_users']) ? intval($_data['import_users']) : 0; - $_data['use_ssl'] = isset($_data['use_ssl']) ? boolval($_data['use_ssl']) : false; - $_data['use_tls'] = isset($_data['use_tls']) && !$_data['use_ssl'] ? boolval($_data['use_tls']) : false; - $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; - $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; - $required_settings = array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error', 'login_provisioning'); + $_data['host'] = (!empty($_data['host'])) ? str_replace(" ", "", $_data['host']) : ""; + $_data['port'] = (!empty($_data['port'])) ? intval($_data['port']) : 389; + $_data['username_field'] = (!empty($_data['username_field'])) ? strtolower($_data['username_field']) : "mail"; + $_data['attribute_field'] = (!empty($_data['attribute_field'])) ? strtolower($_data['attribute_field']) : ""; + $_data['filter'] = (!empty($_data['filter'])) ? $_data['filter'] : ""; + $_data['periodic_sync'] = isset($_data['periodic_sync']) ? intval($_data['periodic_sync']) : 0; + $_data['import_users'] = isset($_data['import_users']) ? intval($_data['import_users']) : 0; + $_data['sync_disabled_users'] = isset($_data['sync_disabled_users']) ? intval($_data['sync_disabled_users']) : 0; + $_data['use_ssl'] = isset($_data['use_ssl']) ? boolval($_data['use_ssl']) : false; + $_data['use_tls'] = isset($_data['use_tls']) && !$_data['use_ssl'] ? boolval($_data['use_tls']) : false; + $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; + $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; + $required_settings = array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_disabled_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error', 'login_provisioning'); break; } diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 8d2efea3d..fe5950e6d 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -3691,6 +3691,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } } + // Override active status if explicitly provided (IDP sync takes priority over template) + if (isset($_data['active'])) { + $mailbox_attributes['active'] = $_data['active']; + } + $mailbox_attributes['quota'] = intval($mailbox_attributes['quota'] / 1048576); $result = mailbox('edit', 'mailbox', $mailbox_attributes); if ($result === false) return $result; diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index b86879ded..3a51aee3a 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -234,6 +234,8 @@ "iam_mapping": "Attribut Mapping", "iam_bindpass": "Bind Passwort", "iam_periodic_full_sync": "Vollsynchronisation", + "iam_sync_disabled_users": "Deaktivierte Benutzer synchronisieren", + "iam_sync_disabled_users_info": "Mailcow-Benutzer automatisch deaktivieren, wenn sie in LDAP/Keycloak deaktiviert sind, und wieder aktivieren, wenn sie reaktiviert werden. Unterstützt Active Directory (userAccountControl), OpenLDAP, 389 Directory Server und FreeIPA (pwdAccountLockedTime).", "iam_port": "Port", "iam_realm": "Realm", "iam_redirect_url": "Redirect Url", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 79175e578..0bee83859 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -241,6 +241,8 @@ "iam_mapping": "Attribute Mapping", "iam_bindpass": "Bind Password", "iam_periodic_full_sync": "Periodic Full Sync", + "iam_sync_disabled_users": "Sync user disabled status", + "iam_sync_disabled_users_info": "Automatically disable mailcow users when they are disabled in LDAP/Keycloak, and re-enable them when they are re-enabled. Supports Active Directory (userAccountControl), OpenLDAP, 389 Directory Server, and FreeIPA (pwdAccountLockedTime).", "iam_port": "Port", "iam_realm": "Realm", "iam_redirect_url": "Redirect Url", diff --git a/data/web/templates/admin/tab-config-identity-provider.twig b/data/web/templates/admin/tab-config-identity-provider.twig index 4572d7fb5..0d26b664d 100644 --- a/data/web/templates/admin/tab-config-identity-provider.twig +++ b/data/web/templates/admin/tab-config-identity-provider.twig @@ -249,6 +249,17 @@ +