roundcube-password_recovery/password_recovery.php

530 lines
21 KiB
PHP

<?php
/**
* Password recovery
*
* Plugin to reset an account password
*
* @version 1.0
* @original_author Alexander Alferov
*
* @url https://github.com/AlfnRU/roundcube-password_recovery
*/
class password_recovery extends rcube_plugin {
public $task = 'login|logout|settings|mail';
public $rc;
public $db;
public $user;
public $use_password;
public $pwd_conf;
private $pwd;
private $fields;
private $send;
function init() {
$this->rc = rcmail::get_instance();
$this->load_config();
if (!$this->rc->config->get('pr_use_confirm_code') && !$this->rc->config->get('pr_use_question'))
return;
$this->init_ui();
$this->add_texts('localization/');
if ($this->rc->task == 'login' || $this->rc->task == 'logout') {
$this->add_hook('render_page', [$this, 'add_labels_to_login_page']);
$this->add_hook('startup', [$this, 'startup']);
$this->register_action('plugin.get_confirm_code_count', [$this, 'get_confirm_code_count']);
} else if ($this->rc->task == 'mail') {
$this->add_hook('render_page', [$this, 'add_labels_to_mail_page']);
$this->add_hook('messages_list', [$this, 'check_identities']);
} else if ($this->rc->task == 'settings') {
$this->add_hook('identity_form', [$this, 'identity_form']);
$this->add_hook('identity_update', [$this, 'identity_update']);
}
$this->include_script('password_recovery.js');
}
/*******************
* STARTUP
*******************/
function init_ui() {
if (!$this->fields) $this->fields = $this->rc->config->get('pr_fields');
if (!$this->db) $this->get_dbh();
if (!$this->user) $this->get_user_props();
$this->use_password = ($this->rc->config->get('pr_use_password_plugin') && $this->rc->plugins->load_plugin('password', true));
$new_fields = [
'token' => ['type' => 'VARCHAR(255)', 'default' => '""'],
'token_validity' => ['type' => 'DATETIME' , 'default' => '2000-01-01 00:00:00']
];
foreach($this->fields as $field => $field_name){
$new_fields[$field_name] = ['type' => ($field == 'phone' ? 'VARCHAR(30)' : 'VARCHAR(255)'), 'default' => '""'];
}
foreach($new_fields as $field_name => $field_props){
$query = "SELECT " . $field_name . " FROM " . $this->rc->config->get('pr_users_table');
$result = $this->db->query($query);
if (!$result) {
$query = "ALTER TABLE " . $this->rc->config->get('pr_users_table') . " ADD " . $field_name . " " . $field_props['type'] . " DEFAULT " . $field_props['default'];
$result = $this->db->query($query);
}
}
require_once $this->home . '/lib/send.php';
$this->send = new password_recovery_send($this);
require_once $this->home . '/lib/password.php';
$this->pwd = new password_recovery_pwd($this);
}
function startup($p) {
if ($this->rc->action != 'plugin.password_recovery' || !isset($_SESSION['temp']))
return $p;
switch ($this->get_action()) {
case 'init':
$this->recovery_password_form();
break;
case 'renew':
$this->renew_confirm_code();
break;
case 'new':
$this->new_password_form();
break;
case 'reset':
$this->reset_password();
break;
case 'save':
$this->save_password();
break;
case 'cancel':
$this->rc->kill_session();
$this->rc->output->command('redirect', './');
break;
}
return $p;
}
function add_labels_to_login_page($p) {
if ($p['template'] == 'login') {
$this->rc->output->add_label('password_recovery.forgot_password');
}
return $p;
}
function add_labels_to_mail_page($p) {
$this->rc->output->add_label('password_recovery.no_identities');
$this->rc->output->add_script('rcmail.message_time = 10000;');
return $p;
}
/*******************
* PASSWORD
*******************/
// Creating form for reset password
private function recovery_password_form() {
$this->rc->output->add_label(
'password_recovery.recovery_password',
'password_recovery.no_username'
);
$this->rc->output->set_pagetitle($this->gettext('recovery_password'));
$this->rc->output->add_gui_object('recoverypasswordform', 'recovery-password-form');
$this->rc->output->send('password_recovery.recovery_password_form');
}
// Creating form for new password
private function new_password_form() {
$this->rc->output->add_label(
'password_recovery.newpassword',
'password_recovery.newpassword_confirm',
'password_recovery.question',
'password_recovery.answer',
'password_recovery.code',
'password_recovery.recovery_password',
'password_recovery.renew_code',
'password_recovery.count_send_code_maximum',
'password_recovery.no_code',
'password_recovery.no_answer',
'password_recovery.no_password',
'password_recovery.no_password_confirm',
'password_recovery.password_inconsistency',
'password_recovery.password_too_short'
);
$this->rc->output->set_pagetitle($this->gettext('recovery_password'));
$this->rc->output->add_gui_object('newpasswordform', 'new-password-form');
$password_minimum_length = ($this->use_password ? $this->rc->config->get('password_minimum_length',8) : $this->rc->config->get('pr_password_minimum_length',8));
$this->rc->output->set_env('pr_username', $this->user['username']);
$this->rc->output->set_env('pr_question', $this->user['question']);
$this->rc->output->set_env('pr_use_question', (bool) ($this->rc->config->get('pr_use_question') && $this->user['have_answer']));
$this->rc->output->set_env('pr_use_confirm_code', (bool) ($this->rc->config->get('pr_use_confirm_code') && $this->user['have_code']));
$this->rc->output->set_env('pr_password_minimum_length', (int) $password_minimum_length);
$this->rc->output->send('password_recovery.new_password_form');
}
// Renew and send confirmation code to user (to alternative email and phone)
private function renew_confirm_code() {
if ($this->get_confirm_code_count() < $this->rc->config->get('pr_confirm_code_count_max')) {
$result = $this->send->send_confirm_code_to_user();
if ($result['send']) {
$this->update_confirm_code_count(1);
$message = $result['message'];
$type = 'confirmation';
} else {
$message = $this->gettext('send_failed') . "\n" . $result['message'];
$type = 'error';
}
} else {
$message = $this->gettext('count_send_code_maximum');
$type = 'error';
}
$this->rc->output->command('display_message', $message, $type);
$this->rc->output->send('plugin');
}
// Creating and send confirmation code to user (to alternative email and phone) or send message to administrator
private function reset_password() {
// kill remember_me cookies
setcookie ('rememberme_user', '', time()-3600);
setcookie ('rememberme_pass', '', time()-3600);
$allow_answer = ($this->rc->config->get('pr_use_question') && $this->user['have_answer']);
$allow_code = ($this->rc->config->get('pr_use_confirm_code') && $this->user['have_code']);
if (!$this->user['username']) {
$message = $this->gettext('user_not_found');
$type = 'error';
} else if (!$allow_answer && !$allow_code) {
$this->send->send_alert_to_admin($this->user['username']);
$message = $this->gettext('sent_to_admin');
$type = 'error';
} else if ($allow_code) {
$result['send'] = false;
if ($this->user['token_validity'] && !$this->user['token_expired']) {
$this->update_confirm_code_count(1);
$result['send'] = true;
$message = $this->gettext('check_account_notice');
$type = 'notice';
} else {
$result = $this->send->send_confirm_code_to_user();
if ($result['send']) {
$this->update_confirm_code_count(1);
$message = $result['message'];
$type = 'confirmation';
} else {
$message = $this->gettext('send_failed') . "\n" . $result['message'];
$type = 'error';
}
}
}
$this->logging("Password recovery request for '" . $this->user['username'] . "' (IP: " . rcube_utils::remote_addr() . ")");
if ($message) {
$this->rc->output->command('display_message', $message, $type);
}
if ($type == 'error') {
$this->recovery_password_form();
} else {
$this->new_password_form();
}
}
// Save new password to DB
private function save_password() {
$params = rcube_utils::request2param(rcube_utils::INPUT_POST);
$this->debug("Save new password: " . print_r($params, true));
if ($this->rc->config->get('pr_use_question') && $this->user['have_answer'] && $this->user['answer'] != $params['answer']) {
$message = $this->gettext('answer_failed');
$type = 'error';
} else if ($this->rc->config->get('pr_use_confirm_code') && $this->user['token_expired']) {
$message = $this->gettext('code_expired');
$type = 'error';
} else if ($this->rc->config->get('pr_use_confirm_code') && $this->user['token'] != $params['code']) {
$message = $this->gettext('code_failed');
$type = 'error';
} else {
// props to save
$save = ['token'=>'', 'token_validity'=>''];
// check allowed characters according to the configured 'password_charset' option
// by converting the password entered by the user to this charset and back to UTF-8
$rc_charset = strtoupper($this->rc->output->get_charset());
$orig_pwd = $params['newpassword'];
$chk_pwd = rcube_charset::convert($orig_pwd, $rc_charset, 'UTF-8');
$chk_pwd = rcube_charset::convert($chk_pwd, 'UTF-8', $rc_charset);
// We're doing this for consistence with Roundcube core
$newpassword = rcube_charset::convert($params['newpassword'], $rc_charset, 'UTF-8');
if ($chk_pwd != $orig_pwd || preg_match('/[\x00-\x1F\x7F]/', $newpassword)) {
$message = $this->gettext('password_forbidden');
$type = 'error';
} else if (!$this->use_password && ($chk_strength = $this->pwd->_check_strength($newpassword))) {
$message = $chk_strength;
$type = 'error';
} else {
if ($this->use_password) {
$result = $this->pwd->_save($newpassword, $this->user['username']);
if ($result != 0) {
$message = $this->gettext('write_failed') . ": " . $result;
$type = 'error';
}
} else {
$save['password'] = crypt($newpassword, '$1$' . rcube_utils::random_bytes(9));
}
if ($type != 'error' && $this->set_user_props($save)) {
$this->logging("Save new password for '" . $this->user['username'] . "' (IP: " . rcube_utils::remote_addr() . ")");
$message = $this->gettext('password_changed');
$type = 'confirmation';
} else if (!$this->use_password) {
$message = $this->gettext('password_not_changed');
$type = 'error';
}
}
}
$this->rc->output->command('display_message', $message, $type);
if ($type != 'error') {
$this->rc->kill_session();
$this->rc->output->command('redirect', './', 2);
// $this->rc->output->send('login');
}
}
/*******************
* IDENTITIES
*******************/
// Verifying the user identities needed to recover the password
function check_identities() {
if (!isset($_SESSION['show_notice_identities']) && !$this->user['have_altemail'] && !$this->user['have_phone']) {
$link = "<a href='./?_task=settings&_action=identities'>". $this->gettext('click_here') ."</a>";
$this->rc->output->command('display_message', sprintf($this->gettext('no_identities'), $link), 'notice');
$_SESSION['show_notice_identities'] = true;
}
}
// Handler for 'identity_form' hook (executed on identities form create)
function identity_form($p) {
if (isset($p['form']['addressing']) && !empty($p['record']['identity_id'])) {
$new_fields = [];
foreach ($p['form']['addressing']['content'] as $col => $colprop) {
$new_fields[$col] = $colprop;
if ($col == 'email') {
// add ext fields after 'email'
foreach ($this->fields as $field => $field_name){
$new_fields[$field] = ['type' => 'text', 'size' => 40, 'label' => $this->gettext($field)];
}
}
}
if (!$this->rc->config->get('pr_use_question')) {
unset($new_fields['question']);
unset($new_fields['answer']);
}
$p['form']['addressing']['content'] = $new_fields;
if($this->user['username']){
foreach ($this->fields as $field => $field_name){
$p['record'][$field] = $this->user[$field];
}
}
}
return $p;
}
// Handler for identity_update hook (executed on identities form submit)
function identity_update($p) {
$save = [];
foreach ($this->fields as $field => $field_name) {
$save[$field] = rcube_utils::get_input_value("_".$field,rcube_utils::INPUT_POST);
}
foreach ($save as $par => $val) {
if ((!$this->user['username'] && empty($val)) || ($this->user['username'] && $val == $this->user[$par])) {
unset($save[$par]);
}
}
if ($save['altemail']) {
$save['altemail'] = rcube_utils::idn_to_ascii($save['altemail']);
if (empty($save['altemail'])) {
$this->rc->output->command('display_message', $this->gettext('altemail_cleared'), 'confirmation');
} else if($save['altemail'] == $p['record']['email']) {
unset($save['altemail']);
$p['abort'] = true;
$p['message'] = $this->gettext('altemail_match_primary');
return $p;
} else if(!rcube_utils::check_email($save['altemail'])) {
unset($save['altemail']);
$p['abort'] = true;
$p['message'] = $this->gettext('altemail_invalid');
return $p;
}
}
if (count($save)) {
$this->debug("Save user identities: " . print_r($save, true));
$this->set_user_props($save);
}
return $p;
}
// Return array - user props (username must be user@domain.ltd)
function get_user_props($username = null, $with_alias = true) {
if (!$username) {
$username = ($this->rc->get_user_name() ? $this->rc->get_user_name() : rcube_utils::get_input_value("_username", rcube_utils::INPUT_GPC));
}
$ret = [];
$user = trim(urldecode($username));
if ($user) {
// get user row
$query = "SELECT u.user_id, u.username, i.email" .
" FROM users u" .
" INNER JOIN identities i ON i.user_id = u.user_id" .
" WHERE username=?";
$result = $this->rc->db->query($query, $user);
if ($result && ($arr = $this->rc->db->fetch_assoc($result))) {
$fields = [];
foreach ($this->fields as $field => $field_name) {
$fields[] = $field_name . " as " . $field;
}
$query = "SELECT " . implode(",",$fields) . ", token, token_validity, token='' OR token_validity < NOW() as token_expired" .
" FROM " . $this->rc->config->get('pr_users_table') .
" WHERE username=?";
$result = $this->db->query($query, $arr['email']);
$ret = array_merge($arr, $this->db->fetch_assoc($result));
} else {
// for alias (with users_alias plugin)
if ($with_alias && $this->rc->plugins->load_plugin('users_alias', true)) {
$users_alias = new users_alias($this->api);
$result = $users_alias->alias2user(['user' => $user]);
if ($result['user']) {
$ret = $this->get_user_props($result['user'], false);
}
}
}
$_SESSION['username'] = $ret['username']; //for password plugin
$ret['have_altemail'] = ($ret['altemail'] && !empty($ret['altemail']));
$ret['have_phone'] = ($ret['phone'] && !empty($ret['phone']));
$ret['have_code'] = ($ret['have_altemail'] || $ret['have_phone']);
$ret['have_answer'] = ($ret['question'] && !empty($ret['question']) && $ret['answer'] && !empty($ret['answer']));
}
$this->user = $ret;
return $ret;
}
// Save user props to DB
function set_user_props($props) {
$fields = [];
foreach ($this->fields as $field => $field_name) {
if (isset($props[$field])) {
$fields[] = $field_name . " = '" . $props[$field] . "'";
}
}
if (isset($props['token'])) {
if (empty($props['token'])) {
$code_validity_time = 0;
} else {
$code_validity_time = (int) $this->rc->config->get('pr_confirm_code_validity_time', 30);
}
$fields[] = "token = '" . $props['token'] . "', token_validity = NOW() + INTERVAL " . $code_validity_time . " MINUTE";
}
if ($props['password']) {
$fields[] = "password = '" . $props['password'] . "'";
}
if (count($fields)) {
$query = "UPDATE " . $this->rc->config->get('pr_users_table') . " SET " . implode(",",$fields) . " WHERE username=?";
$this->db->query($query, $this->user['username']);
$this->get_user_props(); //update user props
$this->debug("Update user '" . $this->user['username'] . "' props: " . print_r($fields, true));
return $this->db->affected_rows() == 1;
}
return false;
}
function update_confirm_code_count($plus = 0) {
$count = $this->get_confirm_code_count() + $plus;
$_SESSION['pr_confirm_code_count'] = $count;
}
function get_confirm_code_count() {
if (!isset($_SESSION['pr_confirm_code_count'])) {
$_SESSION['pr_confirm_code_count'] = 0;
}
return $_SESSION['pr_confirm_code_count'];
}
/*******************
* service functions
*******************/
function get_dbh() {
if (!$this->db) {
if ($dsn = $this->rc->config->get('pr_db_dsn')) {
$this->db = rcube_db::factory($dsn);
$this->db->set_debug((bool)$this->rc->config->get('sql_debug'));
}
else {
$this->db = $this->rc->get_dbh();
}
}
return $this->db;
}
function get_action() {
$action = rcube_utils::get_input_value('_a', rcube_utils::INPUT_GPC);
if (!$action || empty($action)) {
$action = 'init';
}
return $action;
}
function logging($text) {
if ($this->rc->config->get('pr_password_log') == true) {
rcube::write_log('password', $text);
}
}
function debug($text) {
if ($this->rc->config->get('pr_debug') == true) {
$msg = (is_array($text) ? print_r($text, true) : $text);
rcube::write_log('console', $msg);
}
}
}
?>