mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-11-05 00:52:57 -05:00
* Replace bcrypt challenge auth with direct password comparison Closes #2428 Highlights: * Replaced the bcrypt challenge authentication flow with direct password comparison against the bcrypt hash stored in the user configuration. * Removed the `bcrypt.js` dependency, so authentication no longer depends on JavaScript. * Removed the nonce and challenge mechanism and the `nonce` endpoint from the `javascript` controller. * Removed the JavaScript and HTML code used for the previous authentication flow. * Removed the `<noscript>` blocks that were only needed for the previous authentication flow. * Updated `FreshRSS_FormAuth::checkCredentials()` to accept the username, stored bcrypt hash, and plain-text password directly. * The stored password hash must still use the expected bcrypt format. * Empty or whitespace-only passwords are rejected even if the hash would otherwise match. * The bcrypt 72-byte limit is enforced during normal authentication. * Existing bcrypt passwords are handled correctly when the 72-byte limit cuts through a UTF-8 character. * Password input is no longer trimmed before authentication or password changes. Leading and trailing whitespace is therefore kept as part of the password. * Added `FreshRSS_FormAuth::passwordRequirementsMet()` to replace `FreshRSS_password_Util::check()`. * New passwords must be between 7 and 72 bytes by default. * The minimum length check can be disabled where needed. * Password requirements are now checked server-side for all password creation and update paths. * Added client-side byte-length validation for password fields using `TextEncoder` and `data-max-bytes`. * This gives better feedback for UTF-8 passwords. * Server-side validation remains authoritative. * Updated password handling in authentication, re-authentication, self-deletion, user creation, and user update paths so passwords are not trimmed automatically. * Added `FormAuthTest` to cover: * password length limits in bytes * UTF-8 passwords around the 72-byte limit * bcrypt truncation behavior during authentication * malformed and non-bcrypt hashes * empty passwords * authentication failures and log messages * Removed `bcrypt.js` from the documented dependencies and updated the authentication documentation. * Updated the installation and authentication views and JavaScript to match the new password handling. This simplifies the authentication flow, removes the client-side bcrypt challenge, makes password validation consistent between the client and server, and adds more test coverage while keeping compatibility with existing bcrypt password hashes. Because the password is now sent directly to the server instead of being processed by `bcrypt.js`, deployments should use HTTPS. Documentation for HTTPS setup can be handled separately. * Add test case with password truncated down to 71 characters * Compare log messages too during form auth tests * Mark the `getLastLog()` method as impure * Fix: Check password reqs against new password instead of current * Fix: Add custom JS validator for UTF-8, handle cut-off UTF-8 during login * Fix: Enforce password requirements everywhere server-side * Add TODO for updateUser in CLI * make readme * Fix extra.js in install.php * Don't report validity yet if the password is too short or not filled out * Simplify checking of whether to report validity * Fix: Do not trim password from user's input * Also `trim: false` in some userController paths * Use the proper argument order for PHPUnit's `assertSame()` * Remove `maxlength` in favor of `data-max-bytes` But add `pattern` again for some nojs support * make readme * composer run-script translations * Update i18n string for web form admin setting * i18n: fr * Minor whitespace --------- Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
858 lines
26 KiB
PHP
858 lines
26 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Controller to handle user actions.
|
||
*/
|
||
class FreshRSS_user_Controller extends FreshRSS_ActionController {
|
||
/**
|
||
* The username is also used as folder name, file name, and part of SQL table name.
|
||
* '_' is a reserved internal username.
|
||
*/
|
||
public const USERNAME_PATTERN = '([0-9a-zA-Z_][0-9a-zA-Z_.@\-]{1,38}|[0-9a-zA-Z])';
|
||
|
||
public static function checkUsername(string $username): bool {
|
||
return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1;
|
||
}
|
||
|
||
/**
|
||
* Validate an email address, supports internationalized addresses.
|
||
*
|
||
* @param string $email The address to validate
|
||
* @return bool true if email is valid, else false
|
||
*/
|
||
private static function validateEmailAddress(string $email): bool {
|
||
$mailer = new PHPMailer\PHPMailer\PHPMailer();
|
||
$mailer->CharSet = 'utf-8';
|
||
$punyemail = $mailer->punyencodeAddress($email);
|
||
return PHPMailer\PHPMailer\PHPMailer::validateAddress($punyemail, 'html5');
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
public static function listUsers(): array {
|
||
$final_list = [];
|
||
$base_path = join_path(DATA_PATH, 'users');
|
||
$dir_list = array_values(array_diff(
|
||
scandir($base_path) ?: [],
|
||
['..', '.', Minz_User::INTERNAL_USER]
|
||
));
|
||
foreach ($dir_list as $file) {
|
||
if ($file[0] !== '.' && is_dir(join_path($base_path, $file)) && file_exists(join_path($base_path, $file, 'config.php'))) {
|
||
$final_list[] = $file;
|
||
}
|
||
}
|
||
return $final_list;
|
||
}
|
||
|
||
public static function userExists(string $username): bool {
|
||
$config_path = USERS_PATH . '/' . $username . '/config.php';
|
||
if (@file_exists($config_path)) {
|
||
return true;
|
||
} elseif (@file_exists($config_path . '.bak.php')) {
|
||
Minz_Log::warning('Config for user “' . $username . '” not found. Attempting to restore from backup.', ADMIN_LOG);
|
||
if (!copy($config_path . '.bak.php', $config_path)) {
|
||
@unlink($config_path);
|
||
return false;
|
||
}
|
||
return @file_exists($config_path);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Return if the maximum number of registrations has been reached.
|
||
* Note a max_registrations of 0 means there is no limit.
|
||
*
|
||
* @return bool true if number of users >= max registrations, false otherwise.
|
||
*/
|
||
public static function max_registrations_reached(): bool {
|
||
$limit_registrations = FreshRSS_Context::systemConf()->limits['max_registrations'];
|
||
$number_accounts = count(self::listUsers());
|
||
return $limit_registrations > 0 && $number_accounts >= $limit_registrations;
|
||
}
|
||
|
||
/** @param array<string,mixed> $userConfigUpdated */
|
||
public static function updateUser(string $user, ?string $email, #[\SensitiveParameter] string $passwordPlain, array $userConfigUpdated = []): bool {
|
||
$userConfig = FreshRSS_UserConfiguration::getForUser($user);
|
||
if ($userConfig === null) {
|
||
return false;
|
||
}
|
||
|
||
if ($email !== null && $userConfig->mail_login !== $email) {
|
||
$userConfig->mail_login = $email;
|
||
|
||
if (FreshRSS_Context::systemConf()->force_email_validation) {
|
||
$userConfig->email_validation_token = hash('sha256', FreshRSS_Context::systemConf()->salt . $email . random_bytes(32));
|
||
$mailer = new FreshRSS_User_Mailer();
|
||
$mailer->send_email_need_validation($user, $userConfig);
|
||
}
|
||
}
|
||
|
||
if ($passwordPlain != '') {
|
||
if (!FreshRSS_FormAuth::passwordRequirementsMet($passwordPlain, ['enforceMinLength' => true])) {
|
||
return false;
|
||
}
|
||
$passwordHash = FreshRSS_password_Util::hash($passwordPlain);
|
||
$userConfig->passwordHash = $passwordHash;
|
||
if ($user === Minz_User::name()) {
|
||
FreshRSS_Context::userConf()->passwordHash = $passwordHash;
|
||
}
|
||
}
|
||
|
||
foreach ($userConfigUpdated as $configName => $configValue) {
|
||
if ($configName !== '' && $configValue !== null) {
|
||
$userConfig->_attribute($configName, $configValue);
|
||
}
|
||
}
|
||
|
||
$ok = $userConfig->save();
|
||
return $ok;
|
||
}
|
||
|
||
public function updateAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (Minz_Request::isPost()) {
|
||
if (self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
$username = Minz_Request::paramString('username');
|
||
$newPasswordPlain = Minz_User::name() !== $username ? Minz_Request::paramString('newPasswordPlain', plaintext: true, trim: false) : '';
|
||
|
||
$ok = self::updateUser($username, null, $newPasswordPlain, [
|
||
'token' => Minz_Request::paramString('token') ?: null,
|
||
]);
|
||
|
||
if ($ok) {
|
||
$isSelfUpdate = Minz_User::name() === $username;
|
||
if ($newPasswordPlain == '' || !$isSelfUpdate) {
|
||
Minz_Request::good(
|
||
_t('feedback.user.updated', $username),
|
||
['c' => 'user', 'a' => 'manage'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Request::good(
|
||
_t('feedback.profile.updated'),
|
||
['c' => 'index', 'a' => 'index'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
}
|
||
} else {
|
||
Minz_Request::bad(_t('feedback.user.updated.error', $username), ['c' => 'user', 'a' => 'manage']);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This action displays the user profile page.
|
||
*/
|
||
public function profileAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess()) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
$email_not_verified = FreshRSS_Context::userConf()->email_validation_token != '';
|
||
$this->view->disable_aside = false;
|
||
if ($email_not_verified) {
|
||
$this->view->disable_aside = true;
|
||
}
|
||
|
||
FreshRSS_View::prependTitle(_t('conf.profile.title') . ' · ');
|
||
|
||
if (Minz_Request::isPost() && Minz_User::name() != null) {
|
||
$old_email = FreshRSS_Context::userConf()->mail_login;
|
||
|
||
$email = Minz_Request::paramString('email');
|
||
|
||
$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
|
||
$newPasswordPlain = Minz_Request::paramString('newPasswordPlain', plaintext: true, trim: false);
|
||
$confirmPasswordPlain = Minz_Request::paramString('confirmPasswordPlain', plaintext: true, trim: false);
|
||
if ($passwordPlain !== '') {
|
||
$username = Minz_User::name();
|
||
|
||
$passwordRequirementsMet = FreshRSS_FormAuth::passwordRequirementsMet(
|
||
$newPasswordPlain,
|
||
['enforceMinLength' => true]
|
||
);
|
||
|
||
// Currently the only requirement is checking whether the password length is within the range of 7-72 bytes.
|
||
// This limit is enforced in the form using a custom JS validator as well, so no custom error message is needed so far
|
||
// on the server side.
|
||
if (!$passwordRequirementsMet || !FreshRSS_FormAuth::checkCredentials(
|
||
$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
|
||
)) {
|
||
Minz_Session::_param('open', true); // Auto-expand `change password` section
|
||
Minz_Request::bad(
|
||
_t('feedback.auth.login.invalid'),
|
||
['c' => 'user', 'a' => 'profile']
|
||
);
|
||
return;
|
||
}
|
||
|
||
if ($confirmPasswordPlain !== $newPasswordPlain) {
|
||
Minz_Session::_param('open', true); // Auto-expand `change password` section
|
||
Minz_Request::bad(
|
||
_t('feedback.profile.passwords_dont_match'),
|
||
['c' => 'user', 'a' => 'profile']
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Minz_Session::regenerateID('FreshRSS');
|
||
} catch (RuntimeException $e) {
|
||
Minz_Log::error('Session could not be regenerated during password change! ' . $e->getMessage());
|
||
Minz_Request::bad(_t('install.session.nok'), ['c' => 'user', 'a' => 'profile']);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (FreshRSS_Context::systemConf()->force_email_validation && empty($email)) {
|
||
Minz_Request::bad(
|
||
_t('user.email.feedback.required'),
|
||
['c' => 'user', 'a' => 'profile']
|
||
);
|
||
}
|
||
|
||
if (!empty($email) && !self::validateEmailAddress($email)) {
|
||
Minz_Request::bad(
|
||
_t('user.email.feedback.invalid'),
|
||
['c' => 'user', 'a' => 'profile']
|
||
);
|
||
}
|
||
|
||
$ok = self::updateUser(
|
||
Minz_User::name(),
|
||
$email,
|
||
$newPasswordPlain,
|
||
[
|
||
'token' => Minz_Request::paramString('token'),
|
||
]
|
||
);
|
||
|
||
Minz_Session::_param('passwordHash', FreshRSS_Context::userConf()->passwordHash);
|
||
|
||
if ($ok) {
|
||
if (FreshRSS_Context::systemConf()->force_email_validation && $email !== $old_email) {
|
||
Minz_Request::good(
|
||
_t('feedback.profile.updated'),
|
||
['c' => 'user', 'a' => 'validateEmail'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Request::good(
|
||
_t('feedback.profile.updated'),
|
||
['c' => 'user', 'a' => 'profile'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
}
|
||
} else {
|
||
Minz_Request::bad(_t('feedback.profile.error'), ['c' => 'user', 'a' => 'profile']);
|
||
}
|
||
}
|
||
}
|
||
|
||
public static function reauthRedirect(): bool {
|
||
$url_redirect = [
|
||
'c' => 'user',
|
||
'a' => 'manage',
|
||
'params' => [],
|
||
];
|
||
$username = Minz_Request::paramStringNull('username');
|
||
if ($username !== null) {
|
||
$url_redirect['a'] = 'details';
|
||
$url_redirect['params']['username'] = $username;
|
||
}
|
||
return FreshRSS_Auth::requestReauth($url_redirect);
|
||
}
|
||
|
||
public function purgeAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (!Minz_Request::isPost()) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
$username = Minz_Request::paramString('username');
|
||
|
||
if (!FreshRSS_UserDAO::exists($username)) {
|
||
Minz_Error::error(404);
|
||
}
|
||
|
||
$feedDAO = FreshRSS_Factory::createFeedDao($username);
|
||
$feedDAO->purge();
|
||
}
|
||
|
||
/**
|
||
* This action displays the user management page.
|
||
*/
|
||
public function manageAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
FreshRSS_View::prependTitle(_t('admin.user.title') . ' · ');
|
||
|
||
if (Minz_Request::isPost()) {
|
||
$action = Minz_Request::paramString('action');
|
||
switch ($action) {
|
||
case 'delete':
|
||
$this->deleteAction();
|
||
break;
|
||
case 'update':
|
||
$this->updateAction();
|
||
break;
|
||
case 'purge':
|
||
$this->purgeAction();
|
||
break;
|
||
case 'promote':
|
||
$this->promoteAction();
|
||
break;
|
||
case 'demote':
|
||
$this->demoteAction();
|
||
break;
|
||
case 'enable':
|
||
$this->enableAction();
|
||
break;
|
||
case 'disable':
|
||
$this->disableAction();
|
||
break;
|
||
}
|
||
}
|
||
|
||
$this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation;
|
||
$this->view->current_user = Minz_Request::paramString('u');
|
||
|
||
$fast = false;
|
||
$startTime = time();
|
||
foreach (self::listUsers() as $user) {
|
||
if (!$fast && (time() - $startTime >= 3)) {
|
||
// Disable detailed user statistics if it takes too long, and will retrieve them asynchronously via JavaScript
|
||
$fast = true;
|
||
}
|
||
$this->view->users[$user] = $this->retrieveUserDetails($user, $fast);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $userConfigOverride
|
||
* @throws Minz_ConfigurationNamespaceException
|
||
* @throws Minz_PDOConnectionException
|
||
*/
|
||
public static function createUser(string $new_user_name, ?string $email, #[\SensitiveParameter] string $passwordPlain,
|
||
array $userConfigOverride = [], bool $insertDefaultFeeds = true): bool {
|
||
$userConfig = [];
|
||
|
||
$customUserConfigPath = join_path(DATA_PATH, 'config-user.custom.php');
|
||
if (file_exists($customUserConfigPath)) {
|
||
$customUserConfig = include $customUserConfigPath;
|
||
if (is_array($customUserConfig)) {
|
||
$userConfig = $customUserConfig;
|
||
}
|
||
}
|
||
|
||
$userConfig = array_merge($userConfig, $userConfigOverride);
|
||
|
||
$ok = self::checkUsername($new_user_name);
|
||
$homeDir = join_path(DATA_PATH, 'users', $new_user_name);
|
||
// create basepath if missing
|
||
if (!is_dir(join_path(DATA_PATH, 'users'))) {
|
||
$ok &= mkdir(join_path(DATA_PATH, 'users'), 0770, true);
|
||
}
|
||
$configPath = '';
|
||
|
||
if ($ok) {
|
||
if (!Minz_Translate::exists(is_string($userConfig['language'] ?? null) ? $userConfig['language'] : '')) {
|
||
$userConfig['language'] = Minz_Translate::DEFAULT_LANGUAGE;
|
||
}
|
||
|
||
$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', self::listUsers()), true); //Not an existing user, case-insensitive
|
||
|
||
$configPath = join_path($homeDir, 'config.php');
|
||
$ok &= !file_exists($configPath);
|
||
}
|
||
if ($ok) {
|
||
// $homeDir must not exist beforehand,
|
||
// otherwise it might be multiple remote parties racing to register one username
|
||
$ok = mkdir($homeDir, 0770, true);
|
||
if ($ok) {
|
||
$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
|
||
}
|
||
}
|
||
if ($ok) {
|
||
$newUserDAO = FreshRSS_Factory::createUserDao($new_user_name);
|
||
$ok &= $newUserDAO->createUser();
|
||
|
||
if ($ok && $insertDefaultFeeds) {
|
||
$opmlPath = DATA_PATH . '/opml.xml';
|
||
if (!file_exists($opmlPath)) {
|
||
$opmlPath = FRESHRSS_PATH . '/opml.default.xml';
|
||
}
|
||
$importController = new FreshRSS_importExport_Controller();
|
||
try {
|
||
$importController->importFile($opmlPath, $opmlPath, $new_user_name);
|
||
} catch (Exception $e) {
|
||
Minz_Log::error('Error while importing default OPML for user ' . $new_user_name . ': ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
$ok &= self::updateUser($new_user_name, $email, $passwordPlain);
|
||
}
|
||
return (bool)$ok;
|
||
}
|
||
|
||
/**
|
||
* This action creates a new user.
|
||
*
|
||
* Request parameters are:
|
||
* - new_user_language
|
||
* - new_user_name
|
||
* - new_user_email
|
||
* - new_user_passwordPlain
|
||
* - r (i.e. a redirection url, optional)
|
||
*
|
||
* @todo clean up this method. Idea: write a method to init a user with basic information.
|
||
*/
|
||
public function createAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin') && self::max_registrations_reached()) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (FreshRSS_Auth::hasAccess('admin') && self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
if (Minz_Request::isPost()) {
|
||
$new_user_name = Minz_Request::paramString('new_user_name');
|
||
$email = Minz_Request::paramString('new_user_email');
|
||
$passwordPlain = Minz_Request::paramString('new_user_passwordPlain', plaintext: true, trim: false);
|
||
$badRedirectUrl = [
|
||
'c' => Minz_Request::paramString('originController') ?: 'auth',
|
||
'a' => Minz_Request::paramString('originAction') ?: 'register',
|
||
];
|
||
|
||
if (!self::checkUsername($new_user_name)) {
|
||
Minz_Request::bad(
|
||
_t('user.username.invalid'),
|
||
$badRedirectUrl
|
||
);
|
||
}
|
||
|
||
if (FreshRSS_UserDAO::exists($new_user_name)) {
|
||
Minz_Request::bad(
|
||
_t('user.username.taken', $new_user_name),
|
||
$badRedirectUrl
|
||
);
|
||
}
|
||
|
||
if (!FreshRSS_FormAuth::passwordRequirementsMet($passwordPlain)) {
|
||
Minz_Request::bad(
|
||
_t('user.password.invalid'),
|
||
$badRedirectUrl
|
||
);
|
||
}
|
||
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
// TODO: We may want to ask the user to accept TOS before first login
|
||
$tos_enabled = file_exists(TOS_FILENAME);
|
||
$accept_tos = Minz_Request::paramBoolean('accept_tos');
|
||
if ($tos_enabled && !$accept_tos) {
|
||
Minz_Request::bad(_t('user.tos.feedback.invalid'), $badRedirectUrl);
|
||
}
|
||
}
|
||
|
||
if (FreshRSS_Context::systemConf()->force_email_validation && empty($email)) {
|
||
Minz_Request::bad(
|
||
_t('user.email.feedback.required'),
|
||
$badRedirectUrl
|
||
);
|
||
}
|
||
|
||
if (!empty($email) && !self::validateEmailAddress($email)) {
|
||
Minz_Request::bad(
|
||
_t('user.email.feedback.invalid'),
|
||
$badRedirectUrl
|
||
);
|
||
}
|
||
|
||
$is_admin = false;
|
||
if (FreshRSS_Auth::hasAccess('admin')) {
|
||
$is_admin = Minz_Request::paramBoolean('new_user_is_admin');
|
||
}
|
||
|
||
$ok = self::createUser($new_user_name, $email, $passwordPlain, [
|
||
'language' => Minz_Request::paramString('new_user_language') ?: FreshRSS_Context::userConf()->language,
|
||
'timezone' => Minz_Request::paramString('new_user_timezone'),
|
||
'is_admin' => $is_admin,
|
||
'enabled' => true,
|
||
]);
|
||
Minz_Request::_param('new_user_passwordPlain'); //Discard plain-text password ASAP
|
||
$_POST['new_user_passwordPlain'] = '';
|
||
invalidateHttpCache();
|
||
|
||
// If the user has admin access, it means he’s already logged in
|
||
// and we don’t want to login with the new account. Otherwise, the
|
||
// user just created its account himself so he probably wants to
|
||
// get started immediately.
|
||
if ($ok && !FreshRSS_Auth::hasAccess('admin')) {
|
||
$user_conf = FreshRSS_UserConfiguration::getForUser($new_user_name);
|
||
if ($user_conf !== null) {
|
||
// Rotate the session ID on this unauthenticated->authenticated
|
||
// transition to prevent session fixation, as on form login.
|
||
try {
|
||
Minz_Session::regenerateID('FreshRSS');
|
||
} catch (RuntimeException $e) {
|
||
Minz_Log::error('Session could not be regenerated during self-registration auto-login! ' . $e->getMessage());
|
||
}
|
||
Minz_Session::_params([
|
||
Minz_User::CURRENT_USER => $new_user_name,
|
||
'passwordHash' => $user_conf->passwordHash,
|
||
'csrf' => false,
|
||
]);
|
||
FreshRSS_Auth::giveAccess();
|
||
} else {
|
||
$ok = false;
|
||
}
|
||
}
|
||
|
||
if ($ok) {
|
||
Minz_Request::setGoodNotification(_t('feedback.user.created', $new_user_name));
|
||
} else {
|
||
Minz_Request::setBadNotification(_t('feedback.user.created.error', $new_user_name));
|
||
}
|
||
}
|
||
|
||
if (FreshRSS_Auth::hasAccess('admin')) {
|
||
$redirect_url = ['c' => 'user', 'a' => 'manage'];
|
||
} else {
|
||
$redirect_url = ['c' => 'index', 'a' => 'index'];
|
||
}
|
||
Minz_Request::forward($redirect_url, true);
|
||
}
|
||
|
||
public static function deleteUser(string $username): bool {
|
||
$ok = self::checkUsername($username);
|
||
if ($ok) {
|
||
$default_user = FreshRSS_Context::systemConf()->default_user;
|
||
$ok &= (strcasecmp($username, $default_user) !== 0); //It is forbidden to delete the default user
|
||
}
|
||
$user_data = join_path(DATA_PATH, 'users', $username);
|
||
$ok &= is_dir($user_data);
|
||
if ($ok) {
|
||
FreshRSS_fever_Util::deleteKey($username);
|
||
Minz_ModelPdo::$usesSharedPdo = false;
|
||
$oldUserDAO = FreshRSS_Factory::createUserDao($username);
|
||
$ok &= $oldUserDAO->deleteUser();
|
||
Minz_ModelPdo::$usesSharedPdo = true;
|
||
$ok &= recursive_unlink($user_data);
|
||
$filenames = glob(PSHB_PATH . '/feeds/*/' . $username . '.txt');
|
||
if (!empty($filenames)) {
|
||
array_map('unlink', $filenames);
|
||
}
|
||
}
|
||
return (bool)$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(): void {
|
||
if (!FreshRSS_Context::systemConf()->force_email_validation) {
|
||
Minz_Error::error(404);
|
||
}
|
||
|
||
FreshRSS_View::prependTitle(_t('user.email.validation.title') . ' · ');
|
||
|
||
$username = Minz_Request::paramString('username');
|
||
if (FreshRSS_Auth::hasAccess()) {
|
||
$username = Minz_User::name() ?? '';
|
||
}
|
||
$token = Minz_Request::paramString('token');
|
||
|
||
if ($username !== '') {
|
||
$user_config = FreshRSS_UserConfiguration::getForUser($username);
|
||
} elseif (FreshRSS_Auth::hasAccess()) {
|
||
$user_config = FreshRSS_Context::userConf();
|
||
} else {
|
||
Minz_Error::error(403);
|
||
return;
|
||
}
|
||
|
||
if (!FreshRSS_UserDAO::exists($username) || $user_config === null) {
|
||
Minz_Error::error(404);
|
||
return;
|
||
}
|
||
|
||
if ($user_config->email_validation_token === '') {
|
||
Minz_Request::good(
|
||
_t('user.email.validation.feedback.unnecessary'),
|
||
['c' => 'index', 'a' => 'index'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
}
|
||
|
||
if ($token != '') {
|
||
if (!hash_equals($user_config->email_validation_token, $token)) {
|
||
Minz_Request::bad(
|
||
_t('user.email.validation.feedback.wrong_token'),
|
||
['c' => 'user', 'a' => 'validateEmail']
|
||
);
|
||
}
|
||
|
||
$user_config->email_validation_token = '';
|
||
if ($user_config->save()) {
|
||
Minz_Request::good(
|
||
_t('user.email.validation.feedback.ok'),
|
||
['c' => 'index', 'a' => 'index'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Request::bad(
|
||
_t('user.email.validation.feedback.error'),
|
||
['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(): void {
|
||
if (!FreshRSS_Auth::hasAccess()) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (!Minz_Request::isPost()) {
|
||
Minz_Error::error(404);
|
||
}
|
||
|
||
$username = Minz_User::name();
|
||
|
||
if (FreshRSS_Context::userConf()->email_validation_token === '') {
|
||
Minz_Request::forward([
|
||
'c' => 'index',
|
||
'a' => 'index',
|
||
], true);
|
||
}
|
||
|
||
$mailer = new FreshRSS_User_Mailer();
|
||
$ok = $username != null && $mailer->send_email_need_validation($username, FreshRSS_Context::userConf());
|
||
|
||
$redirect_url = ['c' => 'user', 'a' => 'validateEmail'];
|
||
if ($ok) {
|
||
Minz_Request::good(
|
||
_t('user.email.validation.feedback.email_sent'),
|
||
$redirect_url,
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Request::bad(
|
||
_t('user.email.validation.feedback.email_failed'),
|
||
$redirect_url
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This action delete an existing user.
|
||
*
|
||
* Request parameter is:
|
||
* - username
|
||
*
|
||
* @todo clean up this method. Idea: create a User->clean() method.
|
||
*/
|
||
public function deleteAction(): void {
|
||
$username = Minz_Request::paramString('username');
|
||
$self_deletion = Minz_User::name() === $username;
|
||
|
||
if (!FreshRSS_Auth::hasAccess('admin') && !$self_deletion) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
$redirect_url = ['c' => 'user', 'a' => 'manage'];
|
||
|
||
if (Minz_Request::isPost()) {
|
||
$ok = true;
|
||
if ($self_deletion) {
|
||
// We check the password if it’s a self-destruction
|
||
$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
|
||
|
||
$ok &= FreshRSS_FormAuth::checkCredentials(
|
||
$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
|
||
);
|
||
if (!$ok) {
|
||
Minz_Request::bad(_t('feedback.auth.login.invalid'), ['c' => 'user', 'a' => 'profile']);
|
||
return;
|
||
}
|
||
} elseif (self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
$ok &= self::deleteUser($username);
|
||
|
||
if ($ok && $self_deletion) {
|
||
FreshRSS_Auth::removeAccess();
|
||
Minz_Session::_param('csrf', false);
|
||
$redirect_url = ['c' => 'index', 'a' => 'index'];
|
||
}
|
||
invalidateHttpCache();
|
||
|
||
if ($ok) {
|
||
Minz_Request::setGoodNotification(_t('feedback.user.deleted', $username));
|
||
} else {
|
||
Minz_Request::setBadNotification(_t('feedback.user.deleted.error', $username));
|
||
}
|
||
}
|
||
|
||
Minz_Request::forward($redirect_url, true);
|
||
}
|
||
|
||
public function promoteAction(): void {
|
||
$this->toggleAction('is_admin', true);
|
||
}
|
||
|
||
public function demoteAction(): void {
|
||
$this->toggleAction('is_admin', false);
|
||
}
|
||
|
||
public function enableAction(): void {
|
||
$this->toggleAction('enabled', true);
|
||
}
|
||
|
||
public function disableAction(): void {
|
||
$this->toggleAction('enabled', false);
|
||
}
|
||
|
||
private function toggleAction(string $field, bool $value): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (!Minz_Request::isPost()) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
$username = Minz_Request::paramString('username');
|
||
if (!FreshRSS_UserDAO::exists($username)) {
|
||
Minz_Error::error(404);
|
||
}
|
||
|
||
if (null === $userConfig = FreshRSS_UserConfiguration::getForUser($username)) {
|
||
Minz_Error::error(500);
|
||
return;
|
||
}
|
||
|
||
if ($field === '') {
|
||
Minz_Error::error(400, 'Invalid field name');
|
||
return;
|
||
}
|
||
|
||
$userConfig->_attribute($field, $value);
|
||
|
||
$ok = $userConfig->save();
|
||
FreshRSS_UserDAO::touch($username);
|
||
|
||
if ($ok) {
|
||
Minz_Request::good(
|
||
_t('feedback.user.updated', $username),
|
||
['c' => 'user', 'a' => 'manage'],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Request::bad(
|
||
_t('feedback.user.updated.error', $username),
|
||
['c' => 'user', 'a' => 'manage']
|
||
);
|
||
}
|
||
}
|
||
|
||
public function detailsAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (self::reauthRedirect()) {
|
||
return;
|
||
}
|
||
|
||
$username = Minz_Request::paramString('username');
|
||
if (!FreshRSS_UserDAO::exists($username)) {
|
||
Minz_Error::error(404);
|
||
}
|
||
|
||
if (Minz_Request::paramBoolean('ajax')) {
|
||
$this->view->_layout(null);
|
||
}
|
||
|
||
$this->view->username = $username;
|
||
$this->view->details = $this->retrieveUserDetails($username);
|
||
FreshRSS_View::prependTitle($username . ' · ' . _t('gen.menu.user_management') . ' · ');
|
||
}
|
||
|
||
/** @return array{feed_count:?int,article_count:?int,database_size:?int,language:string,mail_login:string,enabled:bool,is_admin:bool,last_user_activity:string,is_default:bool} */
|
||
private function retrieveUserDetails(string $username, bool $fast = false): array {
|
||
$feedDAO = $fast ? null : FreshRSS_Factory::createFeedDao($username);
|
||
$entryDAO = $fast ? null : FreshRSS_Factory::createEntryDao($username);
|
||
$databaseDAO = $fast ? null : FreshRSS_Factory::createDatabaseDAO($username);
|
||
|
||
$userConfiguration = FreshRSS_UserConfiguration::getForUser($username);
|
||
if ($userConfiguration === null) {
|
||
throw new Exception('Error loading user configuration!');
|
||
}
|
||
|
||
return [
|
||
'feed_count' => isset($feedDAO) ? $feedDAO->count() : null,
|
||
'article_count' => isset($entryDAO) ? $entryDAO->count() : null,
|
||
'database_size' => isset($databaseDAO) ? $databaseDAO->size() : null,
|
||
'language' => $userConfiguration->language,
|
||
'mail_login' => $userConfiguration->mail_login,
|
||
'enabled' => $userConfiguration->enabled,
|
||
'is_admin' => $userConfiguration->is_admin,
|
||
'last_user_activity' => date('c', FreshRSS_UserDAO::mtime($username)) ?: '',
|
||
'is_default' => FreshRSS_Context::systemConf()->default_user === $username,
|
||
];
|
||
}
|
||
}
|