diff --git a/data/Dockerfiles/phpfpm/docker-entrypoint.sh b/data/Dockerfiles/phpfpm/docker-entrypoint.sh index 37370113d..9e4e82371 100755 --- a/data/Dockerfiles/phpfpm/docker-entrypoint.sh +++ b/data/Dockerfiles/phpfpm/docker-entrypoint.sh @@ -204,6 +204,17 @@ chown -R 82:82 /web/templates/cache # Clear cache find /web/templates/cache/* -not -name '.gitkeep' -delete +# list client ca of all domains for +CA_LIST="/etc/nginx/conf.d/client_cas.crt" +# Clear the output file +> "$CA_LIST" +# Execute the query and append each value to the output file +mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ssl_client_ca FROM domain;" | while read -r ca; do + echo "$ca" >> "$CA_LIST" +done +echo "SSL client CAs have been appended to $CA_LIST" + + # Run hooks for file in /hooks/*; do if [ -x "${file}" ]; then diff --git a/data/conf/nginx/includes/site-defaults.conf b/data/conf/nginx/includes/site-defaults.conf index 1d03e9398..acf3ce15b 100644 --- a/data/conf/nginx/includes/site-defaults.conf +++ b/data/conf/nginx/includes/site-defaults.conf @@ -13,6 +13,8 @@ ssl_session_timeout 1d; ssl_session_tickets off; + include /etc/nginx/conf.d/includes/ssl_client_auth.conf; + add_header Strict-Transport-Security "max-age=15768000;"; add_header X-Content-Type-Options nosniff; add_header X-XSS-Protection "1; mode=block"; @@ -101,6 +103,10 @@ include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param TLS_SUCCESS $ssl_client_verify; + fastcgi_param TLS_ISSUER $ssl_client_i_dn; + fastcgi_param TLS_DN $ssl_client_s_dn; + fastcgi_param TLS_CERT $ssl_client_cert; fastcgi_read_timeout 3600; fastcgi_send_timeout 3600; } diff --git a/data/conf/nginx/includes/ssl_client_auth.conf b/data/conf/nginx/includes/ssl_client_auth.conf new file mode 100644 index 000000000..edacbf778 --- /dev/null +++ b/data/conf/nginx/includes/ssl_client_auth.conf @@ -0,0 +1,4 @@ + +ssl_verify_client optional; +ssl_client_certificate /etc/nginx/conf.d/client_cas.crt; + diff --git a/data/conf/nginx/templates/ssl_client_auth.template.sh b/data/conf/nginx/templates/ssl_client_auth.template.sh new file mode 100755 index 000000000..b555f042b --- /dev/null +++ b/data/conf/nginx/templates/ssl_client_auth.template.sh @@ -0,0 +1,23 @@ +apk add mariadb-client + +# List client CA of all domains +CA_LIST="/etc/nginx/conf.d/client_cas.crt" +> "$CA_LIST" + +# Define your SQL query +query="SELECT DISTINCT ssl_client_ca FROM domain WHERE ssl_client_ca IS NOT NULL;" +result=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "$query" -B -N) +if [ -n "$result" ]; then + echo "$result" | while IFS= read -r line; do + echo -e "$line" + done > $CA_LIST + #tail -n 1 "$CA_LIST" | wc -c | xargs -I {} truncate "$CA_LIST" -s -{} + echo " +ssl_verify_client optional; +ssl_client_certificate /etc/nginx/conf.d/client_cas.crt; +" > /etc/nginx/conf.d/includes/ssl_client_auth.conf + echo "SSL client CAs have been appended to $CA_LIST" +else + > /etc/nginx/conf.d/includes/ssl_client_auth.conf + echo "No SSL client CAs found" +fi diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index 7183cc8c1..69515ca31 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -233,6 +233,75 @@ function user_login($user, $pass, $extra = null){ return false; } +function user_mutualtls_login() { + global $pdo; + + if (empty($_SERVER["TLS_SUCCESS"]) || empty($_SERVER["TLS_DN"]) || empty($_SERVER["TLS_ISSUER"])) { + // missing info + return false; + } + if (!$_SERVER["TLS_SUCCESS"]) { + // mutual tls login failed + return false; + } + + // parse dn + $pairs = explode(',', $_SERVER["TLS_DN"]); + $dn_details = []; + foreach ($pairs as $pair) { + $keyValue = explode('=', $pair); + $dn_details[$keyValue[0]] = $keyValue[1]; + } + // parse dn + $pairs = explode(',', $_SERVER["TLS_ISSUER"]); + $issuer_details = []; + foreach ($pairs as $pair) { + $keyValue = explode('=', $pair); + $issuer_details[$keyValue[0]] = $keyValue[1]; + } + + $user = $dn_details['emailAddress']; + if (empty($user)){ + // no user specified + return false; + } + + $search = ""; + ksort($issuer_details); + foreach ($issuer_details as $key => $value) { + $search .= "{$key}={$value},"; + } + $search = rtrim($search, ','); + if (empty($search)){ + // incomplete issuer details + return false; + } + + $user_split = explode('@', $user); + $local_part = $user_split[0]; + $domain = $user_split[1]; + // search for match + $stmt = $pdo->prepare("SELECT * FROM `domain` AS d1 + INNER JOIN `mailbox` ON mailbox.domain = d1.domain + INNER JOIN `domain` AS d2 ON mailbox.domain = d2.domain + WHERE `kind` NOT REGEXP 'location|thing|group' + AND d2.`ssl_client_issuer` = :search + AND d2.`active`='1' + AND mailbox.`active`='1' + AND mailbox.`username` = :user"); + $stmt->execute(array( + ':search' => $search, + ':user' => $user + )); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + // user not found + if (!$row){ + return false; + } + + return $user; +} function apppass_login($user, $pass, $app_passwd_data, $extra = null){ global $pdo; diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 215a95fdd..cd8132961 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2522,6 +2522,31 @@ function clear_session(){ session_destroy(); session_write_close(); } +function is_valid_ssl_cert($cert) { + if (empty($cert)) { + return false; + } + $cert_res = openssl_x509_read($cert); + if ($cert_res === false) { + return false; + } + openssl_x509_free($cert_res); + + return true; +} +function has_ssl_client_auth() { + global $pdo; + + $stmt = $pdo->query("SELECT domain FROM `domain` + WHERE `ssl_client_ca` IS NOT NULL + AND `ssl_client_issuer` IS NOT NULL"); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row){ + return false; + } + + return true; +} function get_logs($application, $lines = false) { if ($lines === false) { diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 46658274e..c754a04b0 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -528,11 +528,24 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ); return false; } - $active = (isset($_data['active'])) ? intval($_data['active']) : $DOMAIN_DEFAULT_ATTRIBUTES['active']; + $active = (isset($_data['active'])) ? intval($_data['active']) : $DOMAIN_DEFAULT_ATTRIBUTES['active']; $relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_all_recipients']; - $relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only']; - $backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx']; - $gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal']; + $relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only']; + $backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx']; + $gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal']; + $ssl_client_ca = (is_valid_ssl_cert(trim($_data['ssl_client_ca']))) ? trim($_data['ssl_client_ca']) : null; + $ssl_client_issuer = ""; + if (isset($ssl_client_ca)) { + $ca_issuer = openssl_x509_parse($ssl_client_ca); + if (!empty($ca_issuer) && is_array($ca_issuer['issuer'])){ + $ca_issuer = $ca_issuer['issuer']; + ksort($ca_issuer); + foreach ($ca_issuer as $key => $value) { + $ssl_client_issuer .= "{$key}={$value},"; + } + $ssl_client_issuer = rtrim($ssl_client_issuer, ','); + } + } if ($relay_all_recipients == 1) { $backupmx = '1'; } @@ -588,22 +601,33 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => '%@' . $domain )); // save domain - $stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`) - VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients)"); - $stmt->execute(array( - ':domain' => $domain, - ':description' => $description, - ':aliases' => $aliases, - ':mailboxes' => $mailboxes, - ':defquota' => $defquota, - ':maxquota' => $maxquota, - ':quota' => $quota, - ':backupmx' => $backupmx, - ':gal' => $gal, - ':active' => $active, - ':relay_unknown_only' => $relay_unknown_only, - ':relay_all_recipients' => $relay_all_recipients - )); + try { + $stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`, `ssl_client_issuer`, `ssl_client_ca`) + VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients, :ssl_client_issuer, :ssl_client_ca)"); + $stmt->execute(array( + ':domain' => $domain, + ':description' => $description, + ':aliases' => $aliases, + ':mailboxes' => $mailboxes, + ':defquota' => $defquota, + ':maxquota' => $maxquota, + ':quota' => $quota, + ':backupmx' => $backupmx, + ':gal' => $gal, + ':active' => $active, + ':relay_unknown_only' => $relay_unknown_only, + ':relay_all_recipients' => $relay_all_recipients, + ':ssl_client_issuer' => $ssl_client_issuer, + 'ssl_client_ca' => $ssl_client_ca + )); + } catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => $e->getMessage() + ); + return false; + } // save tags foreach($tags as $index => $tag){ if (empty($tag)) continue; @@ -654,15 +678,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } if (!empty($restart_sogo)) { $restart_response = json_decode(docker('post', 'sogo-mailcow', 'restart'), true); - if ($restart_response['type'] == "success") { - $_SESSION['return'][] = array( - 'type' => 'success', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => array('domain_added', htmlspecialchars($domain)) - ); - return true; - } - else { + if ($restart_response['type'] != "success") { $_SESSION['return'][] = array( 'type' => 'warning', 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), @@ -671,6 +687,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { return false; } } + if (!empty($ssl_client_ca) && !empty($ssl_client_issuer)) { + // restart nginx + $restart_response = json_decode(docker('post', 'nginx-mailcow', 'restart'), true); + if ($restart_response['type'] != "success") { + $_SESSION['return'][] = array( + 'type' => 'warning', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => 'nginx_restart_failed' + ); + return false; + } + } $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), @@ -2673,7 +2701,22 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $maxquota = (!empty($_data['maxquota'])) ? $_data['maxquota'] : ($is_now['max_quota_for_mbox'] / 1048576); $quota = (!empty($_data['quota'])) ? $_data['quota'] : ($is_now['max_quota_for_domain'] / 1048576); $description = (!empty($_data['description'])) ? $_data['description'] : $is_now['description']; - $tags = (is_array($_data['tags']) ? $_data['tags'] : array()); + $tags = (is_array($_data['tags']) ? $_data['tags'] : array()); + $ssl_client_ca = (is_valid_ssl_cert(trim($_data['ssl_client_ca']))) ? trim($_data['ssl_client_ca']) : $is_now['ssl_client_ca']; + $ssl_client_issuer = $is_now['ssl_client_issuer']; + if (is_valid_ssl_cert(trim($_data['ssl_client_ca']))){ + if (isset($ssl_client_ca)) { + $ca_issuer = openssl_x509_parse($ssl_client_ca); + if (!empty($ca_issuer) && is_array($ca_issuer['issuer'])){ + $ca_issuer = $ca_issuer['issuer']; + ksort($ca_issuer); + foreach ($ca_issuer as $key => $value) { + $ssl_client_issuer .= "{$key}={$value},"; + } + $ssl_client_issuer = rtrim($ssl_client_issuer, ','); + } + } + } if ($relay_all_recipients == '1') { $backupmx = '1'; } @@ -2773,35 +2816,47 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { continue; } - $stmt = $pdo->prepare("UPDATE `domain` SET - `relay_all_recipients` = :relay_all_recipients, - `relay_unknown_only` = :relay_unknown_only, - `backupmx` = :backupmx, - `gal` = :gal, - `active` = :active, - `quota` = :quota, - `defquota` = :defquota, - `maxquota` = :maxquota, - `relayhost` = :relayhost, - `mailboxes` = :mailboxes, - `aliases` = :aliases, - `description` = :description - WHERE `domain` = :domain"); - $stmt->execute(array( - ':relay_all_recipients' => $relay_all_recipients, - ':relay_unknown_only' => $relay_unknown_only, - ':backupmx' => $backupmx, - ':gal' => $gal, - ':active' => $active, - ':quota' => $quota, - ':defquota' => $defquota, - ':maxquota' => $maxquota, - ':relayhost' => $relayhost, - ':mailboxes' => $mailboxes, - ':aliases' => $aliases, - ':description' => $description, - ':domain' => $domain - )); + try { + $stmt = $pdo->prepare("UPDATE `domain` SET + `relay_all_recipients` = :relay_all_recipients, + `relay_unknown_only` = :relay_unknown_only, + `backupmx` = :backupmx, + `gal` = :gal, + `active` = :active, + `quota` = :quota, + `defquota` = :defquota, + `maxquota` = :maxquota, + `relayhost` = :relayhost, + `mailboxes` = :mailboxes, + `aliases` = :aliases, + `description` = :description, + `ssl_client_ca` = :ssl_client_ca, + `ssl_client_issuer` = :ssl_client_issuer + WHERE `domain` = :domain"); + $stmt->execute(array( + ':relay_all_recipients' => $relay_all_recipients, + ':relay_unknown_only' => $relay_unknown_only, + ':backupmx' => $backupmx, + ':gal' => $gal, + ':active' => $active, + ':quota' => $quota, + ':defquota' => $defquota, + ':maxquota' => $maxquota, + ':relayhost' => $relayhost, + ':mailboxes' => $mailboxes, + ':aliases' => $aliases, + ':description' => $description, + ':ssl_client_ca' => $ssl_client_ca, + ':ssl_client_issuer' => $ssl_client_issuer, + ':domain' => $domain + )); + }catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => $e->getMessage() + ); + } // save tags foreach($tags as $index => $tag){ if (empty($tag)) continue; @@ -4416,7 +4471,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { `relay_unknown_only`, `backupmx`, `gal`, - `active` + `active`, + `ssl_client_ca` FROM `domain` WHERE `domain`= :domain"); $stmt->execute(array( ':domain' => $_data @@ -4484,6 +4540,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $domaindata['relay_unknown_only_int'] = $row['relay_unknown_only']; $domaindata['created'] = $row['created']; $domaindata['modified'] = $row['modified']; + $domaindata['ssl_client_ca'] = $row['ssl_client_ca']; $stmt = $pdo->prepare("SELECT COUNT(`address`) AS `alias_count` FROM `alias` WHERE (`domain`= :domain OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain2)) AND `address` NOT IN ( diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 4f73911a6..7ea7394cb 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -3,7 +3,7 @@ function init_db_schema() { try { global $pdo; - $db_version = "08012024_1442"; + $db_version = "08022024_1302"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -256,6 +256,8 @@ function init_db_schema() { "gal" => "TINYINT(1) NOT NULL DEFAULT '1'", "relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'", "relay_unknown_only" => "TINYINT(1) NOT NULL DEFAULT '0'", + "ssl_client_issuer" => "TEXT", + "ssl_client_ca" => "TEXT", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", "active" => "TINYINT(1) NOT NULL DEFAULT '1'" diff --git a/data/web/inc/triggers.inc.php b/data/web/inc/triggers.inc.php index c8333f979..0e0f50970 100644 --- a/data/web/inc/triggers.inc.php +++ b/data/web/inc/triggers.inc.php @@ -26,6 +26,26 @@ if ($iam_provider){ } } +if (isset($_GET['mutual_tls_login'])) { + $mutual_login_user = user_mutualtls_login(); + if ($mutual_login_user != false) { + $_SESSION['mailcow_cc_username'] = $mutual_login_user; + $_SESSION['mailcow_cc_role'] = "user"; + + $http_parameters = explode('&', $_SESSION['index_query_string']); + unset($_SESSION['index_query_string']); + if (in_array('mobileconfig', $http_parameters)) { + if (in_array('only_email', $http_parameters)) { + header("Location: /mobileconfig.php?only_email"); + die(); + } + header("Location: /mobileconfig.php"); + die(); + } + header("Location: /user"); + } +} + // SSO Domain Admin if (!empty($_GET['sso_token'])) { $username = domain_admin_sso('check', $_GET['sso_token']); diff --git a/data/web/index.php b/data/web/index.php index c44696b6d..fc480c9fc 100644 --- a/data/web/index.php +++ b/data/web/index.php @@ -29,7 +29,8 @@ $template_data = [ 'oauth2_request' => @$_SESSION['oauth2_request'], 'is_mobileconfig' => str_contains($_SESSION['index_query_string'], 'mobileconfig'), 'login_delay' => @$_SESSION['ldelay'], - 'has_iam_sso' => ($iam_provider) ? true : false + 'has_iam_sso' => ($iam_provider) ? true : false, + 'has_ssl_client_auth' => has_ssl_client_auth() ]; $js_minifier->add('/web/js/site/index.js'); diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 869e69d16..c0de9524e 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -345,6 +345,8 @@ "service_id": "Service ID", "source": "Source", "spamfilter": "Spam filter", + "ssl_client_auth": "mTLS", + "ssl_client_ca": "CA for mTLS Login", "subject": "Subject", "success": "Success", "sys_mails": "System mails", diff --git a/data/web/templates/edit/domain.twig b/data/web/templates/edit/domain.twig index 8a700d06a..dac60a1aa 100644 --- a/data/web/templates/edit/domain.twig +++ b/data/web/templates/edit/domain.twig @@ -91,12 +91,18 @@ -