From 2208d7e6fb2864e2ddc672104b61b2a496fc1e02 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it Date: Tue, 30 Jul 2024 14:46:08 +0200 Subject: [PATCH] [Web] add function to reset user passwords --- data/assets/templates/pw_reset_html.tpl | 29 ++ data/assets/templates/pw_reset_text.tpl | 11 + data/web/admin.php | 1 + data/web/inc/functions.inc.php | 451 +++++++++++++++++- data/web/inc/functions.mailbox.inc.php | 91 ++-- data/web/inc/init_db.inc.php | 16 +- data/web/inc/triggers.inc.php | 60 ++- data/web/inc/vars.inc.php | 6 + data/web/json_api.php | 4 +- data/web/lang/lang.de-de.json | 25 + data/web/lang/lang.en-gb.json | 25 + data/web/reset-password.php | 31 ++ data/web/templates/admin.twig | 4 +- .../admin/tab-config-password-policy.twig | 40 -- .../admin/tab-config-password-settings.twig | 102 ++++ data/web/templates/edit/mailbox.twig | 7 + data/web/templates/index.twig | 3 + data/web/templates/modals/user.twig | 27 ++ data/web/templates/reset-password.twig | 57 +++ data/web/templates/user/tab-user-auth.twig | 1 + 20 files changed, 883 insertions(+), 108 deletions(-) create mode 100644 data/assets/templates/pw_reset_html.tpl create mode 100644 data/assets/templates/pw_reset_text.tpl create mode 100644 data/web/reset-password.php delete mode 100644 data/web/templates/admin/tab-config-password-policy.twig create mode 100644 data/web/templates/admin/tab-config-password-settings.twig create mode 100644 data/web/templates/reset-password.twig diff --git a/data/assets/templates/pw_reset_html.tpl b/data/assets/templates/pw_reset_html.tpl new file mode 100644 index 000000000..481f8cbef --- /dev/null +++ b/data/assets/templates/pw_reset_html.tpl @@ -0,0 +1,29 @@ + + + + + + +Hello {{username2}},

+ +Somebody requested a new password for the {{hostname}} account associated with {{username}}.
+Date of the password reset request: {{date}}

+ +You can reset your password by clicking the link below:
+{{link}}

+ +The link will be valid for the next {{token_lifetime}} minutes.

