API Make token regeneration optional during autologin session renewal

Resolves #11281. Renewing the token/hash during an active session
can trigger a logout in the event of request failures or simultaneous
requests.

This also marks the renew method as deprecated, to be removed
entirely in 6.0.
This commit is contained in:
Garion Herman 2024-07-04 08:15:05 +09:00
parent dbc0288038
commit 724e813b8f
3 changed files with 79 additions and 15 deletions

View File

@ -10,6 +10,7 @@ use SilverStripe\Security\IdentityStore;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\RememberLoginHash;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
use SilverStripe\Dev\Deprecation;
/** /**
* Authenticate a member passed on a session cookie * Authenticate a member passed on a session cookie
@ -175,17 +176,21 @@ class CookieAuthenticationHandler implements AuthenticationHandler
} }
// Renew the token // Renew the token
$rememberLoginHash->renew(); Deprecation::withNoReplacement(fn() => $rememberLoginHash->renew());
$tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days');
Cookie::set( // Send the new token to the client if it was changed
$this->getTokenCookieName(), if ($rememberLoginHash->getToken()) {
$member->ID . ':' . $rememberLoginHash->getToken(), $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days');
$tokenExpiryDays, Cookie::set(
null, $this->getTokenCookieName(),
null, $member->ID . ':' . $rememberLoginHash->getToken(),
false, $tokenExpiryDays,
true null,
); null,
false,
true
);
}
// Audit logging hook // Audit logging hook
$member->extend('memberAutoLoggedIn'); $member->extend('memberAutoLoggedIn');

View File

@ -6,6 +6,7 @@ use DateInterval;
use DateTime; use DateTime;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Dev\Deprecation;
/** /**
* Persists a token associated with a device for users who opted for the "Remember Me" * Persists a token associated with a device for users who opted for the "Remember Me"
@ -80,7 +81,18 @@ class RememberLoginHash extends DataObject
private static $force_single_token = false; private static $force_single_token = false;
/** /**
* The token used for the hash * If true, the token will be replaced during session renewal. This can cause unexpected
* logouts if the new token does not reach the client (e.g. due to a network error).
*
* This can be disabled as of CMS 5.3, and renewal will be removed entirely in CMS 6.
* @deprecated 5.3.0 Will be removed without equivalent functionality
*/
private static bool $replace_token_during_session_renewal = true;
/**
* The token used for the hash. Only present during the lifetime of the request
* that generates it, as the hash representation is stored in the database and
* the token itself is sent to the client.
*/ */
private $token = null; private $token = null;
@ -190,14 +202,22 @@ class RememberLoginHash extends DataObject
/** /**
* Generates a new hash for this member but keeps the device ID intact * Generates a new hash for this member but keeps the device ID intact
* *
* @deprecated 5.3.0 Will be removed without equivalent functionality
* @return RememberLoginHash * @return RememberLoginHash
*/ */
public function renew() public function renew()
{ {
$hash = $this->getNewHash($this->Member()); // Only regenerate token if configured to do so
$this->Hash = $hash; Deprecation::notice('5.3.0', 'Will be removed without equivalent functionality');
$this->extend('onAfterRenewToken'); $replaceToken = RememberLoginHash::config()->get('replace_token_during_session_renewal');
if ($replaceToken) {
$hash = $this->getNewHash($this->Member());
$this->Hash = $hash;
}
$this->extend('onAfterRenewToken', $replaceToken);
$this->write(); $this->write();
return $this; return $this;
} }

View File

@ -6,6 +6,7 @@ use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\RememberLoginHash;
use SilverStripe\SessionManager\Models\LoginSession; use SilverStripe\SessionManager\Models\LoginSession;
use SilverStripe\Dev\Deprecation;
class RememberLoginHashTest extends SapphireTest class RememberLoginHashTest extends SapphireTest
{ {
@ -95,4 +96,42 @@ class RememberLoginHashTest extends SapphireTest
RememberLoginHash::setLogoutAcrossDevices(false); RememberLoginHash::setLogoutAcrossDevices(false);
$this->assertFalse(RememberLoginHash::getLogoutAcrossDevices()); $this->assertFalse(RememberLoginHash::getLogoutAcrossDevices());
} }
/**
* @dataProvider provideRenew
* @param bool $replaceToken
*/
public function testRenew($replaceToken)
{
// If session-manager module is installed it expects an active request during renewal
if (class_exists(LoginSession::class)) {
$this->markTestSkipped();
}
$member = $this->objFromFixture(Member::class, 'main');
RememberLoginHash::config()->set('replace_token_during_session_renewal', $replaceToken);
$hash = RememberLoginHash::generate($member);
$oldToken = $hash->getToken();
$oldHash = $hash->Hash;
Deprecation::withNoReplacement(fn() => $hash->renew());
if ($replaceToken) {
$this->assertNotEquals($oldToken, $hash->getToken());
$this->assertNotEquals($oldHash, $hash->Hash);
} else {
$this->assertEmpty($hash->getToken());
$this->assertEquals($oldHash, $hash->Hash);
}
}
public function provideRenew(): array
{
return [
[true],
[false],
];
}
} }