mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-11-05 09:02:58 -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>
292 lines
10 KiB
PHP
292 lines
10 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* This controller handles action about authentication.
|
||
*/
|
||
class FreshRSS_auth_Controller extends FreshRSS_ActionController {
|
||
/**
|
||
* This action handles authentication management page.
|
||
*
|
||
* Parameters are:
|
||
* - token (default: current token)
|
||
* - anon_access (default: false)
|
||
* - anon_refresh (default: false)
|
||
* - auth_type (default: none)
|
||
* - api_enabled (default: false)
|
||
*/
|
||
public function indexAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess('admin')) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
if (FreshRSS_Auth::requestReauth()) {
|
||
return;
|
||
}
|
||
|
||
FreshRSS_View::prependTitle(_t('admin.auth.title') . ' · ');
|
||
|
||
if (Minz_Request::isPost()) {
|
||
$ok = true;
|
||
|
||
$anon = Minz_Request::paramBoolean('anon_access');
|
||
$anon_refresh = Minz_Request::paramBoolean('anon_refresh');
|
||
$auth_type = Minz_Request::paramString('auth_type') ?: 'form';
|
||
$api_enabled = Minz_Request::paramBoolean('api_enabled');
|
||
if ($anon !== FreshRSS_Context::systemConf()->allow_anonymous ||
|
||
$auth_type !== FreshRSS_Context::systemConf()->auth_type ||
|
||
$anon_refresh !== FreshRSS_Context::systemConf()->allow_anonymous_refresh ||
|
||
$api_enabled !== FreshRSS_Context::systemConf()->api_enabled) {
|
||
if (in_array($auth_type, ['form', 'http_auth', 'none'], true)) {
|
||
FreshRSS_Context::systemConf()->auth_type = $auth_type;
|
||
} else {
|
||
FreshRSS_Context::systemConf()->auth_type = 'form';
|
||
}
|
||
FreshRSS_Context::systemConf()->allow_anonymous = $anon;
|
||
FreshRSS_Context::systemConf()->allow_anonymous_refresh = $anon_refresh;
|
||
FreshRSS_Context::systemConf()->api_enabled = $api_enabled;
|
||
|
||
$ok &= FreshRSS_Context::systemConf()->save();
|
||
}
|
||
|
||
invalidateHttpCache();
|
||
|
||
if ($ok) {
|
||
Minz_Request::good(
|
||
_t('feedback.conf.updated'),
|
||
[ 'c' => 'auth', 'a' => 'index' ],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Request::bad(_t('feedback.conf.error'), [ 'c' => 'auth', 'a' => 'index' ]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This action handles the login page.
|
||
*
|
||
* It forwards to the correct login page (form) or main page if
|
||
* the user is already connected.
|
||
*/
|
||
public function loginAction(): void {
|
||
if (FreshRSS_Auth::hasAccess()) {
|
||
Minz_Request::forward(['c' => 'index', 'a' => 'index'], true);
|
||
}
|
||
|
||
$auth_type = FreshRSS_Context::systemConf()->auth_type;
|
||
FreshRSS_Context::initUser(Minz_User::INTERNAL_USER, false);
|
||
match ($auth_type) {
|
||
'form' => Minz_Request::forward(['c' => 'auth', 'a' => 'formLogin']),
|
||
'http_auth' => Minz_Error::error(403, [
|
||
'error' => [
|
||
_t('feedback.access.denied'),
|
||
' [HTTP Remote-User=' . htmlspecialchars(FreshRSS_http_Util::httpAuthUser(onlyTrusted: false), ENT_NOQUOTES, 'UTF-8') .
|
||
' ; Remote IP address=' . Minz_Request::connectionRemoteAddress() . ']'
|
||
]
|
||
], false),
|
||
'none' => Minz_Error::error(404), // It should not happen!
|
||
default => Minz_Error::error(404), // TODO load plugin instead
|
||
};
|
||
}
|
||
|
||
/**
|
||
* This action handles form login page.
|
||
*
|
||
* If this action is reached through a POST request, username and password
|
||
* are compared against the bcrypt password hash stored in the user configuration file
|
||
* to authenticate the current user.
|
||
*
|
||
* Parameters are:
|
||
* - username (default: '')
|
||
* - passwordPlain (default: '')
|
||
* - keep_logged_in (default: false)
|
||
*
|
||
* @throws Exception
|
||
*/
|
||
public function formLoginAction(): void {
|
||
invalidateHttpCache();
|
||
|
||
FreshRSS_View::prependTitle(_t('gen.auth.login') . ' · ');
|
||
$this->view->auth_autofills = FreshRSS_FormAuth::getAutofilledFields();
|
||
|
||
$limits = FreshRSS_Context::systemConf()->limits;
|
||
$this->view->cookie_days = (int)round($limits['cookie_duration'] / 86400, 1);
|
||
|
||
if (Minz_Request::isPost()) {
|
||
$username = Minz_Request::paramString('username');
|
||
$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
|
||
$ip_address = Minz_Request::connectionRemoteAddress();
|
||
|
||
if (!Minz_Session::paramBoolean('sessionOk')) {
|
||
Minz_Log::warning("Invalid session during login for user={$username}, ip_address={$ip_address}");
|
||
header('HTTP/1.1 403 Forbidden');
|
||
Minz_Request::setBadNotification(_t('install.session.nok'));
|
||
Minz_Request::forward(['c' => 'auth', 'a' => 'login'], redirect: true);
|
||
return;
|
||
}
|
||
|
||
usleep(random_int(100, 10000)); // Primitive mitigation of timing attacks, in μs
|
||
|
||
FreshRSS_Context::initUser($username);
|
||
if (!FreshRSS_Context::hasUserConf()) {
|
||
// Initialise the default user to be able to display the error page
|
||
FreshRSS_Context::initUser(FreshRSS_Context::systemConf()->default_user);
|
||
Minz_Error::error(403, _t('feedback.auth.login.invalid'), false);
|
||
return;
|
||
}
|
||
|
||
if (!FreshRSS_Context::userConf()->enabled || FreshRSS_Context::userConf()->passwordHash == '') {
|
||
usleep(random_int(100, 5000)); // Primitive mitigation of timing attacks, in μs
|
||
Minz_Error::error(403, _t('feedback.auth.login.invalid'), false);
|
||
return;
|
||
}
|
||
|
||
$ok = FreshRSS_FormAuth::checkCredentials(
|
||
$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
|
||
);
|
||
if ($ok) {
|
||
try {
|
||
Minz_Session::regenerateID('FreshRSS');
|
||
} catch (RuntimeException $e) {
|
||
Minz_Log::error("Session could not be regenerated during login for user={$username}, ip_address={$ip_address}: {$e->getMessage()}");
|
||
header('HTTP/1.1 500 Internal Server Error');
|
||
Minz_Request::setBadNotification(_t('install.session.nok'));
|
||
Minz_Request::forward(['c' => 'auth', 'a' => 'login'], redirect: true);
|
||
return;
|
||
}
|
||
// Set session parameters to give access to the user.
|
||
Minz_Session::_params([
|
||
Minz_User::CURRENT_USER => $username,
|
||
'passwordHash' => FreshRSS_Context::userConf()->passwordHash,
|
||
'csrf' => false,
|
||
]);
|
||
FreshRSS_Auth::giveAccess();
|
||
|
||
// Set cookie parameter if needed.
|
||
if (Minz_Request::paramBoolean('keep_logged_in')) {
|
||
FreshRSS_FormAuth::makeCookie($username, FreshRSS_Context::userConf()->passwordHash);
|
||
} else {
|
||
FreshRSS_FormAuth::deleteCookie();
|
||
}
|
||
|
||
Minz_Translate::init(FreshRSS_Context::userConf()->language);
|
||
|
||
FreshRSS_UserDAO::touch();
|
||
|
||
// All is good, go back to the original request or the index.
|
||
$url = Minz_Url::unserialize(Minz_Request::paramString('original_request'));
|
||
if (empty($url)) {
|
||
$url = [ 'c' => 'index', 'a' => 'index' ];
|
||
}
|
||
Minz_Request::good(
|
||
_t('feedback.auth.login.success'),
|
||
$url,
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Log::warning("Password mismatch for user={$username}, ip_address={$ip_address}");
|
||
header('HTTP/1.1 403 Forbidden');
|
||
Minz_Request::setBadNotification(_t('feedback.auth.login.invalid'));
|
||
Minz_Request::forward(['c' => 'auth', 'a' => 'login'], redirect: true);
|
||
}
|
||
} else {
|
||
Minz_Session::deleteLegacyCookie('FreshRSS'); // Delete legacy cookie (before 1.29.0)
|
||
Minz_Session::_param('sessionOk', true); // To be checked in the POST login request.
|
||
}
|
||
}
|
||
|
||
public function reauthAction(): void {
|
||
if (!FreshRSS_Auth::hasAccess()) {
|
||
Minz_Error::error(403);
|
||
return;
|
||
}
|
||
/** @var array{c?: string, a?: string, params?: array<string, mixed>} $redirect */
|
||
$redirect = Minz_Url::unserialize(Minz_Request::paramString('r'));
|
||
if (!FreshRSS_Auth::needsReauth()) {
|
||
Minz_Request::forward($redirect, true);
|
||
return;
|
||
}
|
||
if (Minz_Request::isPost()) {
|
||
$username = Minz_User::name() ?? '';
|
||
$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
|
||
if (!FreshRSS_FormAuth::checkCredentials(
|
||
$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
|
||
)) {
|
||
Minz_Request::setBadNotification(_t('feedback.auth.login.invalid'));
|
||
} else {
|
||
try {
|
||
Minz_Session::regenerateID('FreshRSS');
|
||
Minz_Session::_param('lastReauth', time());
|
||
Minz_Request::forward($redirect, true);
|
||
return;
|
||
} catch (RuntimeException $e) {
|
||
Minz_Log::error("Session could not be regenerated during reauthentication! {$e->getMessage()}");
|
||
Minz_Session::_param('lastReauth', 0);
|
||
header('HTTP/1.1 500 Internal Server Error');
|
||
Minz_Request::setBadNotification(_t('install.session.nok'));
|
||
}
|
||
}
|
||
}
|
||
FreshRSS_View::prependTitle(_t('gen.auth.reauth.title') . ' · ');
|
||
$this->view->auth_autofills = FreshRSS_FormAuth::getAutofilledFields();
|
||
}
|
||
|
||
/**
|
||
* This action removes all accesses of the current user.
|
||
*/
|
||
public function logoutAction(): void {
|
||
if (Minz_Request::isPost()) {
|
||
invalidateHttpCache();
|
||
FreshRSS_Auth::removeAccess();
|
||
Minz_Session::_param('csrf', false);
|
||
try {
|
||
Minz_Session::regenerateID('FreshRSS');
|
||
} catch (RuntimeException $e) {
|
||
Minz_Log::error('Session could not be regenerated during logout! ' . $e->getMessage());
|
||
}
|
||
Minz_Request::good(
|
||
_t('feedback.auth.logout.success'),
|
||
[ 'c' => 'index', 'a' => 'index' ],
|
||
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
|
||
);
|
||
} else {
|
||
Minz_Error::error(403);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This action gives possibility to a user to create an account.
|
||
*
|
||
* The user is redirected to the home when logged in.
|
||
*
|
||
* A 403 is sent if max number of registrations is reached.
|
||
*/
|
||
public function registerAction(): void {
|
||
if (FreshRSS_Auth::hasAccess()) {
|
||
Minz_Request::forward(['c' => 'index', 'a' => 'index'], true);
|
||
}
|
||
|
||
if (FreshRSS_user_Controller::max_registrations_reached()) {
|
||
Minz_Error::error(403);
|
||
}
|
||
|
||
$this->view->show_tos_checkbox = file_exists(TOS_FILENAME);
|
||
$this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation;
|
||
$this->view->preferred_language = Minz_Translate::getLanguage(null, Minz_Request::getPreferredLanguages(), FreshRSS_Context::systemConf()->language);
|
||
$this->view->auth_autofills = FreshRSS_FormAuth::getAutofilledFields();
|
||
FreshRSS_View::prependTitle(_t('gen.auth.registration.title') . ' · ');
|
||
}
|
||
|
||
public static function getLogoutUrl(): string {
|
||
if (($_SERVER['AUTH_TYPE'] ?? '') === 'openid-connect') {
|
||
$url_string = urlencode(Minz_Request::guessBaseUrl());
|
||
return './oidc/?logout=' . $url_string . '/';
|
||
# The trailing slash is necessary so that we don’t redirect to http://.
|
||
# https://bz.apache.org/bugzilla/show_bug.cgi?id=61355#c13
|
||
} else {
|
||
return _url('auth', 'logout');
|
||
}
|
||
}
|
||
}
|