Implement sudo mode / reauthentication (#7753)

* Implement sudo mode / reauthentication

* i18n: fr

* generate flags

* Improvements

* Remove HMAC check
* Don't require reauth to access logs when signed in as admin
* Notify user of bad login via notification instead

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
This commit is contained in:
Inverle
2025-07-31 13:53:14 +02:00
committed by GitHub
parent d0425f8c3a
commit 3ce64d271b
38 changed files with 323 additions and 12 deletions

View File

@@ -21,6 +21,10 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
Minz_Error::error(403);
}
if (FreshRSS_Auth::requestReauth()) {
return;
}
FreshRSS_View::prependTitle(_t('admin.auth.title') . ' · ');
if (Minz_Request::isPost()) {
@@ -219,6 +223,35 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
}
}
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');
$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 {
Minz_Session::_param('lastReauth', time());
Minz_Request::forward($redirect, true);
return;
}
}
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.
*/

View File

@@ -270,6 +270,10 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
Minz_Request::forward(['c' => 'update'], true);
}
if (FreshRSS_Auth::requestReauth()) {
return;
}
if (Minz_Request::paramBoolean('post_conf')) {
if (self::isGit()) {
$res = !self::hasGitUpdate();

View File

@@ -72,6 +72,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
}
if (Minz_Request::isPost()) {
if (self::reauthRedirect()) {
return;
}
$username = Minz_Request::paramString('username');
$newPasswordPlain = Minz_User::name() !== $username ? Minz_Request::paramString('newPasswordPlain', true) : '';
@@ -190,21 +194,41 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
}
}
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()) {
$username = Minz_Request::paramString('username');
if (!FreshRSS_UserDAO::exists($username)) {
Minz_Error::error(404);
}
$feedDAO = FreshRSS_Factory::createFeedDao($username);
$feedDAO->purge();
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();
}
/**
@@ -215,6 +239,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
Minz_Error::error(403);
}
if (self::reauthRedirect()) {
return;
}
FreshRSS_View::prependTitle(_t('admin.user.title') . ' · ');
if (Minz_Request::isPost()) {
@@ -337,6 +365,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
Minz_Error::error(403);
}
if (self::reauthRedirect()) {
return;
}
if (Minz_Request::isPost()) {
$new_user_name = Minz_Request::paramString('new_user_name');
$email = Minz_Request::paramString('new_user_email');
@@ -602,7 +634,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
$username, FreshRSS_Context::userConf()->passwordHash,
$nonce, $challenge
);
} elseif (self::reauthRedirect()) {
return;
}
if ($ok) {
$ok &= self::deleteUser($username);
}
@@ -647,6 +682,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
Minz_Error::error(403);
}
if (self::reauthRedirect()) {
return;
}
$username = Minz_Request::paramString('username');
if (!FreshRSS_UserDAO::exists($username)) {
Minz_Error::error(404);
@@ -682,6 +721,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
Minz_Error::error(403);
}
if (self::reauthRedirect()) {
return;
}
$username = Minz_Request::paramString('username');
if (!FreshRSS_UserDAO::exists($username)) {
Minz_Error::error(404);

View File

@@ -165,6 +165,7 @@ class FreshRSS_Auth {
self::$login_ok = false;
Minz_Session::_params([
'loginOk' => false,
'lastReauth' => false,
'csrf' => false,
'REMOTE_USER' => false,
]);
@@ -230,4 +231,54 @@ class FreshRSS_Auth {
}
return $token != '' && $token === $csrf;
}
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;
}
}

View File

@@ -9,6 +9,8 @@ declare(strict_types=1);
* @property bool $api_enabled
* @property string $archiving
* @property 'form'|'http_auth'|'none' $auth_type
* @property-read bool $reauth_required
* @property-read int $reauth_time
* @property-read string $auto_update_url
* @property-read array<int,mixed> $curl_options
* @property string $default_user

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Heslo',
'format' => '<small>Alespoň 7 znaků</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nový účet',
'ask' => 'Vytvořit účet?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Passwort',
'format' => '<small>mindestens 7 Zeichen</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Neuer Account',
'ask' => 'Erstelle einen Account?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Password', // TODO
'format' => '<small>At least 7 characters</small>', // TODO
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'New account', // TODO
'ask' => 'Create an account?', // TODO

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Password', // IGNORE
'format' => '<small>At least 7 characters</small>', // IGNORE
),
'reauth' => array(
'header' => 'Reauthentication is required', // IGNORE
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // IGNORE
'title' => 'Reauthentication', // IGNORE
),
'registration' => array(
'_' => 'New account', // IGNORE
'ask' => 'Create an account?', // IGNORE

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Password',
'format' => '<small>At least 7 characters</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required',
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>',
'title' => 'Reauthentication',
),
'registration' => array(
'_' => 'New account',
'ask' => 'Create an account?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Contraseña',
'format' => '<small>Mínimo de 7 caracteres</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nueva cuenta',
'ask' => '¿Crear una cuenta?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => ' رمز عبور',
'format' => '<small>حداقل 7 نویسه</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => ' حساب جدید',
'ask' => ' یک حساب کاربری ایجاد کنید؟',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Salasana',
'format' => '<small>Vähintään 7 merkkiä</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Uusi tili',
'ask' => 'Haluatko luoda tilin?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Mot de passe',
'format' => '<small>7 caractères minimum</small>',
),
'reauth' => array(
'header' => 'Une réauthentification est requise',
'tip' => 'La réauthentification sera valide pendant <u>%d minutes</u>',
'title' => 'Réauthentification',
),
'registration' => array(
'_' => 'Nouveau compte',
'ask' => 'Créer un compte ?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'סיסמה',
'format' => '<small>At least 7 characters</small>', // TODO
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'New account', // TODO
'ask' => 'Create an account?', // TODO

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Jelszó',
'format' => '<small>Legalább 7 karakter hosszú</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Új fiók',
'ask' => 'Létrehoz egy új fiókot?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Kata sandi',
'format' => '<small>Paling tidak 7 karakter</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Akun baru',
'ask' => 'Buat akun?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Password', // IGNORE
'format' => '<small>almeno 7 caratteri</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nuovo profilo',
'ask' => 'Vuoi creare un nuovo profilo?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'パスワード',
'format' => '<small>最低7文字必要です</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => '新規アカウント',
'ask' => 'アカウントを作りますか?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => '암호',
'format' => '<small>7 글자 이상이어야 합니다</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => '새 계정',
'ask' => '새 계정을 만들까요?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Parole',
'format' => '<small>Vismaz 7 rakstzīmes</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Jauns konts',
'ask' => 'Uztaisīt kontu?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Wachtwoord',
'format' => '<small>Ten minste 7 tekens</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nieuw account',
'ask' => 'Maak een account?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Senhal',
'format' => '<small>Almens 7 caractèrs</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Compte nòu',
'ask' => 'Crear un compte?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Hasło',
'format' => '<small>przynajmniej 7 znaków</small>',
),
'reauth' => array(
'header' => 'Wymagane ponowne logowanie',
'tip' => 'Nie będziesz proszony o ponowne logowanie przez <u>%d minut</u>',
'title' => 'Ponowne logowanie',
),
'registration' => array(
'_' => 'Tworzenie konta',
'ask' => 'Nie masz konta?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Senha',
'format' => '<small>Ao menos 7 caracteres</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nova conta',
'ask' => 'Criar novoa conta?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Senha',
'format' => '<small>Pelo menos 7 caracteres</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nova conta',
'ask' => 'Criar novoa conta?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Пароль',
'format' => '<small>Не менее 7 символов</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Новый аккаунт',
'ask' => 'Создать аккаунт?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Heslo',
'format' => '<small>Najmenej 7 znakov</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Nový účet',
'ask' => 'Vytvoriť účet?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => 'Parola',
'format' => '<small>En az 7 karakter</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => 'Yeni hesap',
'ask' => 'Hesap oluştur?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => '密码',
'format' => '<small>至少 7 个字符</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => '新用户',
'ask' => '创建新用户?',

View File

@@ -61,6 +61,11 @@ return array(
'_' => '密碼',
'format' => '<small>至少 7 個字元</small>',
),
'reauth' => array(
'header' => 'Reauthentication is required', // TODO
'tip' => 'You wont be asked to sign in again for <u>%d minutes</u>', // TODO
'title' => 'Reauthentication', // TODO
),
'registration' => array(
'_' => '新使用者',
'ask' => '創建新使用者?',

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/** @var FreshRSS_View $this */
?>
<main class="prompt">
<h1><?= _t('gen.auth.reauth.header') ?></h1>
<form id="crypto-form" method="post">
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
<input type="hidden" id="username" value="<?= Minz_User::name() ?>" />
<div class="form-group">
<label for="passwordPlain"><?= _t('gen.auth.password') ?></label>
<div class="stick">
<input type="password" id="passwordPlain" required="required" />
<button type="button" class="btn toggle-password" data-toggle="passwordPlain"><?= _i('key') ?></button>
</div>
<input type="hidden" id="challenge" name="challenge" />
<noscript><strong><?= _t('gen.js.should_be_activated') ?></strong></noscript>
</div>
<?php
$reauth_time = FreshRSS_Context::systemConf()->reauth_time;
?>
<p class="help"><?= _i('help') ?> <?= _t('gen.auth.reauth.tip', intval($reauth_time / 60)) ?></p>
<div class="form-group form-group-actions">
<button id="loginButton" type="submit" class="btn btn-important" disabled="disabled">
<?= _t('gen.auth.login') ?>
</button>
</div>
</form>
</main>

