mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-10-28 14:08:45 -04:00
* auth: make the login challenge nonce one-time The per-login challenge nonce stored in the session was never consumed or cleared, so a captured challenge stayed replayable and survived logout (CWE-294). The client (p/scripts/extra.js) already fetches a fresh nonce for every attempt, so consuming it does not affect legitimate retries. Consume the nonce (unset it) as soon as it is read for verification in form login, reauthentication, password change and account self-deletion, and clear it in FreshRSS_Auth::removeAccess so logout invalidates any outstanding challenge. * auth: don't clear the nonce in removeAccess (breaks login) removeAccess() runs on every unauthenticated request via FreshRSS_Auth::init() -- including the login POST itself, before formLoginAction reads the nonce -- so clearing it there rejected every login with 'Invalid session'. Thanks @jamalkamaladdin for the measured catch. The one-time guarantee is preserved by consuming the nonce when it is read for verification (login/reauth/password change/self-deletion); logout no longer needs a separate clear.
299 lines
11 KiB
PHP
299 lines
11 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 to login the current user.
|
||
*
|
||
* Parameters are:
|
||
* - nonce (default: false)
|
||
* - username (default: '')
|
||
* - challenge (default: '')
|
||
* - keep_logged_in (default: false)
|
||
*
|
||
* @throws Exception
|
||
*/
|
||
public function formLoginAction(): void {
|
||
invalidateHttpCache();
|
||
|
||
FreshRSS_View::prependTitle(_t('gen.auth.login') . ' · ');
|
||
FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/bcrypt.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/bcrypt.js')));
|
||
|
||
$limits = FreshRSS_Context::systemConf()->limits;
|
||
$this->view->cookie_days = (int)round($limits['cookie_duration'] / 86400, 1);
|
||
|
||
$isPOST = Minz_Request::isPost() && !Minz_Session::paramBoolean('POST_to_GET');
|
||
Minz_Session::_param('POST_to_GET');
|
||
|
||
if ($isPOST) {
|
||
$nonce = Minz_Session::paramString('nonce');
|
||
Minz_Session::_param('nonce', false); // One-time: consume the challenge so it cannot be replayed.
|
||
$username = Minz_Request::paramString('username');
|
||
$challenge = Minz_Request::paramString('challenge');
|
||
$ip_address = Minz_Request::connectionRemoteAddress();
|
||
|
||
if ($nonce === '') {
|
||
Minz_Log::warning("Invalid session during login for user={$username}, nonce={$nonce}, ip_address={$ip_address}");
|
||
header('HTTP/1.1 403 Forbidden');
|
||
Minz_Session::_param('POST_to_GET', true); //Prevent infinite internal redirect
|
||
Minz_Request::setBadNotification(_t('install.session.nok'));
|
||
Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
|
||
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, $nonce, $challenge
|
||
);
|
||
if ($ok) {
|
||
// Set session parameter to give access to the user.
|
||
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'], false);
|
||
return;
|
||
}
|
||
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}, nonce={$nonce}, c={$challenge}, ip_address={$ip_address}");
|
||
header('HTTP/1.1 403 Forbidden');
|
||
Minz_Session::_param('POST_to_GET', true); //Prevent infinite internal redirect
|
||
Minz_Request::setBadNotification(_t('feedback.auth.login.invalid'));
|
||
Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
|
||
}
|
||
} else {
|
||
Minz_Session::deleteLegacyCookie('FreshRSS'); // Delete legacy cookie (before 1.29.0)
|
||
}
|
||
}
|
||
|
||
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() ?? '';
|
||
$nonce = Minz_Session::paramString('nonce');
|
||
Minz_Session::_param('nonce', false); // One-time: consume the challenge so it cannot be replayed.
|
||
$challenge = Minz_Request::paramString('challenge');
|
||
if (!FreshRSS_FormAuth::checkCredentials(
|
||
$username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge
|
||
)) {
|
||
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') . ' · ');
|
||
FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/bcrypt.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/bcrypt.js')));
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
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');
|
||
}
|
||
}
|
||
}
|