Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b289f09
Add an email field to the profile page
marienfressinaud Jul 29, 2019
73de0d2
Add boolean to the conf to force email validation
marienfressinaud Jul 29, 2019
80483a7
Add email during registration if email must be validated
marienfressinaud Jul 29, 2019
66fe611
Set email token to validate when email changes
marienfressinaud Jul 30, 2019
fd26629
Block access to FreshRSS if email is not validated
marienfressinaud Aug 1, 2019
1df3f6e
Send email when address is changed
marienfressinaud Aug 2, 2019
1a77a64
Allow to resend the validation email
marienfressinaud Aug 6, 2019
48e073b
Allow the user to change its email while blocked
marienfressinaud Aug 6, 2019
d037d9a
Document the email validation feature
marienfressinaud Aug 6, 2019
4682c2b
fixup! Allow the user to change its email while blocked
marienfressinaud Aug 17, 2019
efbf405
tec: Autoload PHPMailer lib
marienfressinaud Aug 21, 2019
7fdb345
Validate email address format
marienfressinaud Aug 21, 2019
129301e
Add feedback on validation email resend action
marienfressinaud Aug 22, 2019
4087289
Allow to logout when user is blocked
marienfressinaud Aug 22, 2019
320ba6a
fix: Change default email "from"
marienfressinaud Aug 22, 2019
0a07ff8
Reorganize i18n keys
marienfressinaud Aug 28, 2019
bc80eca
Complete all the locales with default english
marienfressinaud Aug 28, 2019
326c26f
Hide sidebar (profile page) if email is not validated
marienfressinaud Aug 28, 2019
986d04f
Don't check email format if value is empty
marienfressinaud Aug 28, 2019
2aa28f5
Check email requirements on registration
marienfressinaud Aug 28, 2019
2cde74d
Allow admin to specify email when creating users
marienfressinaud Aug 28, 2019
0e34cd2
Remove trailing comma in userController
marienfressinaud Aug 29, 2019
4a6b37a
Set PHPMailer validator to html5 before sending email
marienfressinaud Aug 29, 2019
a3031d2
fixup! Remove trailing comma in userController
marienfressinaud Aug 29, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Controllers/authController.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ public function registerAction() {
Minz_Error::error(403);
}