+ +If you did not request a new password, please ignore this email.
+ + diff --git a/data/assets/templates/pw_reset_text.tpl b/data/assets/templates/pw_reset_text.tpl new file mode 100644 index 000000000..fabe1e71a --- /dev/null +++ b/data/assets/templates/pw_reset_text.tpl @@ -0,0 +1,11 @@ +Hello {{username2}}, + +Somebody requested a new password for the {{hostname}} account associated with {{username}}. +Date of the password reset request: {{date}} + +You can reset your password by clicking the link below: +{{link}} + +The link will be valid for the next {{token_lifetime}} minutes. + +If you did not request a new password, please ignore this email. diff --git a/data/web/admin.php b/data/web/admin.php index d0fcbc992..5dd7b3c6b 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -107,6 +107,7 @@ $template_data = [ 'f2b_banlist_url' => getBaseUrl() . "/api/v1/get/fail2ban/banlist/" . $f2b_data['banlist_id'], 'q_data' => quarantine('settings'), 'qn_data' => quota_notification('get'), + 'pw_reset_data' => reset_password('get_notification'), 'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'), 'rsettings' => $rsettings, 'rspamd_regex_maps' => $rspamd_regex_maps, diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 8e0ac580b..af74d1407 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1073,13 +1073,17 @@ function update_sogo_static_view($mailbox = null) { function edit_user_account($_data) { global $lang; global $pdo; + $_data_log = $_data; !isset($_data_log['user_new_pass']) ?: $_data_log['user_new_pass'] = '*'; !isset($_data_log['user_new_pass2']) ?: $_data_log['user_new_pass2'] = '*'; !isset($_data_log['user_old_pass']) ?: $_data_log['user_old_pass'] = '*'; + $username = $_SESSION['mailcow_cc_username']; $role = $_SESSION['mailcow_cc_role']; $password_old = $_data['user_old_pass']; + $pw_recovery_email = $_data['pw_recovery_email']; + if (filter_var($username, FILTER_VALIDATE_EMAIL === false) || $role != 'user') { $_SESSION['return'][] = array( 'type' => 'danger', @@ -1088,20 +1092,24 @@ function edit_user_account($_data) { ); return false; } - $stmt = $pdo->prepare("SELECT `password` FROM `mailbox` - WHERE `kind` NOT REGEXP 'location|thing|group' - AND `username` = :user"); - $stmt->execute(array(':user' => $username)); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!verify_hash($row['password'], $password_old)) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_data_log), - 'msg' => 'access_denied' - ); - return false; - } - if (!empty($_data['user_new_pass']) && !empty($_data['user_new_pass2'])) { + + // edit password + if (!empty($password_old) && !empty($_data['user_new_pass']) && !empty($_data['user_new_pass2'])) { + $stmt = $pdo->prepare("SELECT `password` FROM `mailbox` + WHERE `kind` NOT REGEXP 'location|thing|group' + AND `username` = :user"); + $stmt->execute(array(':user' => $username)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!verify_hash($row['password'], $password_old)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + $password_new = $_data['user_new_pass']; $password_new2 = $_data['user_new_pass2']; if (password_check($password_new, $password_new2) !== true) { @@ -1116,8 +1124,29 @@ function edit_user_account($_data) { ':password_hashed' => $password_hashed, ':username' => $username )); + + update_sogo_static_view(); } - update_sogo_static_view(); + // edit password recovery email + elseif (isset($pw_recovery_email)) { + if (!isset($_SESSION['acl']['pw_reset']) || $_SESSION['acl']['pw_reset'] != "1" ) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => 'access_denied' + ); + return false; + } + + $pw_recovery_email = (!filter_var($pw_recovery_email, FILTER_VALIDATE_EMAIL)) ? '' : $pw_recovery_email; + $stmt = $pdo->prepare("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email) + WHERE `username` = :username"); + $stmt->execute(array( + ':recovery_email' => $pw_recovery_email, + ':username' => $username + )); + } + $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_data_log), @@ -2261,6 +2290,398 @@ function uuid4() { return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } +function reset_password($action, $data = null) { + global $pdo; + global $redis; + global $mailcow_hostname; + global $PW_RESET_TOKEN_LIMIT; + global $PW_RESET_TOKEN_LIFETIME; + + $_data_log = $data; + if (isset($_data_log['new_password'])) $_data_log['new_password'] = '*'; + if (isset($_data_log['new_password2'])) $_data_log['new_password2'] = '*'; + + switch ($action) { + case 'check': + $token = $data; + + $stmt = $pdo->prepare("SELECT `t1`.`username` FROM `reset_password` AS `t1` JOIN `mailbox` AS `t2` ON `t1`.`username` = `t2`.`username` WHERE `t1`.`token` = :token AND `t1`.`created` > DATE_SUB(NOW(), INTERVAL :lifetime MINUTE) AND `t2`.`active` = 1;"); + $stmt->execute(array( + ':token' => preg_replace('/[^a-zA-Z0-9-]/', '', $token), + ':lifetime' => $PW_RESET_TOKEN_LIFETIME + )); + $return = $stmt->fetch(PDO::FETCH_ASSOC); + return empty($return['username']) ? false : $return['username']; + break; + case 'issue': + $username = $data; + + // perform cleanup + $stmt = $pdo->prepare("DELETE FROM `reset_password` WHERE created < DATE_SUB(NOW(), INTERVAL :lifetime MINUTE);"); + $stmt->execute(array(':lifetime' => $PW_RESET_TOKEN_LIFETIME)); + + if (filter_var($username, FILTER_VALIDATE_EMAIL) === false) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + + $stmt = $pdo->prepare("SELECT * FROM `mailbox` + WHERE `username` = :username"); + $stmt->execute(array(':username' => $username)); + $mailbox_data = $stmt->fetch(PDO::FETCH_ASSOC); + + if (empty($mailbox_data)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'password_reset_invalid_user' + ); + return false; + } + + $mailbox_attr = json_decode($mailbox_data['attributes'], true); + if (empty($mailbox_attr['recovery_email']) || filter_var($mailbox_attr['recovery_email'], FILTER_VALIDATE_EMAIL) === false) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => "password_reset_invalid_user" + ); + return false; + } + + $stmt = $pdo->prepare("SELECT * FROM `reset_password` + WHERE `username` = :username"); + $stmt->execute(array(':username' => $username)); + $generated_token_count = count($stmt->fetchAll(PDO::FETCH_ASSOC)); + if ($generated_token_count >= $PW_RESET_TOKEN_LIMIT) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => "reset_token_limit_exceeded" + ); + return false; + } + + $token = implode('-', array( + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))) + )); + + $stmt = $pdo->prepare("INSERT INTO `reset_password` (`username`, `token`) + VALUES (:username, :token)"); + $stmt->execute(array( + ':username' => $username, + ':token' => $token + )); + + $pw_reset_notification = reset_password('get_notification', 'raw'); + if (!$pw_reset_notification) return false; + + $reset_link = getBaseURL() . "/reset-password?token=" . $token; + + $request_date = new DateTime(); + $locale_date = locale_get_default(); + $date_formatter = new IntlDateFormatter( + $locale_date, + IntlDateFormatter::FULL, + IntlDateFormatter::FULL + ); + $formatted_request_date = $date_formatter->format($request_date); + + // set template vars + // subject + $pw_reset_notification['subject'] = str_replace('{{hostname}}', $mailcow_hostname, $pw_reset_notification['subject']); + $pw_reset_notification['subject'] = str_replace('{{link}}', $reset_link, $pw_reset_notification['subject']); + $pw_reset_notification['subject'] = str_replace('{{username}}', $username, $pw_reset_notification['subject']); + $pw_reset_notification['subject'] = str_replace('{{username2}}', $mailbox_attr['recovery_email'], $pw_reset_notification['subject']); + $pw_reset_notification['subject'] = str_replace('{{date}}', $formatted_request_date, $pw_reset_notification['subject']); + $pw_reset_notification['subject'] = str_replace('{{token_lifetime}}', $PW_RESET_TOKEN_LIFETIME, $pw_reset_notification['subject']); + // text + $pw_reset_notification['text_tmpl'] = str_replace('{{hostname}}', $mailcow_hostname, $pw_reset_notification['text_tmpl']); + $pw_reset_notification['text_tmpl'] = str_replace('{{link}}', $reset_link, $pw_reset_notification['text_tmpl']); + $pw_reset_notification['text_tmpl'] = str_replace('{{username}}', $username, $pw_reset_notification['text_tmpl']); + $pw_reset_notification['text_tmpl'] = str_replace('{{username2}}', $mailbox_attr['recovery_email'], $pw_reset_notification['text_tmpl']); + $pw_reset_notification['text_tmpl'] = str_replace('{{date}}', $formatted_request_date, $pw_reset_notification['text_tmpl']); + $pw_reset_notification['text_tmpl'] = str_replace('{{token_lifetime}}', $PW_RESET_TOKEN_LIFETIME, $pw_reset_notification['text_tmpl']); + // html + $pw_reset_notification['html_tmpl'] = str_replace('{{hostname}}', $mailcow_hostname, $pw_reset_notification['html_tmpl']); + $pw_reset_notification['html_tmpl'] = str_replace('{{link}}', $reset_link, $pw_reset_notification['html_tmpl']); + $pw_reset_notification['html_tmpl'] = str_replace('{{username}}', $username, $pw_reset_notification['html_tmpl']); + $pw_reset_notification['html_tmpl'] = str_replace('{{username2}}', $mailbox_attr['recovery_email'], $pw_reset_notification['html_tmpl']); + $pw_reset_notification['html_tmpl'] = str_replace('{{date}}', $formatted_request_date, $pw_reset_notification['html_tmpl']); + $pw_reset_notification['html_tmpl'] = str_replace('{{token_lifetime}}', $PW_RESET_TOKEN_LIFETIME, $pw_reset_notification['html_tmpl']); + + + $email_sent = reset_password('send_mail', array( + "from" => $pw_reset_notification['from'], + "to" => $mailbox_attr['recovery_email'], + "subject" => $pw_reset_notification['subject'], + "text" => $pw_reset_notification['text_tmpl'], + "html" => $pw_reset_notification['html_tmpl'] + )); + + if (!$email_sent){ + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => "recovery_email_failed" + ); + return false; + } + + list($localPart, $domainPart) = explode('@', $mailbox_attr['recovery_email']); + if (strlen($localPart) > 1) { + $maskedLocalPart = $localPart[0] . str_repeat('*', strlen($localPart) - 1); + } else { + $maskedLocalPart = "*"; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array("recovery_email_sent", $maskedLocalPart . '@' . $domainPart) + ); + return array( + "username" => $username, + "issue" => "success" + ); + break; + case 'reset': + $token = $data['token']; + $new_password = $data['new_password']; + $new_password2 = $data['new_password2']; + $username = $data['username']; + $check_tfa = $data['check_tfa']; + + if (!$username || !$token) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'invalid_reset_token' + ); + return false; + } + + # check new password + if (!password_check($new_password, $new_password2)) { + return false; + } + + if ($check_tfa){ + // check for tfa authenticators + $authenticators = get_tfa($username); + if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) { + $_SESSION['pending_mailcow_cc_username'] = $username; + $_SESSION['pending_pw_reset_token'] = $token; + $_SESSION['pending_pw_new_password'] = $new_password; + $_SESSION['pending_tfa_methods'] = $authenticators['additional']; + $_SESSION['return'][] = array( + 'type' => 'info', + 'log' => array(__FUNCTION__, $user, '*'), + 'msg' => 'awaiting_tfa_confirmation' + ); + return false; + } + } + + # set new password + $password_hashed = hash_password($new_password); + $stmt = $pdo->prepare("UPDATE `mailbox` SET + `password` = :password_hashed, + `attributes` = JSON_SET(`attributes`, '$.passwd_update', NOW()) + WHERE `username` = :username"); + $stmt->execute(array( + ':password_hashed' => $password_hashed, + ':username' => $username + )); + + // perform cleanup + $stmt = $pdo->prepare("DELETE FROM `reset_password` WHERE `username` = :username;"); + $stmt->execute(array( + ':username' => $username + )); + + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'password_changed_success' + ); + return true; + break; + case 'get_notification': + $type = $data; + + try { + $settings['from'] = $redis->Get('PW_RESET_FROM'); + $settings['subject'] = $redis->Get('PW_RESET_SUBJ'); + $settings['html_tmpl'] = $redis->Get('PW_RESET_HTML'); + $settings['text_tmpl'] = $redis->Get('PW_RESET_TEXT'); + if (empty($settings['html_tmpl']) && empty($settings['text_tmpl'])) { + $settings['html_tmpl'] = file_get_contents("/tpls/pw_reset_html.tpl"); + $settings['text_tmpl'] = file_get_contents("/tpls/pw_reset_text.tpl"); + } + + if ($type != "raw") { + $settings['html_tmpl'] = htmlspecialchars($settings['html_tmpl']); + $settings['text_tmpl'] = htmlspecialchars($settings['text_tmpl']); + } + } + catch (RedisException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('redis_error', $e) + ); + return false; + } + + return $settings; + break; + case 'send_mail': + $from = $data['from']; + $to = $data['to']; + $text = $data['text']; + $html = $data['html']; + $subject = $data['subject']; + + if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'from_invalid' + ); + return false; + } + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'to_invalid' + ); + return false; + } + if (empty($subject)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'subject_empty' + ); + return false; + } + if (empty($text)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'text_empty' + ); + return false; + } + + ini_set('max_execution_time', 0); + ini_set('max_input_time', 0); + $mail = new PHPMailer; + $mail->Timeout = 10; + $mail->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + ); + $mail->isSMTP(); + $mail->Host = 'postfix-mailcow'; + $mail->SMTPAuth = false; + $mail->Port = 25; + $mail->setFrom($from); + $mail->Subject = $subject; + $mail->CharSet ="UTF-8"; + if (!empty($html)) { + $mail->Body = $html; + $mail->AltBody = $text; + } + else { + $mail->Body = $text; + } + $mail->XMailer = 'MooMail'; + $mail->AddAddress($to); + if (!$mail->send()) { + return false; + } + $mail->ClearAllRecipients(); + + return true; + break; + } + + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + + switch ($action) { + case 'edit_notification': + $subject = $data['subject']; + $from = preg_replace('/[\x00-\x1F\x80-\xFF]/', '', $data['from']); + + if (filter_var($from, FILTER_VALIDATE_EMAIL) === false) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => '???' + ); + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + + $text = (empty($data['text_tmpl'])) ? "" : $data['text_tmpl']; + $html = (empty($data['html_tmpl'])) ? "" : $data['html_tmpl']; + if (empty($text) && empty($html)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + + try { + $redis->Set('PW_RESET_FROM', $from); + $redis->Set('PW_RESET_SUBJ', $subject); + $redis->Set('PW_RESET_HTML', $html); + $redis->Set('PW_RESET_TEXT', $text); + } + catch (RedisException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('redis_error', $e) + ); + return false; + } + + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'saved_settings' + ); + break; + } +} + 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 00c11deec..7c9414f0a 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -2865,21 +2865,22 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $_data['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0; } if (!empty($is_now)) { - $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; + $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; (int)$force_pw_update = (isset($_data['force_pw_update'])) ? intval($_data['force_pw_update']) : intval($is_now['attributes']['force_pw_update']); - (int)$sogo_access = (isset($_data['sogo_access']) && isset($_SESSION['acl']['sogo_access']) && $_SESSION['acl']['sogo_access'] == "1") ? intval($_data['sogo_access']) : intval($is_now['attributes']['sogo_access']); - (int)$imap_access = (isset($_data['imap_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['imap_access']) : intval($is_now['attributes']['imap_access']); - (int)$pop3_access = (isset($_data['pop3_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['pop3_access']) : intval($is_now['attributes']['pop3_access']); - (int)$smtp_access = (isset($_data['smtp_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['smtp_access']) : intval($is_now['attributes']['smtp_access']); - (int)$sieve_access = (isset($_data['sieve_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['sieve_access']) : intval($is_now['attributes']['sieve_access']); - (int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['mailbox_relayhost']) && $_SESSION['acl']['mailbox_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']); - (int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576); - $name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name']; - $domain = $is_now['domain']; - $quota_b = $quota_m * 1048576; - $password = (!empty($_data['password'])) ? $_data['password'] : null; - $password2 = (!empty($_data['password2'])) ? $_data['password2'] : null; - $tags = (is_array($_data['tags']) ? $_data['tags'] : array()); + (int)$sogo_access = (isset($_data['sogo_access']) && isset($_SESSION['acl']['sogo_access']) && $_SESSION['acl']['sogo_access'] == "1") ? intval($_data['sogo_access']) : intval($is_now['attributes']['sogo_access']); + (int)$imap_access = (isset($_data['imap_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['imap_access']) : intval($is_now['attributes']['imap_access']); + (int)$pop3_access = (isset($_data['pop3_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['pop3_access']) : intval($is_now['attributes']['pop3_access']); + (int)$smtp_access = (isset($_data['smtp_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['smtp_access']) : intval($is_now['attributes']['smtp_access']); + (int)$sieve_access = (isset($_data['sieve_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['sieve_access']) : intval($is_now['attributes']['sieve_access']); + (int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['mailbox_relayhost']) && $_SESSION['acl']['mailbox_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']); + (int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576); + $name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name']; + $domain = $is_now['domain']; + $quota_b = $quota_m * 1048576; + $password = (!empty($_data['password'])) ? $_data['password'] : null; + $password2 = (!empty($_data['password2'])) ? $_data['password2'] : null; + $pw_recovery_email = (isset($_data['pw_recovery_email'])) ? $_data['pw_recovery_email'] : $is_now['attributes']['recovery_email']; + $tags = (is_array($_data['tags']) ? $_data['tags'] : array()); } else { $_SESSION['return'][] = array( @@ -3132,31 +3133,43 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':address' => $username, ':active' => $active )); - $stmt = $pdo->prepare("UPDATE `mailbox` SET - `active` = :active, - `name`= :name, - `quota` = :quota_b, - `attributes` = JSON_SET(`attributes`, '$.force_pw_update', :force_pw_update), - `attributes` = JSON_SET(`attributes`, '$.sogo_access', :sogo_access), - `attributes` = JSON_SET(`attributes`, '$.imap_access', :imap_access), - `attributes` = JSON_SET(`attributes`, '$.sieve_access', :sieve_access), - `attributes` = JSON_SET(`attributes`, '$.pop3_access', :pop3_access), - `attributes` = JSON_SET(`attributes`, '$.relayhost', :relayhost), - `attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access) - WHERE `username` = :username"); - $stmt->execute(array( - ':active' => $active, - ':name' => $name, - ':quota_b' => $quota_b, - ':force_pw_update' => $force_pw_update, - ':sogo_access' => $sogo_access, - ':imap_access' => $imap_access, - ':pop3_access' => $pop3_access, - ':sieve_access' => $sieve_access, - ':smtp_access' => $smtp_access, - ':relayhost' => $relayhost, - ':username' => $username - )); + try { + $stmt = $pdo->prepare("UPDATE `mailbox` SET + `active` = :active, + `name`= :name, + `quota` = :quota_b, + `attributes` = JSON_SET(`attributes`, '$.force_pw_update', :force_pw_update), + `attributes` = JSON_SET(`attributes`, '$.sogo_access', :sogo_access), + `attributes` = JSON_SET(`attributes`, '$.imap_access', :imap_access), + `attributes` = JSON_SET(`attributes`, '$.sieve_access', :sieve_access), + `attributes` = JSON_SET(`attributes`, '$.pop3_access', :pop3_access), + `attributes` = JSON_SET(`attributes`, '$.relayhost', :relayhost), + `attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access), + `attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email) + WHERE `username` = :username"); + $stmt->execute(array( + ':active' => $active, + ':name' => $name, + ':quota_b' => $quota_b, + ':force_pw_update' => $force_pw_update, + ':sogo_access' => $sogo_access, + ':imap_access' => $imap_access, + ':pop3_access' => $pop3_access, + ':sieve_access' => $sieve_access, + ':smtp_access' => $smtp_access, + ':recovery_email' => $pw_recovery_email, + ':relayhost' => $relayhost, + ':username' => $username + )); + } + 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; diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index f62858b3f..8c4951d57 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 = "26022024_1433"; + $db_version = "29072024_1000"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -483,6 +483,7 @@ function init_db_schema() { "quarantine_notification" => "TINYINT(1) NOT NULL DEFAULT '1'", "quarantine_category" => "TINYINT(1) NOT NULL DEFAULT '1'", "app_passwds" => "TINYINT(1) NOT NULL DEFAULT '1'", + "pw_reset" => "TINYINT(1) NOT NULL DEFAULT '1'", ), "keys" => array( "primary" => array( @@ -694,6 +695,19 @@ function init_db_schema() { ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "reset_password" => array( + "cols" => array( + "username" => "VARCHAR(255) NOT NULL", + "token" => "VARCHAR(255) NOT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + ), + "keys" => array( + "primary" => array( + "" => array("token", "created") + ), + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "imapsync" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", diff --git a/data/web/inc/triggers.inc.php b/data/web/inc/triggers.inc.php index 6922429b8..5c625e414 100644 --- a/data/web/inc/triggers.inc.php +++ b/data/web/inc/triggers.inc.php @@ -10,16 +10,54 @@ if (!empty($_GET['sso_token'])) { } } +if (isset($_POST["pw_reset_request"]) && !empty($_POST['username'])) { + reset_password("issue", $_POST['username']); + header("Location: /"); + exit; +} +if (isset($_POST["pw_reset"])) { + $username = reset_password("check", $_POST['token']); + $reset_result = reset_password("reset", array( + 'new_password' => $_POST['new_password'], + 'new_password2' => $_POST['new_password2'], + 'token' => $_POST['token'], + 'username' => $username, + 'check_tfa' => True + )); + + if ($reset_result){ + header("Location: /"); + exit; + } +} if (isset($_POST["verify_tfa_login"])) { if (verify_tfa_login($_SESSION['pending_mailcow_cc_username'], $_POST)) { - $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username']; - $_SESSION['mailcow_cc_role'] = $_SESSION['pending_mailcow_cc_role']; - unset($_SESSION['pending_mailcow_cc_username']); - unset($_SESSION['pending_mailcow_cc_role']); - unset($_SESSION['pending_tfa_methods']); + if (isset($_SESSION['pending_mailcow_cc_username']) && isset($_SESSION['pending_pw_reset_token']) && isset($_SESSION['pending_pw_new_password'])) { + reset_password("reset", array( + 'new_password' => $_SESSION['pending_pw_new_password'], + 'new_password2' => $_SESSION['pending_pw_new_password'], + 'token' => $_SESSION['pending_pw_reset_token'], + 'username' => $_SESSION['pending_mailcow_cc_username'] + )); + unset($_SESSION['pending_pw_reset_token']); + unset($_SESSION['pending_pw_new_password']); + unset($_SESSION['pending_mailcow_cc_username']); + unset($_SESSION['pending_tfa_methods']); - header("Location: /user"); + header("Location: /"); + exit; + } else { + $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username']; + $_SESSION['mailcow_cc_role'] = $_SESSION['pending_mailcow_cc_role']; + unset($_SESSION['pending_mailcow_cc_username']); + unset($_SESSION['pending_mailcow_cc_role']); + unset($_SESSION['pending_tfa_methods']); + + header("Location: /user"); + } } else { + unset($_SESSION['pending_pw_reset_token']); + unset($_SESSION['pending_pw_new_password']); unset($_SESSION['pending_mailcow_cc_username']); unset($_SESSION['pending_mailcow_cc_role']); unset($_SESSION['pending_tfa_methods']); @@ -27,11 +65,13 @@ if (isset($_POST["verify_tfa_login"])) { } if (isset($_GET["cancel_tfa_login"])) { - unset($_SESSION['pending_mailcow_cc_username']); - unset($_SESSION['pending_mailcow_cc_role']); - unset($_SESSION['pending_tfa_methods']); + unset($_SESSION['pending_pw_reset_token']); + unset($_SESSION['pending_pw_new_password']); + unset($_SESSION['pending_mailcow_cc_username']); + unset($_SESSION['pending_mailcow_cc_role']); + unset($_SESSION['pending_tfa_methods']); - header("Location: /"); + header("Location: /"); } if (isset($_POST["quick_release"])) { diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 830b21805..d3165b8af 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -210,6 +210,12 @@ $MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format'] = 'maildir:'; // Show last IMAP and POP3 logins $SHOW_LAST_LOGIN = true; +// Maximum number of password reset tokens that can be generated at once per user +$PW_RESET_TOKEN_LIMIT = 3; + +// Maximum time in minutes a password reset token is valid +$PW_RESET_TOKEN_LIFETIME = 15; + // UV flag handling in FIDO2/WebAuthn - defaults to false to allow iOS logins // true = required // false = preferred diff --git a/data/web/json_api.php b/data/web/json_api.php index 9e165b68e..e14dd9962 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -1973,7 +1973,6 @@ if (isset($_GET['query'])) { case "quota_notification_bcc": process_edit_return(quota_notification_bcc('edit', $attr)); break; - break; case "mailq": process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr))); break; @@ -2069,6 +2068,9 @@ if (isset($_GET['query'])) { case "cors": process_edit_return(cors('edit', $attr)); break; + case "reset-password-notification": + process_edit_return(reset_password('edit_notification', $attr)); + break; // return no route found if no case is matched default: http_response_code(404); diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 2298e1cbd..a734e09cb 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -14,6 +14,7 @@ "prohibited": "Untersagt durch Richtlinie", "protocol_access": "Ändern der erlaubten Protokolle", "pushover": "Pushover", + "pw_reset": "Verwalten der E-Mail zur Passwortwiederherstellung erlauben", "quarantine": "Quarantäne-Aktionen", "quarantine_attachments": "Anhänge aus Quarantäne", "quarantine_category": "Ändern der Quarantäne-Benachrichtigungskategorie", @@ -248,6 +249,11 @@ "password_policy_numbers": "Muss eine Ziffer enthalten", "password_policy_special_chars": "Muss Sonderzeichen enthalten", "password_repeat": "Passwort wiederholen", + "password_reset_info": "Wenn keine E-Mail zur Passwortwiederherstellung hinterlegt ist, kann diese Funktion nicht genutzt werden.", + "password_reset_settings": "Einstellungen zur Passwortwiederherstellung", + "password_reset_tmpl_html": "HTML Vorlage", + "password_reset_tmpl_text": "Text Vorlage", + "password_settings": "Passwort Einstellungen", "priority": "Gewichtung", "private_key": "Private Key", "quarantine": "Quarantäne", @@ -287,6 +293,8 @@ "remove_row": "Entfernen", "reset_default": "Zurücksetzen auf Standard", "reset_limit": "Hash entfernen", + "reset_password_vars": "{{link}} Der generierte Passwort-Reset-Link
{{username}} Die E-Mail-Adresse des Benutzers, der die Passwortzurücksetzung angefordert hat
{{username2}} Die E-Mail-Adresse zur Wiederherstellung
{{date}} Das Datum, an dem die Passwort-Reset-Anfrage gestellt wurde
{{token_lifetime}} Die Gültigkeitsdauer des Tokens in Minuten
{{hostname}} Der mailcow Hostname", + "restore_template": "Leer lassen, um Standard-Template wiederherzustellen.", "routing": "Routing", "rsetting_add_rule": "Regel hinzufügen", "rsetting_content": "Regelinhalt", @@ -407,6 +415,7 @@ "invalid_nexthop_authenticated": "Dieser Next Hop existiert bereits mit abweichenden Authentifizierungsdaten. Die bestehenden Authentifizierungsdaten dieses \"Next Hops\" müssen vorab angepasst werden.", "invalid_recipient_map_new": "Neuer Empfänger \"%s\" ist ungültig", "invalid_recipient_map_old": "Originaler Empfänger \"%s\" ist ungültig", + "invalid_reset_token": "Ungültiger Rücksetz-Token", "ip_list_empty": "Liste erlaubter IPs darf nicht leer sein", "is_alias": "%s lautet bereits eine Alias-Adresse", "is_alias_or_mailbox": "Eine Mailbox, ein Alias oder eine sich aus einer Alias-Domain ergebende Adresse mit dem Namen %s ist bereits vorhanden", @@ -436,6 +445,7 @@ "password_complexity": "Passwort entspricht nicht den Richtlinien", "password_empty": "Passwort darf nicht leer sein", "password_mismatch": "Passwort-Wiederholung stimmt nicht überein", + "password_reset_invalid_user": "Benutzer nicht gefunden oder keine E-Mail-Adresse zur Wiederherstellung eingerichtet", "policy_list_from_exists": "Ein Eintrag mit diesem Wert existiert bereits", "policy_list_from_invalid": "Eintrag hat ein ungültiges Format", "private_key_error": "Schlüsselfehler: %s", @@ -444,10 +454,12 @@ "pushover_token": "Pushover Token hat das falsche Format", "quota_not_0_not_numeric": "Speicherplatz muss numerisch und >= 0 sein", "recipient_map_entry_exists": "Eine Empfängerumschreibung für Objekt \"%s\" existiert bereits", + "recovery_email_failed": "E-Mail zur Wiederherstellung konnte nicht gesendet werden. Bitte wenden Sie sich an Ihren Administrator.", "redis_error": "Redis Fehler: %s", "relayhost_invalid": "Map-Eintrag %s ist ungültig", "release_send_failed": "Die Nachricht konnte nicht versendet werden: %s", "reset_f2b_regex": "Regex-Filter konnten nicht in vorgegebener Zeit zurückgesetzt werden, bitte erneut versuchen oder die Webseite neu laden.", + "reset_token_limit_exceeded": "Das Limit für Rücksetz-Tokens wurde überschritten. Bitte versuchen Sie es später erneut.", "resource_invalid": "Ressourcenname %s ist ungültig", "rl_timeframe": "Ratelimit-Zeitraum ist inkorrekt", "rspamd_ui_pw_length": "Rspamd UI-Passwort muss mindestens 6 Zeichen lang sein", @@ -467,6 +479,7 @@ "tls_policy_map_dest_invalid": "Ziel ist ungültig", "tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits", "tls_policy_map_parameter_invalid": "Parameter ist ungültig", + "to_invalid": "Empfänger darf nicht leer sein", "totp_verification_failed": "TOTP-Verifizierung fehlgeschlagen", "transport_dest_exists": "Transport-Maps-Ziel \"%s\" existiert bereits", "webauthn_verification_failed": "WebAuthn-Verifizierung fehlgeschlagen: %s", @@ -638,6 +651,7 @@ "nexthop": "Next Hop", "none_inherit": "Keine Auswahl / Erben", "password": "Passwort", + "password_recovery_email": "E-Mail zur Passwortwiederherstellung", "password_repeat": "Passwort wiederholen", "previous": "Vorherige Seite", "private_comment": "Privater Kommentar", @@ -741,12 +755,19 @@ "session_expires": "Die Sitzung wird in etwa 15 Sekunden beendet." }, "login": { + "back_to_mailcow": "Zurück zu mailcow", "delayed": "Login wurde zur Sicherheit um %s Sekunde/n verzögert.", "fido2_webauthn": "FIDO2/WebAuthn Login", + "forgot_password": "> Passwort vergessen?", + "invalid_pass_reset_token": "Der Rücksetz-Token für das Passwort ist ungültig oder abgelaufen.
Bitte fordern Sie einen neuen Link zur Passwortwiederherstellung an.", "login": "Anmelden", "mobileconfig_info": "Bitte als Mailbox-Benutzer einloggen, um das Verbindungsprofil herunterzuladen.", + "new_password": "Neues Passwort", + "new_password_confirm": "Neues Passwort bestätigen", "other_logins": "Key Login", "password": "Passwort", + "reset_password": "Passwort zurücksetzen", + "request_reset_password": "Passwortänderung anfordern", "username": "Benutzername" }, "mailbox": { @@ -1065,11 +1086,13 @@ "nginx_reloaded": "Nginx wurde neu geladen", "object_modified": "Änderungen an Objekt %s wurden gespeichert", "password_policy_saved": "Passwortrichtlinie wurde erfolgreich gespeichert", + "password_changed_success": "Das Passwort wurde erfolgreich geändert", "pushover_settings_edited": "Pushover-Konfiguration gespeichert, bitte den Zugang im Anschluss verifizieren.", "qlearn_spam": "Nachricht-ID %s wurde als Spam gelernt und gelöscht", "queue_command_success": "Queue-Aufgabe erfolgreich ausgeführt", "recipient_map_entry_deleted": "Empfängerumschreibung mit der ID %s wurde gelöscht", "recipient_map_entry_saved": "Empfängerumschreibung für Objekt \"%s\" wurde gespeichert", + "recovery_email_sent": "Wiederherstellungs-E-Mail an %s gesendet", "relayhost_added": "Map-Eintrag %s wurde hinzugefügt", "relayhost_removed": "Map-Eintrag %s wurde entfernt", "reset_main_logo": "Standardgrafik wurde wiederhergestellt", @@ -1202,6 +1225,7 @@ "password": "Passwort", "password_now": "Aktuelles Passwort (Änderungen bestätigen)", "password_repeat": "Passwort (Wiederholung)", + "password_reset_info": "Wenn keine E-Mail zur Passwortwiederherstellung hinterlegt ist, kann diese Funktion nicht genutzt werden.", "pushover_evaluate_x_prio": "Hohe Priorität eskalieren [X-Priority: 1]", "pushover_info": "Push-Benachrichtungen werden angewendet auf alle nicht-Spam Nachrichten zugestellt an %s, einschließlich Alias-Adressen (shared, non-shared, tagged).", "pushover_only_x_prio": "Nur Mail mit hoher Priorität berücksichtigen [X-Priority: 1]", @@ -1211,6 +1235,7 @@ "pushover_title": "Notification Titel", "pushover_vars": "Wenn kein Sender-Filter definiert ist, werden alle E-Mails berücksichtigt.
Die direkte Absenderprüfung und reguläre Ausdrücke werden unabhängig voneinander geprüft, sie hängen nicht voneinander ab und werden der Reihe nach ausgeführt.
Verwendbare Variablen für Titel und Text (Datenschutzrichtlinien beachten)", "pushover_verify": "Verbindung verifizieren", + "pw_recovery_email": "E-Mail zur Passwortwiederherstellung", "q_add_header": "Junk-Ordner", "q_all": "Alle Kategorien", "q_reject": "Abgelehnt", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 964caade6..636c1aded 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -14,6 +14,7 @@ "prohibited": "Prohibited by ACL", "protocol_access": "Change protocol access", "pushover": "Pushover", + "pw_reset": "Allow to reset mailcow user password", "quarantine": "Quarantine actions", "quarantine_attachments": "Quarantine attachments", "quarantine_category": "Change quarantine notification category", @@ -256,6 +257,11 @@ "password_policy_numbers": "Must contain at least one number", "password_policy_special_chars": "Must contain special characters", "password_repeat": "Confirmation password (repeat)", + "password_reset_info": "If no recovery email is provided, this function cannot be used.", + "password_reset_settings": "Password Recovery Settings", + "password_reset_tmpl_html": "HTML Template", + "password_reset_tmpl_text": "Text Template", + "password_settings": "Password Settings", "priority": "Priority", "private_key": "Private key", "quarantine": "Quarantine", @@ -296,6 +302,8 @@ "remove_row": "Remove row", "reset_default": "Reset to default", "reset_limit": "Remove hash", + "reset_password_vars": "{{link}} The generated password reset link
{{username}} The mailbox name of the user who requested the password reset
{{username2}} The recovery mailbox name
{{date}} The date the password reset request was made
{{token_lifetime}} The token lifetime in minutes
{{hostname}} The mailcow hostname", + "restore_template": "Leave empty to restore default template.", "routing": "Routing", "rsetting_add_rule": "Add rule", "rsetting_content": "Rule content", @@ -407,6 +415,7 @@ "invalid_nexthop_authenticated": "Next hop exists with different credentials, please update the existing credentials for this next hop first.", "invalid_recipient_map_new": "Invalid new recipient specified: %s", "invalid_recipient_map_old": "Invalid original recipient specified: %s", + "invalid_reset_token": "Invalid reset token", "ip_list_empty": "List of allowed IPs cannot be empty", "is_alias": "%s is already known as an alias address", "is_alias_or_mailbox": "%s is already known as an alias, a mailbox or an alias address expanded from an alias domain.", @@ -436,6 +445,7 @@ "password_complexity": "Password does not meet the policy", "password_empty": "Password must not be empty", "password_mismatch": "Confirmation password does not match", + "password_reset_invalid_user": "Mailbox not found or no recovery email is set", "policy_list_from_exists": "A record with given name exists", "policy_list_from_invalid": "Record has invalid format", "private_key_error": "Private key error: %s", @@ -444,10 +454,12 @@ "pushover_token": "Pushover token has a wrong format", "quota_not_0_not_numeric": "Quota must be numeric and >= 0", "recipient_map_entry_exists": "A Recipient map entry \"%s\" exists", + "recovery_email_failed": "Could not send a recovery email. Please contact your administrator.", "redis_error": "Redis error: %s", "relayhost_invalid": "Map entry %s is invalid", "release_send_failed": "Message could not be released: %s", "reset_f2b_regex": "Regex filter could not be reset in time, please try again or wait a few more seconds and reload the website.", + "reset_token_limit_exceeded": "Reset token limit has been exceeded. Please try again later.", "resource_invalid": "Resource name %s is invalid", "rl_timeframe": "Rate limit time frame is incorrect", "rspamd_ui_pw_length": "Rspamd UI password should be at least 6 chars long", @@ -470,6 +482,7 @@ "tls_policy_map_dest_invalid": "Policy destination is invalid", "tls_policy_map_entry_exists": "A TLS policy map entry \"%s\" exists", "tls_policy_map_parameter_invalid": "Policy parameter is invalid", + "to_invalid": "Recipient must not be empty", "totp_verification_failed": "TOTP verification failed", "transport_dest_exists": "Transport destination \"%s\" exists", "webauthn_verification_failed": "WebAuthn verification failed: %s", @@ -638,6 +651,7 @@ "none_inherit": "None / Inherit", "nexthop": "Next hop", "password": "Password", + "password_recovery_email": "Password recovery email", "password_repeat": "Confirmation password (repeat)", "previous": "Previous page", "private_comment": "Private comment", @@ -741,12 +755,19 @@ "session_expires": "Your session will expire in about 15 seconds" }, "login": { + "back_to_mailcow": "Back to mailcow", "delayed": "Login was delayed by %s seconds.", "fido2_webauthn": "FIDO2/WebAuthn Login", + "forgot_password": "> Forgot Password?", + "invalid_pass_reset_token": "The reset password token is invalid or has expired.
Please request a new password reset link.", "login": "Login", "mobileconfig_info": "Please login as mailbox user to download the requested Apple connection profile.", + "new_password": "New Password", + "new_password_confirm": "Confirm new password", "other_logins": "Key login", "password": "Password", + "reset_password": "Reset Password", + "request_reset_password": "Request password change", "username": "Username" }, "mailbox": { @@ -1072,11 +1093,13 @@ "nginx_reloaded": "Nginx was reloaded", "object_modified": "Changes to object %s have been saved", "password_policy_saved": "Password policy was saved successfully", + "password_changed_success": "Password was successfully changed", "pushover_settings_edited": "Pushover settings successfully set, please verify credentials.", "qlearn_spam": "Message ID %s was learned as spam and deleted", "queue_command_success": "Queue command completed successfully", "recipient_map_entry_deleted": "Recipient map ID %s has been deleted", "recipient_map_entry_saved": "Recipient map entry \"%s\" has been saved", + "recovery_email_sent": "Recovery email sent to %s", "relayhost_added": "Map entry %s has been added", "relayhost_removed": "Map entry %s has been removed", "reset_main_logo": "Reset to default logo", @@ -1210,6 +1233,7 @@ "password": "Password", "password_now": "Current password (confirm changes)", "password_repeat": "Password (repeat)", + "password_reset_info": "If no email for password recovery is provided, this function cannot be used.", "pushover_evaluate_x_prio": "Escalate high priority mail [X-Priority: 1]", "pushover_info": "Push notification settings will apply to all clean (non-spam) mail delivered to %s including aliases (shared, non-shared, tagged).", "pushover_only_x_prio": "Only consider high priority mail [X-Priority: 1]", @@ -1220,6 +1244,7 @@ "pushover_sound": "Sound", "pushover_vars": "When no sender filter is defined, all mails will be considered.
Regex filters as well as exact sender checks can be defined individually and will be considered sequentially. They do not depend on each other.
Useable variables for text and title (please take note of data protection policies)", "pushover_verify": "Verify credentials", + "pw_recovery_email": "Password recovery email", "q_add_header": "Junk folder", "q_all": "All categories", "q_reject": "Rejected", diff --git a/data/web/reset-password.php b/data/web/reset-password.php new file mode 100644 index 000000000..a0225dc6a --- /dev/null +++ b/data/web/reset-password.php @@ -0,0 +1,31 @@ + str_contains($_SESSION['index_query_string'], 'mobileconfig'), + 'is_reset_token_valid' => $is_reset_token_valid, + 'reset_token' => $_GET['token'] +]; + +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; diff --git a/data/web/templates/admin.twig b/data/web/templates/admin.twig index 33f2422b5..0d238eee7 100644 --- a/data/web/templates/admin.twig +++ b/data/web/templates/admin.twig @@ -22,7 +22,7 @@
  • -
  • +
  • @@ -51,7 +51,7 @@ {% include 'admin/tab-config-quota.twig' %} {% include 'admin/tab-config-rsettings.twig' %} {% include 'admin/tab-config-customize.twig' %} - {% include 'admin/tab-config-password-policy.twig' %} + {% include 'admin/tab-config-password-settings.twig' %} {% include 'admin/tab-sys-mails.twig' %} {% include 'admin/tab-globalfilter-regex.twig' %} diff --git a/data/web/templates/admin/tab-config-password-policy.twig b/data/web/templates/admin/tab-config-password-policy.twig deleted file mode 100644 index 8209ba542..000000000 --- a/data/web/templates/admin/tab-config-password-policy.twig +++ /dev/null @@ -1,40 +0,0 @@ -
    -
    -
    - - {{ lang.admin.password_policy }} -
    -
    -
    - {% for name, value in password_complexity %} - {% if name == 'length' %} -
    - -
    - -
    -
    - {% else %} - -
    -
    - -
    -
    - {% endif %} - {% endfor %} -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    diff --git a/data/web/templates/admin/tab-config-password-settings.twig b/data/web/templates/admin/tab-config-password-settings.twig new file mode 100644 index 000000000..6b2c494c2 --- /dev/null +++ b/data/web/templates/admin/tab-config-password-settings.twig @@ -0,0 +1,102 @@ +
    +
    +
    + + {{ lang.admin.password_settings }} +
    +
    +
    +
    +
    + + {{ lang.admin.password_policy }} + +
    +
    +
    + {% for name, value in password_complexity %} + {% if name == 'length' %} +
    + +
    + +
    +
    + {% else %} + +
    +
    + +
    +
    + {% endif %} + {% endfor %} +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + + {{ lang.admin.password_reset_settings }} + +
    + {{ lang.admin.reset_password_vars|raw }}

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + {{ lang.admin.password_reset_tmpl_text }} + {{ lang.admin.restore_template }} +
    +
    + +
    +
    + {{ lang.admin.password_reset_tmpl_html }} + {{ lang.admin.restore_template }} +
    +
    + +
    +
    + +
    +
    +
    +
    diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index 8960ee938..8de0095f2 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -203,6 +203,13 @@ +
    + +
    + + {{ lang.admin.password_reset_info }} +
    +
    diff --git a/data/web/templates/index.twig b/data/web/templates/index.twig index aa282547c..90e232caf 100644 --- a/data/web/templates/index.twig +++ b/data/web/templates/index.twig @@ -63,6 +63,9 @@ {% endif %}
    + {% if login_delay %}

    {{ lang.login.delayed|format(login_delay) }}

    {% endif %} diff --git a/data/web/templates/modals/user.twig b/data/web/templates/modals/user.twig index b4188773c..c9cd4b97e 100644 --- a/data/web/templates/modals/user.twig +++ b/data/web/templates/modals/user.twig @@ -309,6 +309,33 @@
    + +