Provide email address verification feature (#2481)

* Add an email field to the profile page

I reuse the `mail_login` from the configuration. I'm not sure if it's
useful today (I would say it was used when Persona login was available).

A good improvement would be to rename `mail_login` into `email` so it
would be more intuitive to use.

* Add boolean to the conf to force email validation

This commit only adds a configuration item.

* Add email during registration if email must be validated

* Set email token to validate when email changes

* Block access to FreshRSS if email is not validated

* Send email when address is changed

* Allow to resend the validation email

* Allow the user to change its email while blocked

* Document the email validation feature

* fixup! Allow the user to change its email while blocked

* tec: Autoload PHPMailer lib

* Validate email address format

* Add feedback on validation email resend action

* Allow to logout when user is blocked

* fix: Change default email "from"

* Reorganize i18n keys

* Complete all the locales with default english

* Hide sidebar (profile page) if email is not validated

* Check email requirements on registration

* Allow admin to specify email when creating users

* Don't check email format if value is empty

* Remove trailing comma in userController

Co-Authored-By: Alexandre Alapetite <alexandre@alapetite.fr>

* Set PHPMailer validator to html5 before sending email

* fixup! Remove trailing comma in userController
This commit is contained in:
Marien Fressinaud
2019-08-29 12:02:05 +02:00
committed by Alexandre Alapetite
parent ad44ff8169
commit 75632e70f0
81 changed files with 1002 additions and 18 deletions

View File

@@ -33,12 +33,23 @@ class FreshRSS_user_Controller extends Minz_ActionController {
return false;
}
public static function updateUser($user, $passwordPlain, $apiPasswordPlain, $userConfigUpdated = array()) {
public static function updateUser($user, $email, $passwordPlain, $apiPasswordPlain, $userConfigUpdated = array()) {
$userConfig = get_user_configuration($user);
if ($userConfig === null) {
return false;
}
if ($email !== null && $userConfig->mail_login !== $email) {
$userConfig->mail_login = $email;
if (FreshRSS_Context::$system_conf->force_email_validation) {
$salt = FreshRSS_Context::$system_conf->salt;
$userConfig->email_validation_token = sha1($salt . uniqid(mt_rand(), true));
$mailer = new FreshRSS_User_Mailer();
$mailer->send_email_need_validation($user, $userConfig);
}
}
if ($passwordPlain != '') {
$passwordHash = self::hashPassword($passwordPlain);
$userConfig->passwordHash = $passwordHash;
@@ -84,7 +95,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
$apiPasswordPlain = Minz_Request::param('apiPasswordPlain', '', true);
$username = Minz_Request::param('username');
$ok = self::updateUser($username, $passwordPlain, $apiPasswordPlain, array(
$ok = self::updateUser($username, null, $passwordPlain, $apiPasswordPlain, array(
'token' => Minz_Request::param('token', null),
));
@@ -111,25 +122,58 @@ class FreshRSS_user_Controller extends Minz_ActionController {
Minz_Error::error(403);
}
$email_not_verified = FreshRSS_Context::$user_conf->email_validation_token !== '';
if ($email_not_verified) {
$this->view->_layout('simple');
$this->view->disable_aside = true;
}
Minz_View::prependTitle(_t('conf.profile.title') . ' · ');
Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')));
if (Minz_Request::isPost()) {
$system_conf = FreshRSS_Context::$system_conf;
$user_config = FreshRSS_Context::$user_conf;
$old_email = $user_config->mail_login;
$email = trim(Minz_Request::param('email', ''));
$passwordPlain = Minz_Request::param('newPasswordPlain', '', true);
Minz_Request::_param('newPasswordPlain'); //Discard plain-text password ASAP
$_POST['newPasswordPlain'] = '';
$apiPasswordPlain = Minz_Request::param('apiPasswordPlain', '', true);
$ok = self::updateUser(Minz_Session::param('currentUser'), $passwordPlain, $apiPasswordPlain, array(
if ($system_conf->force_email_validation && empty($email)) {
Minz_Request::bad(
_t('user.email.feedback.required'),
array('c' => 'user', 'a' => 'profile')
);
}
if (!empty($email) && !validateEmailAddress($email)) {
Minz_Request::bad(
_t('user.email.feedback.invalid'),
array('c' => 'user', 'a' => 'profile')
);
}
$ok = self::updateUser(
Minz_Session::param('currentUser'),
$email,
$passwordPlain,
$apiPasswordPlain,
array(
'token' => Minz_Request::param('token', null),
));
)
);
Minz_Session::_param('passwordHash', FreshRSS_Context::$user_conf->passwordHash);
if ($ok) {
if ($passwordPlain == '') {
if ($system_conf->force_email_validation && $email !== $old_email) {
Minz_Request::good(_t('feedback.profile.updated'), array('c' => 'user', 'a' => 'validateEmail'));
} elseif ($passwordPlain == '') {
Minz_Request::good(_t('feedback.profile.updated'), array('c' => 'user', 'a' => 'profile'));
} else {
Minz_Request::good(_t('feedback.profile.updated'), array('c' => 'index', 'a' => 'index'));
@@ -151,6 +195,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
Minz_View::prependTitle(_t('admin.user.title') . ' · ');
$this->view->show_email_field = FreshRSS_Context::$system_conf->force_email_validation;
$this->view->current_user = Minz_Request::param('u');
$this->view->nb_articles = 0;
@@ -165,7 +210,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
}
}
public static function createUser($new_user_name, $passwordPlain, $apiPasswordPlain, $userConfig = array(), $insertDefaultFeeds = true) {
public static function createUser($new_user_name, $email, $passwordPlain, $apiPasswordPlain, $userConfig = array(), $insertDefaultFeeds = true) {
if (!is_array($userConfig)) {
$userConfig = array();
}
@@ -193,7 +238,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
if ($ok) {
$userDAO = new FreshRSS_UserDAO();
$ok &= $userDAO->createUser($new_user_name, $userConfig['language'], $insertDefaultFeeds);
$ok &= self::updateUser($new_user_name, $passwordPlain, $apiPasswordPlain);
$ok &= self::updateUser($new_user_name, $email, $passwordPlain, $apiPasswordPlain);
}
return $ok;
}
@@ -204,6 +249,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
* Request parameters are:
* - new_user_language
* - new_user_name
* - new_user_email
* - new_user_passwordPlain
* - r (i.e. a redirection url, optional)
*
@@ -216,11 +262,28 @@ class FreshRSS_user_Controller extends Minz_ActionController {
}
if (Minz_Request::isPost()) {
$system_conf = FreshRSS_Context::$system_conf;
$new_user_name = Minz_Request::param('new_user_name');
$email = Minz_Request::param('new_user_email', '');
$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
$ok = self::createUser($new_user_name, $passwordPlain, '', array('language' => $new_user_language));
if ($system_conf->force_email_validation && empty($email)) {
Minz_Request::bad(
_t('user.email.feedback.required'),
array('c' => 'auth', 'a' => 'register')
);
}
if (!empty($email) && !validateEmailAddress($email)) {
Minz_Request::bad(
_t('user.email.feedback.invalid'),
array('c' => 'auth', 'a' => 'register')
);
}
$ok = self::createUser($new_user_name, $email, $passwordPlain, '', array('language' => $new_user_language));
Minz_Request::_param('new_user_passwordPlain'); //Discard plain-text password ASAP
$_POST['new_user_passwordPlain'] = '';
invalidateHttpCache();
@@ -272,6 +335,122 @@ class FreshRSS_user_Controller extends Minz_ActionController {
return $ok;
}
/**
* This action validates an email address, based on the token sent by email.
* It also serves the main page when user is blocked.
*
* Request parameters are:
* - username
* - token
*
* This route works with GET requests since the URL is provided by email.
* The security risks (e.g. forged URL by an attacker) are not very high so
* it's ok.
*
* It returns 404 error if `force_email_validation` is disabled or if the
* user doesn't exist.
*
* It returns 403 if user isn't logged in and `username` param isn't passed.
*/
public function validateEmailAction() {
if (!FreshRSS_Context::$system_conf->force_email_validation) {
Minz_Error::error(404);
}
Minz_View::prependTitle(_t('user.email.validation.title') . ' · ');
$this->view->_layout('simple');
$username = Minz_Request::param('username');
$token = Minz_Request::param('token');
if ($username) {
$user_config = get_user_configuration($username);
} elseif (FreshRSS_Auth::hasAccess()) {
$user_config = FreshRSS_Context::$user_conf;
} else {
Minz_Error::error(403);
}
if (!FreshRSS_UserDAO::exists($username) || $user_config === null) {
Minz_Error::error(404);
}
if ($user_config->email_validation_token === '') {
Minz_Request::good(
_t('user.email.validation.feedback.unnecessary'),
array('c' => 'index', 'a' => 'index')
);
}
if ($token) {
if ($user_config->email_validation_token !== $token) {
Minz_Request::bad(
_t('user.email.validation.feedback.wrong_token'),
array('c' => 'user', 'a' => 'validateEmail')
);
}
$user_config->email_validation_token = '';
if ($user_config->save()) {
Minz_Request::good(
_t('user.email.validation.feedback.ok'),
array('c' => 'index', 'a' => 'index')
);
} else {
Minz_Request::bad(
_t('user.email.validation.feedback.error'),
array('c' => 'user', 'a' => 'validateEmail')
);
}
}
}
/**
* This action resends a validation email to the current user.
*
* It only acts on POST requests but doesn't require any param (except the
* CSRF token).
*
* It returns 403 error if the user is not logged in or 404 if request is
* not POST. Else it redirects silently to the index if user has already
* validated its email, or to the user#validateEmail route.
*/
public function sendValidationEmailAction() {
if (!FreshRSS_Auth::hasAccess()) {
Minz_Error::error(403);
}
if (!Minz_Request::isPost()) {
Minz_Error::error(404);
}
$username = Minz_Session::param('currentUser', '_');
$user_config = FreshRSS_Context::$user_conf;
if ($user_config->email_validation_token === '') {
Minz_Request::forward(array(
'c' => 'index',
'a' => 'index',
), true);
}
$mailer = new FreshRSS_User_Mailer();
$ok = $mailer->send_email_need_validation($username, $user_config);
$redirect_url = array('c' => 'user', 'a' => 'validateEmail');
if ($ok) {
Minz_Request::good(
_t('user.email.validation.feedback.email_sent'),
$redirect_url
);
} else {
Minz_Request::bad(
_t('user.email.validation.feedback.email_failed'),
$redirect_url
);
}
}
/**
* This action delete an existing user.
*