Merge pull request #6009 from mailcow/feat/pw-reset

[Web] Add a forgot password flow
This commit is contained in:
FreddleSpl0it 2024-08-15 11:06:30 +02:00 committed by GitHub
commit cb9ca772b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 888 additions and 111 deletions

View File

@ -0,0 +1,29 @@
<html>
<head>
<meta name="x-apple-disable-message-reformatting" />
<style>
body {
font-family: Helvetica, Arial, Sans-Serif;
}
/* mobile devices */
@media all and (max-width: 480px) {
.mob {
display: none;
}
}
</style>
</head>
<body>
Hello {{username2}},<br><br>
Somebody requested a new password for the {{hostname}} account associated with {{username}}.<br>
<small>Date of the password reset request: {{date}}</small><br><br>
You can reset your password by clicking the link below:<br>
<a href="{{link}}">{{link}}</a><br><br>
The link will be valid for the next {{token_lifetime}} minutes.<br><br>
If you did not request a new password, please ignore this email.<br>
</body>
</html>

View File

@ -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.

View File

@ -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,

View File

@ -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,386 @@ 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;
}
$pw_reset_notification = reset_password('get_notification', 'raw');
if (!$pw_reset_notification) return false;
if (empty($pw_reset_notification['from']) || empty($pw_reset_notification['subject'])) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'password_reset_na'
);
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
));
$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']);
$from = (!filter_var($from, FILTER_VALIDATE_EMAIL)) ? "" : $from;
$subject = (empty($subject)) ? "" : $subject;
$text = (empty($data['text_tmpl'])) ? "" : $data['text_tmpl'];
$html = (empty($data['html_tmpl'])) ? "" : $data['html_tmpl'];
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) {

View File

@ -184,6 +184,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
'msg' => 'global_filter_written'
);
return true;
break;
case 'filter':
$sieve = new Sieve\SieveParser();
if (!isset($_SESSION['acl']['filters']) || $_SESSION['acl']['filters'] != "1" ) {
@ -1249,6 +1250,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$_data['quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
$_data['quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
$_data['app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
$_data['pw_reset'] = (in_array('pw_reset', $_data['acl'])) ? 1 : 0;
} else {
$_data['spam_alias'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_spam_alias']);
$_data['tls_policy'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_tls_policy']);
@ -1264,14 +1266,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$_data['quarantine_notification'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_notification']);
$_data['quarantine_category'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_category']);
$_data['app_passwds'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_app_passwds']);
$_data['pw_reset'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_pw_reset']);
}
try {
$stmt = $pdo->prepare("INSERT INTO `user_acl`
(`username`, `spam_alias`, `tls_policy`, `spam_score`, `spam_policy`, `delimiter_action`, `syncjobs`, `eas_reset`, `sogo_profile_reset`,
`pushover`, `quarantine`, `quarantine_attachments`, `quarantine_notification`, `quarantine_category`, `app_passwds`)
`pushover`, `quarantine`, `quarantine_attachments`, `quarantine_notification`, `quarantine_category`, `app_passwds`, `pw_reset`)
VALUES (:username, :spam_alias, :tls_policy, :spam_score, :spam_policy, :delimiter_action, :syncjobs, :eas_reset, :sogo_profile_reset,
:pushover, :quarantine, :quarantine_attachments, :quarantine_notification, :quarantine_category, :app_passwds) ");
:pushover, :quarantine, :quarantine_attachments, :quarantine_notification, :quarantine_category, :app_passwds, :pw_reset) ");
$stmt->execute(array(
':username' => $username,
':spam_alias' => $_data['spam_alias'],
@ -1287,7 +1290,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':quarantine_attachments' => $_data['quarantine_attachments'],
':quarantine_notification' => $_data['quarantine_notification'],
':quarantine_category' => $_data['quarantine_category'],
':app_passwds' => $_data['app_passwds']
':app_passwds' => $_data['app_passwds'],
':pw_reset' => $_data['pw_reset']
));
}
catch (PDOException $e) {
@ -1576,6 +1580,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr['acl_quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
$attr['acl_quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
$attr['acl_app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
$attr['acl_pw_reset'] = (in_array('pw_reset', $_data['acl'])) ? 1 : 0;
} else {
$_data['acl'] = (array)$_data['acl'];
$attr['acl_spam_alias'] = 0;
@ -2865,21 +2870,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 +3138,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;
@ -3263,6 +3281,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr['acl_quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
$attr['acl_quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
$attr['acl_app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
$attr['acl_pw_reset'] = (in_array('pw_reset', $_data['acl'])) ? 1 : 0;
} else {
foreach ($is_now as $key => $value){
$attr[$key] = $is_now[$key];

View File

@ -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",

View File

@ -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"])) {

View File

@ -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

View File

@ -380,6 +380,9 @@ $(document).ready(function() {
if (template.acl_app_passwds == 1){
acl.push("app_passwds");
}
if (template.acl_pw_reset == 1){
acl.push("pw_reset");
}
$('#user_acl').selectpicker('val', acl);
$('#rl_value').val(template.rl_value);

View File

@ -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);

View File

@ -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": "<code>{{link}}</code> Der generierte Passwort-Reset-Link<br><code>{{username}}</code> Die E-Mail-Adresse des Benutzers, der die Passwortzurücksetzung angefordert hat<br><code>{{username2}}</code> Die E-Mail-Adresse zur Wiederherstellung<br><code>{{date}}</code> Das Datum, an dem die Passwort-Reset-Anfrage gestellt wurde<br><code>{{token_lifetime}}</code> Die Gültigkeitsdauer des Tokens in Minuten<br><code>{{hostname}}</code> 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,8 @@
"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",
"password_reset_na": "Die Passwortwiederherstellung ist momentan nicht verfügbar. Bitte wenden Sie sich an Ihren Administrator.",
"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 +455,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 +480,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 +652,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 +756,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.<br>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 +1087,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 +1226,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 [<code>X-Priority: 1</code>]",
"pushover_info": "Push-Benachrichtungen werden angewendet auf alle nicht-Spam Nachrichten zugestellt an <b>%s</b>, einschließlich Alias-Adressen (shared, non-shared, tagged).",
"pushover_only_x_prio": "Nur Mail mit hoher Priorität berücksichtigen [<code>X-Priority: 1</code>]",
@ -1211,6 +1236,7 @@
"pushover_title": "Notification Titel",
"pushover_vars": "Wenn kein Sender-Filter definiert ist, werden alle E-Mails berücksichtigt.<br>Die direkte Absenderprüfung und reguläre Ausdrücke werden unabhängig voneinander geprüft, sie <b>hängen nicht voneinander ab</b> und werden der Reihe nach ausgeführt. <br>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",

View File

@ -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": "<code>{{link}}</code> The generated password reset link<br><code>{{username}}</code> The mailbox name of the user who requested the password reset<br><code>{{username2}}</code> The recovery mailbox name<br><code>{{date}}</code> The date the password reset request was made<br><code>{{token_lifetime}}</code> The token lifetime in minutes<br><code>{{hostname}}</code> 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,8 @@
"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",
"password_reset_na": "The password recovery is currently unavailable. Please contact your administrator.",
"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 +455,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 +483,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 +652,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 +756,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.<br>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 +1094,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 +1234,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 [<code>X-Priority: 1</code>]",
"pushover_info": "Push notification settings will apply to all clean (non-spam) mail delivered to <b>%s</b> including aliases (shared, non-shared, tagged).",
"pushover_only_x_prio": "Only consider high priority mail [<code>X-Priority: 1</code>]",
@ -1220,6 +1245,7 @@
"pushover_sound": "Sound",
"pushover_vars": "When no sender filter is defined, all mails will be considered.<br>Regex filters as well as exact sender checks can be defined individually and will be considered sequentially. They do not depend on each other.<br>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",

View File

@ -0,0 +1,31 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
header('Location: /debug');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
$_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
if (isset($_GET['token'])) $is_reset_token_valid = reset_password("check", $_GET['token']);
else $is_reset_token_valid = False;
$template = 'reset-password.twig';
$template_data = [
'is_mobileconfig' => 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';

View File

@ -22,7 +22,7 @@
<li><button class="dropdown-item" data-bs-target="#tab-config-quarantine" aria-selected="false" aria-controls="tab-config-quarantine" role="tab" data-bs-toggle="tab">{{ lang.admin.quarantine }}</button></li>
<li><button class="dropdown-item" data-bs-target="#tab-config-quota" aria-selected="false" aria-controls="tab-config-quota" role="tab" data-bs-toggle="tab">{{ lang.admin.quota_notifications }}</button></li>
<li><button class="dropdown-item" data-bs-target="#tab-config-rsettings" aria-selected="false" aria-controls="tab-config-rsettings" role="tab" data-bs-toggle="tab">{{ lang.admin.rspamd_settings_map }}</button></li>
<li><button class="dropdown-item" data-bs-target="#tab-config-password-policy" aria-selected="false" aria-controls="tab-config-password-policy" role="tab" data-bs-toggle="tab">{{ lang.admin.password_policy }}</button></li>
<li><button class="dropdown-item" data-bs-target="#tab-config-password-settings" aria-selected="false" aria-controls="tab-config-password-settings" role="tab" data-bs-toggle="tab">{{ lang.admin.password_settings }}</button></li>
<li><button class="dropdown-item" data-bs-target="#tab-config-customize" aria-selected="false" aria-controls="tab-config-customize" role="tab" data-bs-toggle="tab">{{ lang.admin.customize }}</button></li>
</ul>
</li>
@ -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' %}
</div>

View File

@ -1,40 +0,0 @@
<div class="tab-pane fade" id="tab-config-password-policy" role="tabpanel" aria-labelledby="tab-config-password-policy">
<div class="card mb-4">
<div class="card-header d-flex fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-password-policy" data-bs-toggle="collapse" aria-controls="collapse-tab-config-password-policy">
{{ lang.admin.password_policy }}
</button>
<span class="d-none d-md-block">{{ lang.admin.password_policy }}</span>
</div>
<div id="collapse-tab-config-password-policy" class="card-body collapse" data-bs-parent="#admin-content">
<form class="form-horizontal" data-id="passwordpolicy" role="form" method="post">
{% for name, value in password_complexity %}
{% if name == 'length' %}
<div class="row mb-4">
<label class="control-label col-sm-3 text-sm-end" for="length">{{ lang.admin.password_length }}:</label>
<div class="col-sm-2">
<input type="number" class="form-control" min="3" max="64" name="length" id="length" value="{{ value }}" required>
</div>
</div>
{% else %}
<input type="hidden" name="{{ name }}" value="0">
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="{{ name }}" id="{{ name }}" value="1" {% if value == 1 %}checked{% endif %}> {{ lang.admin['password_policy_'~name] }}
</label>
</div>
</div>
{% endif %}
{% endfor %}
<div class="row mt-4 mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="btn-group">
<button class="btn btn-sm d-block d-sm-inline btn-success" data-item="passwordpolicy" data-action="edit_selected" data-id="passwordpolicy" data-api-url='edit/passwordpolicy' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,102 @@
<div class="tab-pane fade" id="tab-config-password-settings" role="tabpanel" aria-labelledby="tab-config-password-settings">
<div class="card mb-4">
<div class="card-header d-flex fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-password-settings" data-bs-toggle="collapse" aria-controls="collapse-tab-config-password-settings">
{{ lang.admin.password_settings }}
</button>
<span class="d-none d-md-block">{{ lang.admin.password_settings }}</span>
</div>
<div id="collapse-tab-config-password-settings" class="card-body collapse" data-bs-parent="#admin-content">
<form class="form-horizontal" data-id="passwordpolicy" role="form" method="post">
<div class="row">
<div class="col-sm-12">
<legend>
{{ lang.admin.password_policy }}
</legend>
<hr />
</div>
</div>
{% for name, value in password_complexity %}
{% if name == 'length' %}
<div class="row mb-4">
<label class="control-label col-sm-3 text-sm-end" for="length">{{ lang.admin.password_length }}:</label>
<div class="col-sm-2">
<input type="number" class="form-control" min="3" max="64" name="length" id="length" value="{{ value }}" required>
</div>
</div>
{% else %}
<input type="hidden" name="{{ name }}" value="0">
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="{{ name }}" id="{{ name }}" value="1" {% if value == 1 %}checked{% endif %}> {{ lang.admin['password_policy_'~name] }}
</label>
</div>
</div>
{% endif %}
{% endfor %}
<div class="row mt-4 mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="btn-group">
<button class="btn btn-sm d-block d-sm-inline btn-success" data-item="passwordpolicy" data-action="edit_selected" data-id="passwordpolicy" data-api-url='edit/passwordpolicy' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
</div>
</form>
<form class="form" role="form" data-id="pw_reset_notification" method="post" style="margin-top: 50px;">
<div class="row">
<div class="col-sm-12">
<legend>
{{ lang.admin.password_reset_settings }}
</legend>
<hr />
<small>{{ lang.admin.reset_password_vars|raw }}</small><br><br>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-6">
<div>
<label for="pw_reset_from">{{ lang.admin.quota_notification_sender }}:</label>
<input type="email" class="form-control" id="pw_reset_from" name="from" value="{{ pw_reset_data.from }}">
</div>
</div>
<div class="col-sm-6">
<div>
<label for="pw_reset_subject">{{ lang.admin.quota_notification_subject }}:</label>
<input type="text" class="form-control" id="pw_reset_subject" name="subject" value="{{ pw_reset_data.subject }}">
</div>
</div>
</div>
<div class="row">
<div class="col-12" data-bs-target="#text_template" style="cursor:pointer" unselectable="on" data-bs-toggle="collapse">
<span class="d-block"><i style="font-size:10pt;" class="bi bi-plus-square"></i> {{ lang.admin.password_reset_tmpl_text }}</span>
<small>{{ lang.admin.restore_template }}</small>
</div>
<div id="text_template" class="col-12 collapse">
<textarea autocorrect="off" spellcheck="false" autocapitalize="none" class="form-control textarea-code mb-2" rows="20" name="text_tmpl">{{ pw_reset_data.text_tmpl|raw }}</textarea>
</div>
<div class="col-12 mt-3" data-bs-target="#html_template" style="cursor:pointer" unselectable="on" data-bs-toggle="collapse">
<span class="d-block"><i style="font-size:10pt;" class="bi bi-plus-square"></i> {{ lang.admin.password_reset_tmpl_html }}</span>
<small>{{ lang.admin.restore_template }}</small>
</div>
<div id="html_template" class="col-12 collapse">
<textarea autocorrect="off" spellcheck="false" autocapitalize="none" class="form-control textarea-code" rows="20" name="html_tmpl">{{ pw_reset_data.html_tmpl|raw }}</textarea>
</div>
</div>
<div class="row">
<div class="col-sm-10">
<div>
<br>
<a type="button" class="btn btn-sm d-block d-sm-inline btn-success" data-action="edit_selected"
data-item="pw_reset_notification"
data-id="pw_reset_notification"
data-api-url='edit/reset-password-notification'
data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.user.save_changes }}</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -112,6 +112,7 @@
<option value="quarantine_notification" {% if template.attributes.acl_quarantine_notification == '1' %} selected{% endif %}>{{ lang.acl["quarantine_notification"] }}</option>
<option value="quarantine_category" {% if template.attributes.acl_quarantine_category == '1' %} selected{% endif %}>{{ lang.acl["quarantine_category"] }}</option>
<option value="app_passwds" {% if template.attributes.acl_app_passwds == '1' %} selected{% endif %}>{{ lang.acl["app_passwds"] }}</option>
<option value="pw_reset" {% if template.attributes.acl_pw_reset == '1' %} selected{% endif %}>{{ lang.acl["pw_reset"] }}</option>
</select>
</div>
</div>

View File

@ -203,6 +203,13 @@
<input type="password" data-pwgen-field="true" class="form-control" name="password2" autocomplete="new-password">
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="pw_recovery_email">{{ lang.edit.password_recovery_email }}</label>
<div class="col-sm-10">
<input type="email" class="form-control" name="pw_recovery_email" value="{{ result.attributes.recovery_email }}">
<small class="text-muted">{{ lang.admin.password_reset_info }}</small>
</div>
</div>
<div data-acl="{{ acl.extend_sender_acl }}" class="row mb-4">
<label class="control-label col-sm-2" for="extended_sender_acl">{{ lang.edit.extended_sender_acl }}</label>
<div class="col-sm-10">

View File

@ -63,6 +63,9 @@
{% endif %}
</div>
</form>
<div class="mt-3 mb-4">
<a href="/reset-password">{{ lang.login.forgot_password }}</a>
</div>
{% if login_delay %}
<p><div class="my-4 alert alert-info">{{ lang.login.delayed|format(login_delay) }}</b></div></p>
{% endif %}

View File

@ -149,6 +149,7 @@
<option value="quarantine_notification" selected>{{ lang.acl["quarantine_notification"] }}</option>
<option value="quarantine_category" selected>{{ lang.acl["quarantine_category"] }}</option>
<option value="app_passwds" selected>{{ lang.acl["app_passwds"] }}</option>
<option value="pw_reset" selected>{{ lang.acl["pw_reset"] }}</option>
</select>
</div>
</div>
@ -318,6 +319,7 @@
<option value="quarantine_notification" selected>{{ lang.acl["quarantine_notification"] }}</option>
<option value="quarantine_category" selected>{{ lang.acl["quarantine_category"] }}</option>
<option value="app_passwds" selected>{{ lang.acl["app_passwds"] }}</option>
<option value="pw_reset" selected>{{ lang.acl["pw_reset"] }}</option>
</select>
</div>
</div>

View File

@ -309,6 +309,33 @@
</div>
</div>
</div><!-- pw change modal -->
<!-- pw recovery email modal -->
<div class="modal fade" id="pwRecoveryEmailModal" tabindex="-1" role="dialog" aria-labelledby="pwRecoveryEmailModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ lang.user.pw_recovery_email }}</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form class="form-horizontal" data-cached-form="false" data-id="pw_recovery_change" role="form" method="post" autocomplete="off">
<div class="row mb-4">
<label class="control-label col-sm-3" for="pw_recovery_email">{{ lang.user.email }}</label>
<div class="col-sm-9">
<input type="email" class="form-control" name="pw_recovery_email" value="{{ mailboxdata.attributes.recovery_email }}">
<small class="text-muted">{{ lang.user.password_reset_info }}</small>
</div>
</div>
<div class="row">
<div class="offset-sm-3 col-sm-9">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="pw_recovery_change" data-item="null" data-api-url='edit/self' data-api-attr='{}' href="#">{{ lang.user.save }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div><!-- pw recovery email modal -->
<!-- temp alias modal -->
<div class="modal fade" id="tempAliasModal" tabindex="-1" role="dialog" aria-labelledby="tempAliasModalLabel">
<div class="modal-dialog modal-lg" role="document">

View File

@ -0,0 +1,57 @@
{% extends 'base.twig' %}
{% block navbar %}{% endblock %}
{% block content %}
<div class="row mb-4" style="margin-top: 60px">
<div class="col-12 col-md-7 col-lg-6 col-xl-5 ms-auto me-auto">
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="bi bi-person-fill me-2"></i> {{ lang.login.reset_password }}
<div class="ms-auto form-check form-switch my-auto d-flex align-items-center">
<label class="form-check-label"><i class="bi bi-moon-fill"></i></label>
<input class="form-check-input ms-2" type="checkbox" id="dark-mode-toggle">
</div>
</div>
<div class="card-body">
<div class="text-center mailcow-logo mb-4">
<img class="main-logo" src="{{ logo|default('/img/cow_mailcow.svg') }}" alt="mailcow">
<img class="main-logo-dark" src="{{ logo_dark|default('/img/cow_mailcow.svg') }}" alt="mailcow-logo-dark">
</div>
<legend>{{ ui_texts.main_name|raw }}</legend><hr />
{% if is_reset_token_valid %}
<form method="post" autofill="off">
<input type="hidden" name="token" value="{{ reset_token }}" />
<input type="password" autocorrect="off" autocapitalize="none" class="form-control mb-2" name="new_password" placeholder="{{ lang.login.new_password }}" />
<input type="password" autocorrect="off" autocapitalize="none" class="form-control mb-2" name="new_password2" placeholder="{{ lang.login.new_password_confirm }}" />
<small id="mismatch_alert" class="text-danger d-none">{{ lang.login.password_mismatch }}</small>
<div class="d-flex justify-content-end mt-4" style="position: relative">
<button type="submit" class="btn btn-xs-lg d-block d-sm-inline btn-success" name="pw_reset">{{ lang.login.reset_password }}</button>
</div>
</form>
{% elseif reset_token is null %}
<form method="post" autofill="off">
<input type="text" autocorrect="off" autocapitalize="none" class="form-control mb-2" name="username" placeholder="{{ lang.login.username }}" />
<div class="d-flex justify-content-end mt-4" style="position: relative">
<button type="submit" class="btn btn-xs-lg d-block d-sm-inline btn-success" name="pw_reset_request">{{ lang.login.request_reset_password }}</button>
</div>
</form>
{% else %}
<p class="text-center">{{ lang.login.invalid_pass_reset_token|raw }}</p>
<a href="/">{{ lang.login.back_to_mailcow }}</a>
{% endif %}
</div>
</div>
</div>
</div>
<script type='text/javascript'>
var csrf_token = '{{ csrf_token }}';
var mailcow_cc_username = '{{ mailcow_cc_username }}';
</script>
{% endblock %}

View File

@ -50,6 +50,7 @@
<p>{{ mailboxdata.quota_used|formatBytes(2) }} / {% if mailboxdata.quota == 0 %}{% else %}{{ mailboxdata.quota|formatBytes(2) }}{% endif %}<br>{{ mailboxdata.messages }} {{ lang.user.messages }}</p>
<hr>
<p><a href="#pwChangeModal" data-bs-toggle="modal"><i class="bi bi-pencil-fill"></i> {{ lang.user.change_password }}</a></p>
{% if acl.pw_reset == 1 %}<p><a href="#pwRecoveryEmailModal" data-bs-toggle="modal"><i class="bi bi-pencil-fill"></i> {{ lang.user.pw_recovery_email }}</a></p>{% endif %}
</div>
</div>
<hr>