View File

@@ -59,6 +59,13 @@ return [
# and in particular not protect `/FreshRSS/p/api/` if you would like to use the API (different login system).
'auth_type' => 'form',
# Whether reauthentication is required for performing sensitive actions e.g. promoting a user or applying an update
'reauth_required' => true,
# Time before asking for reauth
# Default: 1200s (20 min)
'reauth_time' => 1200,
# When using http_auth, automatically register any unknown user
'http_auth_auto_register' => true,

View File

@@ -2,6 +2,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
<rect rx="3" width="70" height="20" fill="green" />
<text x="34" y="14">🇩🇪 96%</text>
<text x="34" y="14">🇩🇪 95%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -2,6 +2,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
<rect rx="3" width="70" height="20" fill="green" />
<text x="34" y="14">🇮🇩 99%</text>
<text x="34" y="14">🇮🇩 98%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -21,6 +21,10 @@ final class UserJSExtension extends Minz_Extension {
$this->registerTranslates();
if (FreshRSS_Auth::requestReauth()) {
return;
}
if (Minz_Request::isPost()) {
$js_rules = Minz_Request::paramString('js-rules', plaintext: true);
$this->saveFile(self::FILENAME, $js_rules);

View File

@@ -2,7 +2,7 @@
"name": "User JS",
"author": "hkcomori, Frans de Jonge",
"description": "Apply user JS.",
"version": "1.1.0",
"version": "1.1.1",
"entrypoint": "UserJS",
"type": "user"
}

View File

@@ -327,6 +327,11 @@ function open_slider_listener(ev) {
req.open('GET', ahref, true);
req.responseType = 'document';
req.onload = function (e) {
if (this.status === 403) {
// Redirect to reauth page (or fail if session expired)
location.href = a.href;
return;
}
location.href = '#slider'; // close menu/dropdown
document.documentElement.classList.add('slider-active');
slider.classList.add('active');