$this->view->show_email_field = FreshRSS_Context::$system_conf->force_email_validation;
Minz_View::prependTitle(_t('gen.auth.registration.title') . ' · ');
}
}
12 changes: 12 additions & 0 deletions app/Controllers/configureController.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,15 +293,24 @@ public function addQueryAction() {
* configuration values then sends a notification to the user.
*
* The options available on the page are:
* - instance name (default: FreshRSS)
* - auto update URL (default: false)
* - force emails validation (default: false)
* - user limit (default: 1)
* - user category limit (default: 16384)
* - user feed limit (default: 16384)
* - user login duration for form auth (default: 2592000)
*
* The `force-email-validation` is ignored with PHP < 5.5
*/
public function systemAction() {
if (!FreshRSS_Auth::hasAccess('admin')) {
Minz_Error::error(403);
}

$can_enable_email_validation = version_compare(PHP_VERSION, '5.5') >= 0;
$this->view->can_enable_email_validation = $can_enable_email_validation;

if (Minz_Request::isPost()) {
$limits = FreshRSS_Context::$system_conf->limits;
$limits['max_registrations'] = Minz_Request::param('max-registrations', 1);
Expand All @@ -311,6 +320,9 @@ public function systemAction() {
FreshRSS_Context::$system_conf->limits = $limits;
FreshRSS_Context::$system_conf->title = Minz_Request::param('instance-name', 'FreshRSS');
FreshRSS_Context::$system_conf->auto_update_url = Minz_Request::param('auto-update-url', false);
if ($can_enable_email_validation) {
FreshRSS_Context::$system_conf->force_email_validation = Minz_Request::param('force-email-validation', false);
}
FreshRSS_Context::$system_conf->save();

invalidateHttpCache();
Expand Down
195 changes: 187 additions & 8 deletions app/Controllers/userController.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,23 @@ public static function deleteFeverKey($username) {
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) {
Copy link
Copy Markdown
Member

@Alkarex Alkarex Aug 15, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$email != '' would be safer here, especially since it might come from Minz_Request::param('email', '');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was on purpose since the email should not be required when force_email_validation is false. But I realize that I should enforce the value if it's true.

$userConfig->mail_login = $email;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere we need to sanitize this user input. Either here or in profileAction().
I suggest looking at https://php.net/filter.filters.validate FILTER_VALIDATE_EMAIL / FILTER_SANITIZE_EMAIL

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would simply check that the email contains a @. Maybe we can strip the string too. But emails are hard to validate and it's almost always badly done (more here). And I'm not comfortable with that:

In general, this validates e-mail addresses against the syntax in RFC 822, with the exceptions that comments and whitespace folding and dotless domain names are not supported.

I don't understand why they would respect the RFC, but in fact not…

The feature itself is about validating that the address is valid by sending an email so I don't think we have to be too strict.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's definitely very odd about dotless domains. The whitespace & comments things is more common. For example, the W3C recommends this for input type=email:

This requirement is a willful violation of RFC 5322, which defines a syntax for e-mail addresses that is simultaneously too strict (before the "@" character), too vague (after the "@" character), and too lax (allowing comments, whitespace characters, and quoted strings in manners unfamiliar to most users) to be of practical use here.

/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are still some classes of characters, which should be discarded, and potentially also a min and max length. I think it is dangerous to pass some user input to the backend without any form of sanitization. If you do not like the PHP built-in validation (which I personally think would be reasonable - I an not even sure the rest of the back-end is able to handle what the PHP validation rejects anyway), then at least the sanitize filter FILTER_SANITIZE_EMAIL https://php.net/manual/filter.filters.sanitize

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll take a look to clean at least a bit this param!

Copy link
Copy Markdown
Member

@Alkarex Alkarex Aug 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I did not realise that the HTML5 version does not allow UTF-8 before the @ 😢

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, it seems that the conclusion is to use the PHPMailer's pcre8 option as @Frenzie suggested higher up

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into the problem! (I was finishing my holidays :)) I didn't thought that PHPMailer could have a verification function (sounds obvious now!) I'll try to finish this tomorrow 👍

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like none of the patterns allow internationalized email addresses (not even pcre8). I'm also very surprised that Firefox or Thunderbird don't allow UTF-8 before the @ either… Is there any server or client that fully support UTF-8 addresses?!

I found that PHPMailer provides a punyencodeAddress function which convert only the domain, that's a start. So I'll convert the address to punycode first and validate the address against this value. Then I can either store the value as punycode (but I'll have to transform it again when displaying the address) or store the utf-8 version (easier, and it should be ok since PHPMailer transforms all the addresses before sending the email)

Copy link
Copy Markdown
Member

@Frenzie Frenzie Aug 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any server or client that fully support UTF-8 addresses?!

Sure, Microsoft has announced support, this one does http://www.postfix.org/SMTPUTF8_README.html

There's an incomplete list on Wikipedia:
https://en.wikipedia.org/wiki/Extended_SMTP#SMTPUTF8

Edit: to be clear, that list is from like 4 years ago and even then I doubt it was complete.


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;
Expand Down Expand Up @@ -87,7 +98,7 @@ public function updateAction() {
$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),
));

Expand All @@ -114,25 +125,58 @@ public function profileAction() {
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'));
Expand All @@ -154,6 +198,7 @@ public function manageAction() {

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;
Expand All @@ -168,7 +213,7 @@ public function manageAction() {
}
}

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();
}
Expand Down Expand Up @@ -196,7 +241,7 @@ public static function createUser($new_user_name, $passwordPlain, $apiPasswordPl
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;
}
Expand All @@ -207,6 +252,7 @@ public static function createUser($new_user_name, $passwordPlain, $apiPasswordPl
* Request parameters are:
* - new_user_language
* - new_user_name
* - new_user_email
* - new_user_passwordPlain
* - r (i.e. a redirection url, optional)
*
Expand All @@ -219,11 +265,28 @@ public function createAction() {
}

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();
Expand Down Expand Up @@ -275,6 +338,122 @@ public static function deleteUser($username) {
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.
*
Expand Down
18 changes: 18 additions & 0 deletions app/FreshRSS.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ public function init() {
Minz_ExtensionManager::enableByList($ext_list);
}

self::checkEmailValidated();

Minz_ExtensionManager::callHook('freshrss_init');
}

Expand Down Expand Up @@ -144,4 +146,20 @@ public static function preLayout() {
FreshRSS_Share::load(join_path(APP_PATH, 'shares.php'));
self::loadStylesAndScripts();
}

private static function checkEmailValidated() {
$email_not_verified = FreshRSS_Auth::hasAccess() && FreshRSS_Context::$user_conf->email_validation_token !== '';
$action_is_allowed = (
Minz_Request::is('user', 'validateEmail') ||
Minz_Request::is('user', 'sendValidationEmail') ||
Minz_Request::is('user', 'profile') ||
Minz_Request::is('auth', 'logout')
);
if ($email_not_verified && !$action_is_allowed) {
Minz_Request::forward(array(
'c' => 'user',
'a' => 'validateEmail',
), true);
}
}
}
31 changes: 31 additions & 0 deletions app/Mailers/UserMailer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/**
* Manage the emails sent to the users.
*/
class FreshRSS_User_Mailer extends Minz_Mailer {
public function send_email_need_validation($username, $user_config) {
$this->view->_path('user_mailer/email_need_validation.txt');

$this->view->username = $username;
$this->view->site_title = FreshRSS_Context::$system_conf->title;
$this->view->validation_url = Minz_Url::display(
array(
'c' => 'user',
'a' => 'validateEmail',
'params' => array(
'username' => $username,
'token' => $user_config->email_validation_token
)
),
'txt',
true
);

$subject_prefix = '[' . FreshRSS_Context::$system_conf->title . ']';
return $this->mail(
$user_config->mail_login,
$subject_prefix . ' ' ._t('user.mailer.email_need_validation.title')
);
}
}
4 changes: 4 additions & 0 deletions app/Models/ConfigurationSetter.php
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,8 @@ private function _auto_update_url(&$data, $value) {

$data['auto_update_url'] = $value;
}

private function _force_email_validation(&$data, $value) {
$data['force_email_validation'] = $this->handleBool($value);
}
}
Loading