Session lifetime extension

aka Persisted Login plugin functionality in core code.

Allows admins to set `$config['session_lifetime_extension_days']`, which
allows users to switch on an extended session lifetime in the login form.
In effect, these user sessions are valid for the configured number of days
after the last activity, even across network outages, closed browsers (as long
as they keep their cookies), etc.
This commit is contained in:
Pablo Zmdl
2025-09-28 15:04:06 +02:00
parent 59700c5ee8
commit ce56fd9b1e
7 changed files with 65 additions and 4 deletions

View File

@@ -646,6 +646,15 @@ $config['display_product_info'] = 1;
// Session lifetime in minutes
$config['session_lifetime'] = 10;
// Allow users to extend their session lifetime to up to X days by checking a
// checkbox at the login. Practically this means that a login will survive
// network changes, browser restarts (unless they delete cookies), etc, for up
// to X days without activity.
// Warning: This reduces the effectiveness of Roundcube's session highjacking
// mitigation, since a stolen session cookie will be valid for much longer than
// without this option.
$config['session_lifetime_extension_days'] = 1;
// Session domain: .example.org
$config['session_domain'] = '';

View File

@@ -2355,6 +2355,18 @@ class rcmail_output_html extends rcmail_output
'buttons' => [],
];
if ($this->config->session_lifetime_extension_days() > 0) {
$session_lifetime_extension_hidden_field = new html_hiddenfield(['name' => '_session_lifetime_extension', 'value' => '0']);
$form_content['hidden']['session_lifetime_extension'] = $session_lifetime_extension_hidden_field->show();
// Make sure the value is in the range 1..365.
$session_lifetime_extension_text = str_replace('#', $this->config->session_lifetime_extension_days(), $this->app->gettext('session_lifetime_extension_switch_text'));
$session_lifetime_extension_checkbox = new html_checkbox(['name' => '_session_lifetime_extension', 'id' => '_session_lifetime_extension', 'title' => $session_lifetime_extension_text]);
$form_content['inputs']['session_lifetime_extension'] = [
'content' => html::label(['for' => '_session_lifetime_extension'], [$session_lifetime_extension_checkbox->show(), $session_lifetime_extension_text]),
];
}
if (is_array($default_host) && count($default_host) > 1) {
$input_host = new html_select(['name' => '_host', 'id' => 'rcmloginhost', 'class' => 'custom-select']);

View File

@@ -34,6 +34,7 @@ class rcube_config
private $userprefs = [];
private $immutable = [];
private $client_tz;
private $session_lifetime_extension_days;
/**
* Renamed options
@@ -926,4 +927,18 @@ class rcube_config
return $deprecated_timezones[$tzname] ?? $tzname;
}
public function session_lifetime_extension_days(): int
{
if ($this->session_lifetime_extension_days === null) {
$config_value = $this->get('session_lifetime_extension_days', 0);
if (is_int($config_value) && $config_value > 0) {
// Make sure the value is in the range 1..365.
$this->session_lifetime_extension_days = min(max(1, $config_value), 365);
} else {
$this->session_lifetime_extension_days = 0;
}
}
return $this->session_lifetime_extension_days;
}
}

View File

@@ -707,6 +707,11 @@ abstract class rcube_session implements \SessionHandlerInterface
$this->log('IP check failed for ' . $this->key . '; expected ' . $this->ip . '; got ' . rcube_utils::remote_addr());
}
// Use the lifetime from the session so the cookie-name matching succeeds.
if ($_SESSION['session_lifetime_extension']) {
$this->set_lifetime($_SESSION['session_lifetime_extension']);
}
if ($result && $this->mkcookie($this->now) != $this->cookie) {
$this->log('Session auth check failed for ' . $this->key . '; timeslot = ' . date('Y-m-d H:i:s', $this->now));
$result = false;
@@ -725,6 +730,10 @@ abstract class rcube_session implements \SessionHandlerInterface
if (!$result) {
$this->log('Session authentication failed for ' . $this->key
. '; invalid auth cookie sent; timeslot = ' . date('Y-m-d H:i:s', $prev));
} else {
// Re-set the auth- and session-id-cookie, because in case of an extended session lifetime they can have an
// expiry date in the browser, which we need to extend.
$this->set_auth_cookie();
}
return $result;
@@ -733,10 +742,20 @@ abstract class rcube_session implements \SessionHandlerInterface
/**
* Set session authentication cookie
*/
public function set_auth_cookie()
public function set_auth_cookie(bool $session_lifetime_extension = false): void
{
if ($session_lifetime_extension === true) {
if ($this->config->session_lifetime_extension_days() > 0) {
$lifetime_seconds = $this->config->session_lifetime_extension_days() * 24 * 60 * 60;
$this->set_lifetime($lifetime_seconds);
$_SESSION['session_lifetime_extension'] = $lifetime_seconds;
$cookie_expiry = time() + $lifetime_seconds;
// Set the sessid-cookie (again) to force/renew its expiration date.
rcube_utils::setcookie(ini_get('session.name'), session_id(), $cookie_expiry);
}
}
$this->cookie = $this->mkcookie($this->now);
rcube_utils::setcookie($this->cookiename, $this->cookie, 0);
rcube_utils::setcookie($this->cookiename, $this->cookie, $cookie_expiry ?? 0);
$_COOKIE[$this->cookiename] = $this->cookie;
}

View File

@@ -21,6 +21,7 @@ $labels['password'] = 'Password';
$labels['server'] = 'Server';
$labels['login'] = 'Login';
$labels['oauthlogin'] = 'Login with $provider';
$labels['session_lifetime_extension_switch_text'] = 'Remember login for up to # days';
// taskbar
$labels['menu'] = 'Menu';

View File

@@ -123,8 +123,8 @@ if ($RCMAIL->task == 'login' && $RCMAIL->action == 'login') {
$RCMAIL->session->remove('temp');
$RCMAIL->session->regenerate_id(false);
// send auth cookie if necessary
$RCMAIL->session->set_auth_cookie();
$session_lifetime_extension = rcube_utils::get_input_string('_session_lifetime_extension', rcube_utils::INPUT_POST);
$RCMAIL->session->set_auth_cookie($session_lifetime_extension === 'on');
// log successful login
$RCMAIL->log_login();

View File

@@ -1160,6 +1160,11 @@ function rcube_elastic_ui() {
icon_name = input.data('icon'),
icon = $('<i>').attr('class', 'input-group-text icon ' + input.attr('name').replace('_', ''));
// Ignore checkboxes, they are prettified well enough by pretty_checkbox() already.
if (input.attr('type') === 'checkbox') {
return;
}
if (icon_name) {
icon.addClass(icon_name);
}