diff --git a/README.password_expiration b/README.password_expiration new file mode 100644 index 00000000..38144690 --- /dev/null +++ b/README.password_expiration @@ -0,0 +1,48 @@ +*Description + +This extension adds support for password expiration. +It is designed to have expiration on users passwords. An email is sent when the password is expiring in 30 days, then 14 days, then 7 days. +It is strongly inspired by https://abridge2devnull.com/posts/2014/09/29/dovecot-user-password-expiration-notifications-updated-4122015/, and adapted to fit with Postfix Admin & Roundcube's password plugin +Expiration unit is day +Expiration value for domain is set through Postfix Admin GUI + +*Installation + +Perform the following changes: + +**Changes in MySQL/MariaDB mailbox table (as defined in $CONF['database_tables'] from config.inc.php): + +You are invited to backup your DB first, and ensure the table name is correct. + +Execute the attached SQL script (password_expiration.sql) that will add the required columns. The expiration value for existing users will be set to 90 days. If you want a different value, edit line 2 in the script and replace 90 by the required value. + +**Changes in Postfix Admin : + +To enable password expiration, add the following to your config.inc.php file: +$CONF['password_expiration_enabled'] = 'YES'; + +All my tests are performed using $CONF['encrypt'] = 'md5crypt'; + +**If you are using Roundcube's password plugin, you should also adapt the $config['password_query'] value. + +I recommend to use: + +$config['password_query'] = 'UPDATE mailbox SET password=%c, modified = now(), password_expiry = now() + interval 90 day'; + +of cource you may adapt to the expected expiration value + +All my tests are performed using $config['password_algorithm'] = 'md5-crypt'; + +**Changes in Dovecot (adapt if you use another LDA) + +Edit dovecot-mysql.conf file, and replace the user_query (and only this one) by this query: + +password_query = SELECT username as user, password, concat('/var/vmail/', maildir) as userdb_var, concat('maildir:/var/vmail/', maildir) as userdb_mail, 20001 as userdb_uid, 20001 as userdb_gid, m.domain FROM mailbox m, domain d where d.domain = m.domain and m.username = '%u' AND m.active = '1' AND (m.pw_expires_on > now() or d.password_expiration_value = 0) + +Of course you may require to adapt the uid, gid, maildir and table to your setup + +**Changes in system + +You need to have a script running on a daily basis to check password expiration and send emails 30, 14 and 7 days before password expiration (script attached: check_mailpass_expiration.sh). +Edit the script to adapt the variables to your setup. +This script is using postfixadmin.my.cnf to read credentials. Edit this file to enter a DB user that is allowed to access (read-write) your database. This file should be protected from any user (chmod 400). diff --git a/check_mailpass_expiration.sh b/check_mailpass_expiration.sh new file mode 100644 index 00000000..a7b33728 --- /dev/null +++ b/check_mailpass_expiration.sh @@ -0,0 +1,20 @@ +#!/bin/bash +#Adapt to your setup + +POSTFIX_DB="postfix_test" +MYSQL_CREDENTIALS_FILE="postfixadmin.my.cnf" + +REPLY_ADDRESS=noreply@example.com + +# Change this list to change notification times and when ... +for INTERVAL in 30 14 7 +do + LOWER=$(( $INTERVAL - 1 )) + + QUERY="SELECT username,password_expiry FROM mailbox WHERE password_expiry > now() + interval $LOWER DAY AND password_expiry < NOW() + interval $INTERVAL DAY" + + mysql --defaults-extra-file="$MYSQL_CREDENTIALS_FILE" "$POSTFIX_DB" -B -e "$QUERY" | while read -a RESULT ; do + echo -e "Dear User, \n Your password will expire on ${RESULT[1]}" | mail -s "Password 30 days before expiration notication" -r $REPLY_ADDRESS ${RESULT[0]} + done + +done diff --git a/config.inc.php b/config.inc.php index e5b40d2a..744b9ae7 100644 --- a/config.inc.php +++ b/config.inc.php @@ -516,6 +516,16 @@ $CONF['show_undeliverable']='YES'; $CONF['show_undeliverable_color']='tomato'; // mails to these domains will never be flagged as undeliverable $CONF['show_undeliverable_exceptions']=array("unixmail.domain.ext","exchangeserver.domain.ext"); +// show mailboxes with expired password +$CONF['show_expired']='YES'; +$CONF['show_expired_color']='orange'; +// show vacation enabled mailboxes +$CONF['show_vacation']='YES'; +$CONF['show_vacation_color']='turquoise'; +// show disabled accounts +$CONF['show_disabled']='YES'; +$CONF['show_disabled_color']='grey'; +// show POP/IMAP mailboxes $CONF['show_popimap']='YES'; $CONF['show_popimap_color']='darkgrey'; // you can assign special colors to some domains. To do this, @@ -661,6 +671,11 @@ $CONF['theme_custom_css'] = ''; // change to boolean true to enable xmlrpc $CONF['xmlrpc_enabled'] = false; +//Account expiration info +//If you want to display the password expiracy status of the accounts (read-only) +//More details in README.password_expiration +$CONF['password_expiration_enable'] = 'YES'; + // If you want to keep most settings at default values and/or want to ensure // that future updates work without problems, you can use a separate config // file (config.local.php) instead of editing this file and override some diff --git a/functions.inc.php b/functions.inc.php index 4495670f..5a34c60b 100644 --- a/functions.inc.php +++ b/functions.inc.php @@ -260,6 +260,18 @@ function check_domain($domain) { return ''; } +/** + * Get password expiration value for a domain + * @param string $domain - a string that may be a domain + * @return int password expiration value for this domain (DAYS, or zero if not enabled) + */ +function get_password_expiration_value ($domain) { + $table_domain = table_by_key('domain'); + $query = "SELECT password_expiry FROM $table_domain WHERE domain='$domain'"; + $result = db_query ($query); + $password_expiration_value = db_array ($result['result']); + return $password_expiration_value[0]; +} /** * check_email @@ -1871,7 +1883,7 @@ function db_delete($table, $where, $delete, $additionalwhere='') { * @param array $timestamp (optional) - array of fields to set to now() - default: array('created', 'modified') * @return int - number of inserted rows */ -function db_insert($table, $values, $timestamp = array('created', 'modified')) { +function db_insert ($table, $values, $timestamp = array('created', 'modified'), $timestamp_expiration = array('password_expiry') ) { $table = table_by_key($table); foreach (array_keys($values) as $key) { @@ -1886,6 +1898,19 @@ function db_insert($table, $values, $timestamp = array('created', 'modified')) { } } + global $CONF; + if ($CONF['password_expiration_enabled'] == 'YES') { + if ($table == 'mailbox') { + $domain_dirty = $values['domain']; + $domain = substr($domain_dirty, 1, -1); // really the update to the mailbox password_expiry should be based on a trigger, or a query like : + // .... NOW() + INTERVAL domain.password_expiry DAY + $password_expiration_value = get_password_expiration_value($domain); + foreach($timestamp_expiration as $key) { + $values[$key] = "now() + interval " . $password_expiration_value . " day"; + } + } + } + $sql_values = "(" . implode(",", escape_string(array_keys($values))).") VALUES (".implode(",", $values).")"; $result = db_query("INSERT INTO $table $sql_values"); @@ -1934,6 +1959,19 @@ function db_update_q($table, $where, $values, $timestamp = array('modified')) { } } + global $CONF; + if ($CONF['password_expiration_enabled'] == 'YES') { + $where_type = explode('=',$where); + $email = ($where_type[1]); + $domain_dirty = explode('@',$email)[1]; + $domain = substr($domain_dirty, 0, -1); + if ($table == 'mailbox') { + $password_expiration_value = get_password_expiration_value($domain); + $key = 'password_expiry'; + $sql_values[$key] = $key . " = now() + interval " . $password_expiration_value . " day"; + } + } + $sql="UPDATE $table SET " . implode(",", $sql_values) . " WHERE $where"; $result = db_query($sql); @@ -2190,6 +2228,36 @@ function gen_show_status($show_alias) { } } + // Vacation CHECK + if ( $CONF['show_vacation'] == 'YES' ) { + $stat_result = db_query ("SELECT * FROM ". $CONF['database_tables']['vacation'] ." WHERE email = '" . $show_alias . "' AND active = 1"); + if ($stat_result['rows'] == 1) { + $stat_string .= "" . $CONF['show_status_text'] . " "; + } else { + $stat_string .= $CONF['show_status_text'] . " "; + } + } + + // Disabled CHECK + if ( $CONF['show_disabled'] == 'YES' ) { + $stat_result = db_query ("SELECT * FROM ". $CONF['database_tables']['mailbox'] ." WHERE username = '" . $show_alias . "' AND active = 0"); + if ($stat_result['rows'] == 1) { + $stat_string .= "" . $CONF['show_status_text'] . " "; + } else { + $stat_string .= $CONF['show_status_text'] . " "; + } + } + + // Expired CHECK + if ( $CONF['show_expired'] == 'YES' ) { + $stat_result = db_query ("SELECT * FROM ". $CONF['database_tables']['mailbox'] ." WHERE username = '" . $show_alias . "' AND password_expiry <= now()"); + if ($stat_result['rows'] == 1) { + $stat_string .= "" . $CONF['show_status_text'] . " "; + } else { + $stat_string .= $CONF['show_status_text'] . " "; + } + } + // POP/IMAP CHECK if ($CONF['show_popimap'] == 'YES') { $stat_delimiter = ""; diff --git a/languages/en.lang b/languages/en.lang index 8084c341..3baa4053 100644 --- a/languages/en.lang +++ b/languages/en.lang @@ -348,6 +348,10 @@ $PALANG['broadcast_mailboxes_only'] = 'Only send to mailboxes'; $PALANG['broadcast_to_domains'] = 'Send to domains:'; $PALANG['pStatus_undeliverable'] = 'maybe UNDELIVERABLE '; +$PALANG['pStatus_disabled'] = 'Account disabled '; +$PALANG['pStatus_expired'] = 'Password expired '; +$PALANG['pStatus_vacation'] = 'Vacation enabled '; + $PALANG['pStatus_custom'] = 'Delivers to '; $PALANG['pStatus_popimap'] = 'POP/IMAP '; @@ -407,6 +411,7 @@ $PALANG['pFetchmail_desc_returned_text'] = 'Text message from last polling'; $PALANG['dateformat_pgsql'] = 'YYYY-mm-dd'; # translators: rearrange to your local date format, but make sure it's a valid PostgreSQL date format $PALANG['dateformat_mysql'] = '%Y-%m-%d'; # translators: rearrange to your local date format, but make sure it's a valid MySQL date format +$PALANG['password_expiration'] = 'Pass expires'; $PALANG['please_keep_this_as_last_entry'] = ''; # needed for language-check.sh /* vim: set expandtab ft=php softtabstop=3 tabstop=3 shiftwidth=3: */ diff --git a/languages/fr.lang b/languages/fr.lang index a789f21b..714504b0 100644 --- a/languages/fr.lang +++ b/languages/fr.lang @@ -342,6 +342,9 @@ $PALANG['pBroadcast_error_empty'] = 'Les champs "Nom", "Sujet" et "Message" ne p $PALANG['broadcast_mailboxes_only'] = 'Only send to mailboxes'; # XXX $PALANG['broadcast_to_domains'] = 'Send to domains:'; # XXX $PALANG['pStatus_undeliverable'] = 'Non délivrable '; +$PALANG['pStatus_vacation'] = 'Répondeur activé '; +$PALANG['pStatus_disabled'] = 'Compte désactivé '; +$PALANG['pStatus_expired'] = 'Mot de passe expiré '; $PALANG['pStatus_custom'] = 'Délivré à '; $PALANG['pStatus_popimap'] = 'POP/IMAP '; $PALANG['password_too_short'] = 'Mot de passe trop court. - %s caractères minimum'; @@ -398,6 +401,7 @@ $PALANG['pFetchmail_desc_date'] = 'Date de la dernière vérification/changement $PALANG['pFetchmail_desc_returned_text'] = 'Message de la dernière vérification'; $PALANG['dateformat_pgsql'] = 'dd-mm-YYYY'; $PALANG['dateformat_mysql'] = '%d-%m-%Y'; +$PALANG['password_expiration'] = 'Expiration du mot de passe'; $PALANG['please_keep_this_as_last_entry'] = ''; # needed for language-check.sh /* vim: set expandtab ft=php softtabstop=3 tabstop=3 shiftwidth=3: */ diff --git a/model/DomainHandler.php b/model/DomainHandler.php index 57a70e0f..b140f0d1 100644 --- a/model/DomainHandler.php +++ b/model/DomainHandler.php @@ -94,6 +94,7 @@ class DomainHandler extends PFAHandler { 'default_aliases' => pacol($this->new, $this->new, 0, 'bool', 'pAdminCreate_domain_defaultaliases', '' , 1,'', /*not in db*/ 1 ), 'created' => pacol(0, 0, 0, 'ts', 'created' , '' ), 'modified' => pacol(0, 0, $super, 'ts', 'last_modified' , '' ), + 'password_expiry' => pacol($super, $super, $super, 'num', 'password_expiration' , 'password_expiration_desc', ''), '_can_edit' => pacol(0, 0, 1, 'int', '' , '' , 0 , /*options*/ '', /*not_in_db*/ 0, diff --git a/model/MailboxHandler.php b/model/MailboxHandler.php index 313068f0..d4132fc0 100644 --- a/model/MailboxHandler.php +++ b/model/MailboxHandler.php @@ -49,6 +49,7 @@ class MailboxHandler extends PFAHandler { 'token_validity' => pacol(1, 0, 0, 'ts', '' , '', date("Y-m-d H:i:s",time())), 'created' => pacol(0, 0, 1, 'ts', 'created' , '' ), 'modified' => pacol(0, 0, 1, 'ts', 'last_modified' , '' ), + 'password_expiry' => pacol(0, 0, 1, 'ts', 'password_expiration' , '' ), # TODO: add virtual 'notified' column and allow to display who received a vacation response? ); diff --git a/password_expiration.sql b/password_expiration.sql new file mode 100644 index 00000000..a272c3de --- /dev/null +++ b/password_expiration.sql @@ -0,0 +1,3 @@ +ALTER TABLE mailbox ADD COLUMN password_expiry TIMESTAMP DEFAULT now() not null; +UPDATE mailbox set password_expiry = now() + interval 90 day; +ALTER TABLE domain ADD COLUMN password_expiry int DEFAULT 0; diff --git a/postfixadmin.my.cnf b/postfixadmin.my.cnf new file mode 100644 index 00000000..22d1c088 --- /dev/null +++ b/postfixadmin.my.cnf @@ -0,0 +1,3 @@ +[client] +user=postfix_read_write_account +password=strong_password diff --git a/public/list-virtual.php b/public/list-virtual.php index 7f7d489f..cffc62e8 100644 --- a/public/list-virtual.php +++ b/public/list-virtual.php @@ -165,6 +165,7 @@ $tAlias = $handler->result(); # $display_mailbox_aliases = Config::bool('alias_control_admin'); +$password_expiration = Config::bool('password_expiration'); # build the sql query $sql_select = "SELECT $table_mailbox.* "; @@ -190,6 +191,10 @@ if ($display_mailbox_aliases) { $sql_join .= " LEFT JOIN $table_alias ON $table_mailbox.username=$table_alias.address "; } +if ($password_expiration) { + $sql_select .= ", $table_mailbox.password_expiry as password_expiration "; +} + if (Config::bool('vacation_control_admin')) { $table_vacation = table_by_key('vacation'); $sql_select .= ", $table_vacation.active AS v_active "; diff --git a/public/upgrade.php b/public/upgrade.php index 334926c3..7e5cfe05 100644 --- a/public/upgrade.php +++ b/public/upgrade.php @@ -88,11 +88,11 @@ function _upgrade_filter_function($name) { return preg_match('/upgrade_[\d]+(_mysql|_pgsql|_sqlite|_mysql_pgsql)?$/', $name) == 1; } -function _db_add_field($table, $field, $fieldtype, $after) { +function _db_add_field($table, $field, $fieldtype, $after = '') { global $CONF; $query = "ALTER TABLE " . table_by_key($table) . " ADD COLUMN $field $fieldtype"; - if ($CONF['database_type'] == 'mysql') { + if ($CONF['database_type'] == 'mysql' && !empty($after)) { $query .= " AFTER $after "; # PgSQL does not support to specify where to add the column, MySQL does } @@ -1760,3 +1760,8 @@ function upgrade_1841_sqlite() { _db_add_field($table, 'token_validity', '{DATETIME}', 'token'); } } + +function upgrade_1842() { + _db_add_field('mailbox', 'password_expiry', "{DATETIME}"); // when a specific mailbox password expires + _db_add_field('domain', 'password_expiry', 'int DEFAULT 0'); // expiry applied to mailboxes within that domain +} diff --git a/templates/list-virtual.tpl b/templates/list-virtual.tpl index 8218e738..e31da865 100644 --- a/templates/list-virtual.tpl +++ b/templates/list-virtual.tpl @@ -71,6 +71,16 @@ {if $CONF.show_undeliverable===YES}  {$CONF.show_status_text}={$PALANG.pStatus_undeliverable} {/if} + {if $CONF.show_vacation===YES} +  {$CONF.show_status_text}={$PALANG.pStatus_vacation} + {/if} + {if $CONF.show_disabled===YES} +  {$CONF.show_status_text}={$PALANG.pStatus_disabled} + {/if} + {if $CONF.show_expired===YES} +  {$CONF.show_status_text}={$PALANG.pStatus_expired} + {/if} + {if $CONF.show_popimap===YES}  {$CONF.show_status_text}={$PALANG.pStatus_popimap} {/if} diff --git a/templates/list-virtual_mailbox.tpl b/templates/list-virtual_mailbox.tpl index 0b19628b..40eecc58 100644 --- a/templates/list-virtual_mailbox.tpl +++ b/templates/list-virtual_mailbox.tpl @@ -13,6 +13,9 @@ {$PALANG.name} {if $CONF.quota===YES}{$PALANG.pOverview_mailbox_quota}{/if} {$PALANG.last_modified} + {if $CONF.password_expiration===YES} + {$PALANG.password_expiration} + {/if} {$PALANG.active} {assign var="colspan" value="`$colspan-6`"}   @@ -74,6 +77,9 @@ {/if} {$item.modified} + {if $CONF.password_expiration===YES} + {$item.password_expiration} + {/if} {if $item.active==1}{$PALANG.YES}{else}{$PALANG.NO}{/if} {if $CONF.vacation_control_admin===YES && $CONF.vacation===YES}