diff --git a/config.inc.php b/config.inc.php index 491297e1..7c4c04e9 100644 --- a/config.inc.php +++ b/config.inc.php @@ -129,10 +129,6 @@ $CONF['admin_email'] = ''; // This will be used as signature in notification messages $CONF['admin_name'] = 'Postmaster'; -// Site admin phone number -// This will be used if a user cannot access his/her email and needs support -$CONF['admin_phone'] = ''; - // Mail Server // Hostname (FQDN) of your mail server. // This is used to send email to Postfix in order to create mailboxes. @@ -588,16 +584,34 @@ $CONF['create_mailbox_subdirs_hostoptions'] = array(); // Optional: // Allows a user to reset his forgotten password with a code sent by email/SMS -$CONF['forgotten_user_password_reset'] = false; # INSECURE, DO NOT ENABLE! See https://github.com/postfixadmin/postfixadmin/pull/18 for details +$CONF['forgotten_user_password_reset'] = true; // Allows an admin to reset his forgotten password with a code sent by email/SMS -$CONF['forgotten_admin_password_reset'] = false; # INSECURE, DO NOT ENABLE! see https://github.com/postfixadmin/postfixadmin/pull/18 for details - -// Clickatell gateway to send SMS code for password reset -// API type: HTTP -$CONF['clickatell_api_id'] = ''; -$CONF['clickatell_user'] = ''; -$CONF['clickatell_password'] = ''; -$CONF['clickatell_sender'] = ''; +$CONF['forgotten_admin_password_reset'] = false; + +// Name of the function to send a SMS +// Please use a name that begins with "x_" to prevent collisions +// This function must accept 2 parameters: phone number and message, +// and return true on success or false on failure +$CONF['sms_send_function'] = ''; + +/* +// Example of send SMS function using Clickatell HTTP API +function x_send_sms_clickatell($to, $message) { + + $clickatell_api_id = 'CHANGEME'; + $clickatell_user = 'CHANGEME'; + $clickatell_password = 'CHANGEME'; + $clickatell_sender = 'CHANGEME'; + + $url = 'https://api.clickatell.com/http/sendmsg?api_id=%s&user=%s&password=%s&to=%s&from=%s&text=%s'; + + $url = sprintf($url, $clickatell_api_id, $clickatell_user, $clickatell_password, $to, $clickatell_sender, urlencode($message)); + + $result = file_get_contents($url); + + return $result !== false; +} +*/ // Theme Config // Specify your own logo and CSS file diff --git a/functions.inc.php b/functions.inc.php index 47fc63ef..3eac8b0f 100644 --- a/functions.inc.php +++ b/functions.inc.php @@ -16,7 +16,7 @@ */ $version = '3.1'; -$min_db_version = 1836; # update (at least) before a release with the latest function numbrer in upgrade.php +$min_db_version = 1837; # update (at least) before a release with the latest function numbrer in upgrade.php /** * check_session @@ -89,6 +89,23 @@ function authentication_require_role($role) { exit(0); } +/** + * Initialize a user or admin session + * + * @param String $username the user or admin name + * @param boolean $is_admin true if the user is an admin, false otherwise + * @return boolean true on success + */ +function init_session($username, $is_admin = false) { + $status = session_regenerate_id(true); + $_SESSION['sessid'] = array(); + $_SESSION['sessid']['roles'] = array(); + $_SESSION['sessid']['roles'][] = $is_admin ? 'admin' : 'user'; + $_SESSION['sessid']['username'] = $username; + $_SESSION['PFA_token'] = md5(uniqid(rand(), true)); + + return $status; +} /** * Add an error message for display on the next page that is rendered. @@ -806,7 +823,7 @@ function encode_header ($string, $default_charset = "utf-8") { // function generate_password () { // length of the generated password - $length = 8; + $length = 12; // define possible characters $possible = "2345678923456789abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ"; # skip 0 and 1 to avoid confusion with O and l @@ -1942,21 +1959,4 @@ function getRemoteAddr() { return $REMOTE_ADDR; } - -/** - * Returns a hash for a username valid for one day - * - * @param String $username user name - * @return String password recovery code - */ -function getPasswordRecoveryCode($username) -{ - $username = trim(strtolower($username)); - $date = date('Y-m-d'); - - $code = substr(strtoupper(md5('SECRET SALTING PHRASE' . $username . $date)), 0, 6); - - return $code; -} - /* vim: set expandtab softtabstop=4 tabstop=4 shiftwidth=4: */ diff --git a/languages/en.lang b/languages/en.lang index 4b0c65b2..f68a2542 100644 --- a/languages/en.lang +++ b/languages/en.lang @@ -154,7 +154,7 @@ $PALANG['pCreate_mailbox_mail'] = 'Send Welcome mail'; $PALANG['pCreate_mailbox_result_error'] = 'Creating the mailbox %s failed!'; $PALANG['pCreate_mailbox_result_success'] = 'The mailbox %s has been added to the mailbox table.'; $PALANG['pCreate_mailbox_result_succes_nosubfolders'] = 'The mailbox %s has been added to the mailbox table, but none (or only some) of the predefined sub-folders could be created.'; -$PALANG['mailbox_updated'] = "The mailbox %s has been updated."; +$PALANG['mailbox_updated'] = "The mailbox %s has been updated."; $PALANG['mailbox_update_failed'] = "Updating the mailbox %s failed!"; $PALANG['pEdit_mailbox_welcome'] = 'Edit a mailbox for your domain.'; @@ -180,11 +180,9 @@ $PALANG['pPassword_result_success'] = 'The password for %s has been changed.'; $PALANG['pPassword_recovery_title'] = 'Follow the instructions to reset your password.'; $PALANG['pPassword_recovery_button'] = 'Send me the code'; -$PALANG['pPassword_recovery_email_body'] = "Hello,\n\nUse the following link to change your email password:\n%s\n\nRegards,\n\n" . $CONF['admin_name']; -$PALANG['pPassword_recovery_email_sent'] = 'An email was sent to:'; +$PALANG['pPassword_recovery_email_body'] = "Hello,\n\nUse the following link to change your email password :\n%s\n\nRegards,\n\n" . $CONF['admin_name']; $PALANG['pPassword_recovery_sms_body'] = "Hello,\nThe code to change your password is: %s\n" . $CONF['admin_name']; -$PALANG['pPassword_recovery_sms_sent'] = 'An SMS was sent to:'; -$PALANG['pPassword_recovery_no_alternative'] = 'No alternative contact info were found. Please contact the support at ' . $CONF['admin_email'] . 'or by phone to ' . $CONF['admin_phone']; +$PALANG['pPassword_recovery_processed'] = "We processed your request. If you entered a valid username, you'll receive an email/SMS with a password code."; $PALANG['pPassword_password_code'] = 'Code sent by email/SMS'; $PALANG['pPassword_code_text_error'] = 'Invalid code'; diff --git a/languages/fr.lang b/languages/fr.lang index 78701df4..f31fb6b7 100644 --- a/languages/fr.lang +++ b/languages/fr.lang @@ -179,10 +179,8 @@ $PALANG['pPassword_result_success'] = 'Le mot de passe de %s a été changé !'; $PALANG['pPassword_recovery_title'] = 'Suivez les instructions pour réinitialiser votre mot de passe.'; $PALANG['pPassword_recovery_button'] = 'Envoyez-moi le code'; $PALANG['pPassword_recovery_email_body'] = "Bonjour,\n\nUtilisez le lien suivant pour modifier votre mot de passe :\n%s\n\nSalutations,\n\n" . $CONF['admin_name']; -$PALANG['pPassword_recovery_email_sent'] = 'Un code a été envoyé à :'; $PALANG['pPassword_recovery_sms_body'] = "Bonjour,\nLe code pour modifier votre mot de passe: %s\n" . $CONF['admin_name']; -$PALANG['pPassword_recovery_sms_sent'] = 'Un code a été envoyé par SMS à :'; -$PALANG['pPassword_recovery_no_alternative'] = "Aucun moyen de contact alternatif n'a été trouvé. Contactez le support à " . $CONF['admin_email'] . ' ou par téléphone ' . $CONF['admin_phone']; +$PALANG['pPassword_recovery_processed'] = "Nous avons traité votre demande. Si le nom d'utilisateur que vous avez saisi est valide, vous recevrez par e-mail/SMS un code de réinitialisation du mot de passe."; $PALANG['pPassword_password_code'] = 'Code reçu par email/SMS'; $PALANG['pPassword_code_text_error'] = 'Code invalide'; @@ -339,7 +337,7 @@ $PALANG['pBroadcast_title'] = 'Envoyer un message général'; $PALANG['pBroadcast_name'] = 'Votre nom'; $PALANG['pBroadcast_success'] = 'Votre message général a été envoyé.'; $PALANG['pAdminMenu_broadcast_message'] = 'message général'; -$PALANG['pBroadcast_error_empty'] = 'Les champs "Nom", "Sujet" et "Message" ne peuvent pas être vides!'; +$PALANG['pBroadcast_error_empty'] = 'Les champs "Nom", "Sujet" et "Message" ne peuvent pas être vides !'; $PALANG['broadcast_mailboxes_only'] = 'Only send to mailboxes'; # XXX $PALANG['broadcast_to_domains'] = 'Send to domains:'; # XXX $PALANG['pStatus_undeliverable'] = 'Non délivrable '; diff --git a/login.php b/login.php index f91ca378..c24f3066 100644 --- a/login.php +++ b/login.php @@ -52,13 +52,8 @@ if ($_SERVER['REQUEST_METHOD'] == "POST") $h = new AdminHandler; if ( $h->login($fUsername, $fPassword) ) { - session_regenerate_id(true); - $_SESSION['sessid'] = array(); - $_SESSION['sessid']['roles'] = array(); - $_SESSION['sessid']['roles'][] = 'admin'; - $_SESSION['sessid']['username'] = $fUsername; - $_SESSION['PFA_token'] = md5(uniqid(rand(), true)); + init_session($fUsername, true); # they've logged in, so see if they are a domain admin, as well. diff --git a/model/AdminHandler.php b/model/AdminHandler.php index e33a6232..bce422fa 100644 --- a/model/AdminHandler.php +++ b/model/AdminHandler.php @@ -35,6 +35,8 @@ class AdminHandler extends PFAHandler { $domains_grouped = 'group_concat(domain)'; } + $passwordReset = Config::read('forgotten_admin_password_reset'); + $this->struct=array( # field name allow display in... type $PALANG label $PALANG description default / options / ... # editing? form list @@ -47,10 +49,6 @@ class AdminHandler extends PFAHandler { /*select*/ 'password as password2' ), - 'phone' => pacol( 1, 1, 0, 'text', 'pCreate_mailbox_phone', 'pCreate_mailbox_phone_desc', ''), - - 'email_other' => pacol( 1, 1, 0, 'mail', 'pCreate_mailbox_email', 'pCreate_mailbox_email_desc', ''), - 'superadmin' => pacol( 1, 1, 0, 'bool', 'super_admin' , 'super_admin_desc' , 0 # TODO: (finally) replace the ALL domain with a column in the admin table # TODO: current status: 'superadmin' column exists and is written when storing an admin with AdminHandler, @@ -78,6 +76,10 @@ class AdminHandler extends PFAHandler { ' ) AS __domain on username = __domain_username'), 'active' => pacol( 1, 1, 1, 'bool', 'active' , '' , 1 ), + 'phone' => pacol( 1, $passwordReset, 0, 'text', 'pCreate_mailbox_phone', 'pCreate_mailbox_phone_desc', ''), + 'email_other' => pacol( 1, $passwordReset, 0, 'mail', 'pCreate_mailbox_email', 'pCreate_mailbox_email_desc', ''), + 'token' => pacol( 1, 0, 0, 'text', '' , '' ), + 'token_validity' => pacol( 1, 0, 0, 'ts', '' , '' ), 'created' => pacol( 0, 0, 0, 'ts', 'created' , '' ), 'modified' => pacol( 0, 0, 1, 'ts', 'last_modified' , '' ), ); diff --git a/model/MailboxHandler.php b/model/MailboxHandler.php index 4efe6f90..0e0fbccb 100644 --- a/model/MailboxHandler.php +++ b/model/MailboxHandler.php @@ -13,6 +13,7 @@ class MailboxHandler extends PFAHandler { # init $this->struct, $this->db_table and $this->id_field protected function initStruct() { + $passwordReset = Config::read('forgotten_user_password_reset'); $this->struct=array( # field name allow display in... type $PALANG label $PALANG description default / options / ... # editing? form list @@ -35,11 +36,13 @@ class MailboxHandler extends PFAHandler { # read_from_db_postprocess() also sets 'quotabytes' for use in init() # TODO: read used quota from quota/quota2 table 'active' => pacol( 1, 1, 1, 'bool', 'active' , '' , 1 ), - 'phone' => pacol( 1, 1, 0, 'text', 'pCreate_mailbox_phone' , 'pCreate_mailbox_phone_desc' , ''), - 'email_other' => pacol( 1, 1, 0, 'mail', 'pCreate_mailbox_email' , 'pCreate_mailbox_email_desc' , ''), 'welcome_mail' => pacol( $this->new, $this->new, 0, 'bool', 'pCreate_mailbox_mail' , '' , 1, /*options*/ '', /*not_in_db*/ 1 ), + 'phone' => pacol( 1, $passwordReset, 0, 'text', 'pCreate_mailbox_phone' , 'pCreate_mailbox_phone_desc' , ''), + 'email_other' => pacol( 1, $passwordReset, 0, 'mail', 'pCreate_mailbox_email' , 'pCreate_mailbox_email_desc' , ''), + 'token' => pacol( 1, 0, 0, 'text', '' , '' ), + 'token_validity'=> pacol( 1, 0, 0, 'ts', '' , '' ), 'created' => pacol( 0, 0, 1, 'ts', 'created' , '' ), 'modified' => pacol( 0, 0, 1, 'ts', 'last_modified' , '' ), # TODO: add virtual 'notified' column and allow to display who received a vacation response? diff --git a/model/PFAHandler.php b/model/PFAHandler.php index 8e1c5a46..66754639 100644 --- a/model/PFAHandler.php +++ b/model/PFAHandler.php @@ -780,6 +780,55 @@ abstract class PFAHandler { return false; } + /** + * Generate and store a unique password reset token valid for one hour + * @param string $username + * @return false|string + */ + function getPasswordRecoveryCode($username) { + if ($this->init($username)) { + $token = generate_password(); + $table = table_by_key($this->db_table); + $updatedRows = db_update($table, $this->id_field, $username, array( + 'token' => pacrypt($token), + 'token_validity' => date("Y-m-d H:i:s", strtotime('+ 1 hour')), + )); + + if ($updatedRows == 1) { + return $token; + } + } + return false; + } + + /** + * Verify user's one time password reset token + * @param string $username + * @param string $token + * @return boolean true on success (i.e. code matches etc) + */ + public function checkPasswordRecoveryCode($username, $token) { + $username = escape_string($username); + + $table = table_by_key($this->db_table); + $active = db_get_boolean(True); + $query = "SELECT token FROM $table WHERE " . $this->id_field . "='$username' AND token <> '' AND active='$active' AND NOW() < token_validity"; + + $result = db_query ($query); + if ($result['rows'] == 1) { + $row = db_array($result['result']); + $crypt_token = pacrypt($token, $row['token']); + + if($row['token'] == $crypt_token) { + db_update($table, $this->id_field, $username, array( + 'token' => '', + 'token_validity' => '2000-01-01 00:00:00', + )); + return true; + } + } + return false; + } /************************************************************************** * functions to read protected variables diff --git a/templates/login.tpl b/templates/login.tpl index 2dabec64..01bddfd0 100644 --- a/templates/login.tpl +++ b/templates/login.tpl @@ -15,8 +15,11 @@ - {if $forgotten_password_reset}
- {$PALANG.pUsersLogin_password_recover}{/if} + +{if $forgotten_password_reset} +
{$PALANG.pUsersLogin_password_recover} +{/if} + diff --git a/upgrade.php b/upgrade.php index 5ce7ded4..592dd49f 100644 --- a/upgrade.php +++ b/upgrade.php @@ -1684,16 +1684,14 @@ function upgrade_1836_mysql() { } -function upgrade_1837_mysql() { +function upgrade_1837() { # alternative contact means to reset a forgotten password foreach(array('admin', 'mailbox') as $table_to_change) { $table = table_by_key($table_to_change); - if(!_mysql_field_exists($table, 'phone')) { - db_query_parsed("ALTER TABLE `$table` ADD COLUMN `phone` varchar(30) NOT NULL DEFAULT ''"); - } - if(!_mysql_field_exists($table, 'email_other')) { - db_query_parsed("ALTER TABLE `$table` ADD COLUMN `email_other` varchar(255) NOT NULL DEFAULT ''"); - } + _db_add_field($table, 'phone', "varchar(30) {UTF-8} NOT NULL DEFAULT ''", 'active'); + _db_add_field($table, 'email_other', "varchar(255) {UTF-8} NOT NULL DEFAULT ''", 'phone'); + _db_add_field($table, 'token', "varchar(255) {UTF-8} NOT NULL DEFAULT ''", 'email_other'); + _db_add_field($table, 'token_validity', '{DATETIME}', 'token'); } } # TODO MySQL: diff --git a/users/login.php b/users/login.php index 65042abe..b05162a7 100644 --- a/users/login.php +++ b/users/login.php @@ -48,12 +48,9 @@ if ($_SERVER['REQUEST_METHOD'] == "POST") $h = new MailboxHandler(); if($h->login($fUsername, $fPassword)) { - session_regenerate_id(true); - $_SESSION['sessid'] = array(); - $_SESSION['sessid']['roles'] = array(); - $_SESSION['sessid']['roles'][] = 'user'; - $_SESSION['sessid']['username'] = $fUsername; - $_SESSION['PFA_token'] = md5(uniqid(rand(), true)); + + init_session($fUsername, false); + header("Location: main.php"); exit; } else { diff --git a/users/password-change.php b/users/password-change.php index b21eb92a..17f770b5 100644 --- a/users/password-change.php +++ b/users/password-change.php @@ -1,16 +1,16 @@ init($tUsername)) { - flash_error($handler->errormsg); + + $handler = $context === 'admin' ? new AdminHandler : new MailboxHandler; + if (!$handler->checkPasswordRecoveryCode($tUsername, $tCode)) { + flash_error(Config::lang('pPassword_code_text_error')); } else { - $values = $handler->result; - $values[$handler->getId_field()] = $tUsername; - $values['password'] = $fPassword; - $values['password2'] = $fPassword2; - if ($handler->set($values) && $handler->store()) { - flash_info(Config::lang_f('pPassword_result_success', $tUsername)); - header('Location: ' . dirname($_SERVER['REQUEST_URI']) . '/main.php'); - exit(0); + + init_session($tUsername, $context === 'admin'); + if (!$handler->init($tUsername)) { + flash_error($handler->errormsg); } else { - foreach($handler->errormsg as $msg) { - flash_error($msg); + $values = $handler->result; + $values['password'] = $fPassword; + $values['password2'] = $fPassword2; + if ($handler->set($values) && $handler->store()) { + flash_info(Config::lang_f('pPassword_result_success', $tUsername)); + header('Location: main.php'); + exit(0); + } else { + foreach($handler->errormsg as $msg) { + flash_error($msg); + } } } } diff --git a/users/password-recover.php b/users/password-recover.php index 92d617f7..10803802 100644 --- a/users/password-recover.php +++ b/users/password-recover.php @@ -34,86 +34,62 @@ if (preg_match('/\/users\//', $_SERVER['REQUEST_URI'])) { } require_once($rel_path . 'common.php'); -if ($context == 'admin' && !Config::read('forgotten_admin_password_reset') || $context == 'users' && !Config::read('forgotten_user_password_reset')) -{ - header('HTTP/1.0 403 Forbidden'); - exit(0); +if ($context === 'admin' && !Config::read('forgotten_admin_password_reset') || $context === 'users' && !Config::read('forgotten_user_password_reset')) { + die('Password reset is disabled by configuration option: forgotten_admin_password_reset'); } -function sendCodebyEmail($to, $username, $code) -{ - $fHeaders = "To: " . $to . PHP_EOL; - $fHeaders .= "From: " . Config::read('admin_email') . PHP_EOL; - $fHeaders .= "Subject: " . encode_header(Config::Lang('pPassword_welcome')) . PHP_EOL; - $fHeaders .= "MIME-Version: 1.0" . PHP_EOL; - $fHeaders .= "Content-Type: text/plain; charset=utf-8" . PHP_EOL; - $fHeaders .= "Content-Transfer-Encoding: 8bit" . PHP_EOL . PHP_EOL; - +function sendCodebyEmail($to, $username, $code) { $url = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'] . dirname($_SERVER['REQUEST_URI']) . '/password-change.php?username=' . urlencode($username) . '&code=' . $code; - $fHeaders .= Config::lang_f('pPassword_recovery_email_body', $url); - return smtp_mail($to, Config::read('admin_email') , $fHeaders); + return smtp_mail($to, Config::read('admin_email'), Config::Lang('pPassword_welcome'), Config::lang_f('pPassword_recovery_email_body', $url)); } -function sendCodebySMS($to, $username, $code) -{ +function sendCodebySMS($to, $username, $code) { $text = Config::lang_f('pPassword_recovery_sms_body', $code); - - $url = 'https://api.clickatell.com/http/sendmsg?api_id=' . Config::read('clickatell_api_id') . '&user=' . Config::read('clickatell_user') . '&password=' . Config::read('clickatell_password') . "&to=$to" . '&from=' . Config::read('clickatell_sender') . '&text=' . urlencode($text); - $result = file_get_contents($url); + if (Config::read('sms_send_function') && is_callable(Config::read('sms_send_function'))) { + $result = call_user_func(Config::read('sms_send_function'), $to, $text); + return $result !== false; + } - return $result !== false; + return false; } -if ($_SERVER['REQUEST_METHOD'] == "POST") -{ +if ($_SERVER['REQUEST_METHOD'] === "POST") { + $start_time = microtime(); $tUsername = escape_string (safepost('fUsername')); - $table = table_by_key($context == 'users' ? 'mailbox' : 'admin'); - $result = db_query("SELECT * FROM `$table` WHERE username='$tUsername'"); - $eMessage = ''; - if ($result['rows'] == 1) - { + $handler = $context === 'admin' ? new AdminHandler : new MailboxHandler; + $token = $handler->getPasswordRecoveryCode($tUsername); + if ($token !== false) { + + $table = table_by_key($context === 'users' ? 'mailbox' : 'admin'); + $result = db_query("SELECT * FROM `$table` WHERE username='$tUsername'"); $row = db_array($result['result']); - $code = getPasswordRecoveryCode($tUsername); $email_other = trim($row['email_other']); $phone = trim($row['phone']); - // An active session is required to propagate flash messages to redirected page - if ($email_other) - { - // send email - if (sendCodeByEmail($email_other, $tUsername, $code)) - { - flash_info(Config::Lang('pPassword_recovery_email_sent') . ' ' . $email_other); - } + if ($email_other) { + sendCodeByEmail($email_other, $tUsername, $token); } - - if ($phone) - { - // send phone - if (sendCodeBySMS($phone, $tUsername, $code)) - { - flash_info(Config::Lang('pPassword_recovery_sms_sent') . ' ' . $phone); - } + + if ($phone) { + sendCodeBySMS($phone, $tUsername, $token); } - if ($email_other || $phone) - { - // session_regenerate_id(); + if ($email_other || $phone) { header("Location: password-change.php?username=" . $tUsername); exit(0); } - else - { - flash_error(Config::Lang('pPassword_recovery_no_alternative')); - } } - else - { - flash_error(Config::Lang('pCreate_mailbox_username_text_error1')); + + // throttle password reset requests to prevent brute force attack + $elapsed_time = microtime() - $start_time; + if ($elapsed_time < 2 * pow(10, 6)) { + usleep(2 * pow(10, 6) - $elapsed_time); } + + flash_info(Config::Lang('pPassword_recovery_processed')); } $smarty->assign ('language_selector', language_selector(), false);