Files
FreshRSS/app/Models/Auth.php
T
5a270c02b0 Rotate session ID on all authenticated transitions (CWE-384) (#9333)
* auth: rotate session ID on all authenticated transitions (session fixation)

The PHP session ID was regenerated on explicit form login, reauth, logout and
password change, but NOT when access is (re)established from a remember-me
cookie, via HTTP authentication, or on self-registration auto-login (CWE-384),
so a fixed session ID could become authenticated.

Regenerate the session ID at those transitions too: in FreshRSS_Auth::init
when accessControl() first grants access (covers remember-me restore and HTTP
auth; skipped for auth_type 'none', which has no authentication boundary), and
in the self-registration auto-login path, mirroring the existing form-login
idiom. Also enable session.use_strict_mode before session_start as defence in
depth so an uninitialised, attacker-supplied session ID is not adopted.

* auth: avoid duplicate cookie during session rotation

* Code preferences, shorten some comments

---------

Co-authored-by: Inverle <inverle@proton.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
2026-09-28 10:39:21 +02:00

296 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
/**
* This class handles all authentication process.
*/
class FreshRSS_Auth {
/**
* Determines if user is connected.
*/
public const DEFAULT_COOKIE_DURATION = 7_776_000;
private static bool $login_ok = false;
/**
* This method initializes authentication system.
*/
public static function init(): bool {
if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== FreshRSS_http_Util::httpAuthUser()) {
//HTTP REMOTE_USER has changed
self::removeAccess();
Minz_Session::_param('csrf', false);
}
self::$login_ok = Minz_Session::paramBoolean('loginOk');
$current_user = Minz_User::name();
if ($current_user === null) {
$current_user = FreshRSS_Context::systemConf()->default_user;
Minz_Session::_params([
Minz_User::CURRENT_USER => $current_user,
]);
}
if (self::$login_ok && self::giveAccess()) {
return self::$login_ok;
}
if (self::accessControl()) {
// Rotate the PHP session ID on the unauthenticated->authenticated transition
if (FreshRSS_Context::systemConf()->auth_type !== 'none') {
try {
Minz_Session::regenerateID('FreshRSS');
} catch (RuntimeException $e) {
Minz_Log::error('Session could not be regenerated during access restoration: ' . $e->getMessage());
}
}
if (self::giveAccess()) {
FreshRSS_UserDAO::touch();
return self::$login_ok;
}
}
// Be sure all accesses are removed!
self::removeAccess();
return false;
}
/**
* This method checks if user is allowed to connect.
*
* Required session parameters are also set in this method (such as
* currentUser).
*
* @return bool true if user can be connected, false otherwise.
*/
private static function accessControl(): bool {
$auth_type = FreshRSS_Context::systemConf()->auth_type;
switch ($auth_type) {
case 'form':
$credentials = FreshRSS_FormAuth::getCredentialsFromCookie();
$current_user = '';
if (isset($credentials[1])) {
$current_user = trim($credentials[0]);
Minz_Session::_params([
Minz_User::CURRENT_USER => $current_user,
'passwordHash' => trim($credentials[1]),
'csrf' => false,
]);
}
return $current_user != '';
case 'http_auth':
$current_user = FreshRSS_http_Util::httpAuthUser();
if ($current_user == '') {
return false;
}
$login_ok = FreshRSS_UserDAO::exists($current_user);
if (!$login_ok && FreshRSS_Context::systemConf()->http_auth_auto_register) {
$email = null;
if (FreshRSS_Context::systemConf()->http_auth_auto_register_email_field !== '' &&
is_string($_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field] ?? null)) {
$email = $_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field];
}
$language = Minz_Translate::getLanguage(null, Minz_Request::getPreferredLanguages(), FreshRSS_Context::systemConf()->language);
Minz_Translate::init($language);
$login_ok = FreshRSS_user_Controller::createUser($current_user, $email, '', [
'language' => $language,
]);
}
if ($login_ok) {
Minz_Session::_params([
Minz_User::CURRENT_USER => $current_user,
'csrf' => false,
]);
}
return $login_ok;
case 'none':
return true;
default:
// TODO load extension
return false;
}
}
/**
* Gives access to the current user.
*/
public static function giveAccess(): bool {
FreshRSS_Context::initUser();
if (!FreshRSS_Context::hasUserConf() || !FreshRSS_Context::userConf()->enabled) {
self::$login_ok = false;
return false;
}
switch (FreshRSS_Context::systemConf()->auth_type) {
case 'form':
self::$login_ok = Minz_Session::paramString('passwordHash') === FreshRSS_Context::userConf()->passwordHash;
break;
case 'http_auth':
$current_user = Minz_User::name() ?? '';
self::$login_ok = strcasecmp($current_user, FreshRSS_http_Util::httpAuthUser()) === 0;
break;
case 'none':
self::$login_ok = true;
break;
default:
// TODO: extensions
self::$login_ok = false;
}
Minz_Session::_params([
'loginOk' => self::$login_ok,
'REMOTE_USER' => FreshRSS_http_Util::httpAuthUser(),
]);
return self::$login_ok;
}
/**
* Returns if current user has access to the given scope.
*
* @param string $scope general (default) or admin
* @return bool true if user has corresponding access, false else.
*/
public static function hasAccess(string $scope = 'general'): bool {
if (!FreshRSS_Context::hasUserConf()) {
return false;
}
$currentUser = Minz_User::name();
$isAdmin = FreshRSS_Context::userConf()->is_admin;
$default_user = FreshRSS_Context::systemConf()->default_user;
$ok = self::$login_ok;
switch ($scope) {
case 'general':
break;
case 'admin':
$ok &= $default_user === $currentUser || $isAdmin;
break;
default:
$ok = false;
}
return (bool)$ok;
}
/**
* Removes all accesses for the current user.
*/
public static function removeAccess(): void {
self::$login_ok = false;
Minz_Session::_params([
'loginOk' => false,
'lastReauth' => false,
// 'csrf' => false, // Must be refreshed separately
'REMOTE_USER' => false,
]);
$username = Minz_Request::paramString('user');
if (!Minz_Request::tokenIsOk()) {
$username = FreshRSS_Context::systemConf()->default_user;
}
Minz_User::change($username);
switch (FreshRSS_Context::systemConf()->auth_type) {
case 'form':
Minz_Session::_param('passwordHash');
FreshRSS_FormAuth::deleteCookie();
break;
case 'http_auth':
case 'none':
// Nothing to do…
break;
default:
// TODO: extensions
}
}
/**
* Return if authentication is enabled on this instance of FRSS.
*/
public static function accessNeedsLogin(): bool {
return FreshRSS_Context::systemConf()->auth_type !== 'none';
}
/**
* Return if authentication requires a PHP action.
*/
public static function accessNeedsAction(): bool {
return FreshRSS_Context::systemConf()->auth_type === 'form';
}
public static function csrfToken(): string {
$csrf = Minz_Session::paramString('csrf');
if ($csrf == '') {
$csrf = hash('sha256', FreshRSS_Context::systemConf()->salt . random_bytes(32));
Minz_Session::_param('csrf', $csrf);
}
return $csrf;
}
public static function isCsrfOk(?string $token = null): bool {
$csrf = Minz_Session::paramString('csrf');
if ($token === null) {
$token = is_string($_POST['_csrf'] ?? null) ? $_POST['_csrf'] : '';
}
return $token != '' && hash_equals($csrf, $token);
}
public static function needsReauth(): bool {
$auth_type = FreshRSS_Context::systemConf()->auth_type;
$reauth_required = FreshRSS_Context::systemConf()->reauth_required;
$reauth_time = FreshRSS_Context::systemConf()->reauth_time;
if (!$reauth_required) {
return false;
}
$last_reauth = Minz_Session::paramInt('lastReauth');
if ($auth_type !== 'none' && time() - $last_reauth > $reauth_time) {
if ($auth_type === 'http_auth') {
// TODO: not implemented - just let the user through
return false;
}
return true;
}
return false;
}
/**
* Return if user needs reauth and got redirected to login page.
*
* @param array{c?: string, a?: string, params?: array<string, mixed>}|null $redirect
*/
public static function requestReauth(?array $redirect = null): bool {
if (self::needsReauth()) {
if (Minz_Request::paramBoolean('ajax')) {
// Send 403 and exit instead of redirect with Minz_Error::error()
header('HTTP/1.1 403 Forbidden');
exit();
}
$redirect = Minz_Url::serialize($redirect ?? Minz_Request::currentRequest());
Minz_Request::forward([
'c' => 'auth',
'a' => 'reauth',
'params' => [
'r' => $redirect,
],
], true);
return true;
}
return false;
}
public static function allowAnonymous(): bool {
$defaultUser = FreshRSS_Context::systemConf()->default_user;
$requestedUser = Minz_Request::paramString('user');
return FreshRSS_Context::systemConf()->allow_anonymous &&
Minz_User::name() === $defaultUser &&
($requestedUser === '' || $requestedUser === $defaultUser);
}
public static function allowAnonymousRefresh(): bool {
return FreshRSS_Context::systemConf()->allow_anonymous_refresh;
}
}