diff --git a/README.md b/README.md index 4ba0586..ac5cd0b 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,6 @@ It is also compatible with the [Silverstripe MFA module suite](https://github.co ## Configuration -### Logout across devices - -This module respects the `SilverStripe\Security\RememberLoginHash.logout_across_devices` config setting, which defaults to `true`. This means that the default behaviour is to revoke _all_ a user’s sessions when they log out. - -To change this so that logging out will only revoke the session for that one device, use the following config setting: - -```yml -SilverStripe\Security\RememberLoginHash: - logout_across_devices: false -``` - -**Important:** do not set this value to false if users do not have access to the CMS (or a custom UI where they can revoke sessions). Doing so would make it impossible to a user to revoke a session if they suspect their device has been compromised. - ### Session timeout Non-persisted login sessions (those where the user hasn’t ticked “remember me”) should expire after a period of inactivity, so that they’re removed from the list of active sessions even if the user closes their browser without completing the “log out” action. The length of time before expiry matches the `SilverStripe\Control\Session.timeout` value if one is set, otherwise falling back to a default of one hour. This default can be changed via the following config setting: diff --git a/src/Middleware/LoginSessionMiddleware.php b/src/Middleware/LoginSessionMiddleware.php index c6d7228..c848d73 100644 --- a/src/Middleware/LoginSessionMiddleware.php +++ b/src/Middleware/LoginSessionMiddleware.php @@ -5,10 +5,12 @@ use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Middleware\HTTPMiddleware; +use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\Connect\DatabaseException; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\Security\IdentityStore; +use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Security; use SilverStripe\SessionManager\Model\LoginSession; use SilverStripe\SessionManager\Security\LogInAuthenticationHandler; @@ -35,6 +37,13 @@ public function process(HTTPRequest $request, callable $delegate) // If the session has already been revoked, or we've got a mismatched // member / session, log the user out (this also revokes the session) if (!$loginSession || (int)$loginSession->MemberID !== (int)$member->ID) { + if (method_exists(RememberLoginHash::class, 'setLogoutAcrossDevices')) { + RememberLoginHash::setLogoutAcrossDevices(false); + } else { + // TODO: remove directly modifying config once framework 4.8 has been released + // and update composer.json requirement of framework to ^4.8 + Config::modify()->set(RememberLoginHash::class, 'logout_across_devices', false); + } $identityStore = Injector::inst()->get(IdentityStore::class); $identityStore->logOut($request); return $delegate($request); diff --git a/src/Model/LoginSession.php b/src/Model/LoginSession.php index cdf2757..9499e1c 100644 --- a/src/Model/LoginSession.php +++ b/src/Model/LoginSession.php @@ -174,7 +174,7 @@ public function handlePermission(string $funcName, $member): bool } // Members can manage their own sessions - if ($this->ID == $member->ID) { + if ($this->MemberID === $member->ID) { return true; } diff --git a/src/Security/LogOutAuthenticationHandler.php b/src/Security/LogOutAuthenticationHandler.php index d065819..58c8d45 100644 --- a/src/Security/LogOutAuthenticationHandler.php +++ b/src/Security/LogOutAuthenticationHandler.php @@ -58,16 +58,10 @@ public function logOut(HTTPRequest $request = null) $loginHandler = Injector::inst()->get(LogInAuthenticationHandler::class); $member = Security::getCurrentUser(); - if (RememberLoginHash::config()->get('logout_across_devices')) { - foreach ($member->LoginSessions() as $session) { - $session->delete(); - } - } else { - $loginSessionID = $request->getSession()->get($loginHandler->getSessionVariable()); - $loginSession = LoginSession::get()->byID($loginSessionID); - if ($loginSession && $loginSession->canDelete($member)) { - $loginSession->delete(); - } + $loginSessionID = $request->getSession()->get($loginHandler->getSessionVariable()); + $loginSession = LoginSession::get()->byID($loginSessionID); + if ($loginSession && $loginSession->canDelete($member)) { + $loginSession->delete(); } $request->getSession()->clear($loginHandler->getSessionVariable()); diff --git a/tests/php/Control/LoginSessionMiddlewareTest.php b/tests/php/Control/LoginSessionMiddlewareTest.php index 404d43c..682612d 100644 --- a/tests/php/Control/LoginSessionMiddlewareTest.php +++ b/tests/php/Control/LoginSessionMiddlewareTest.php @@ -2,12 +2,15 @@ namespace SilverStripe\SessionManager\Tests\Control; +use SilverStripe\Control\Cookie; use SilverStripe\Control\Middleware\ConfirmationMiddleware\Url; use SilverStripe\Control\Session; use SilverStripe\Control\Tests\HttpRequestMockBuilder; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\FieldType\DBDatetime; +use SilverStripe\Security\IdentityStore; use SilverStripe\Security\Member; +use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Security; use SilverStripe\SessionManager\Control\LoginSessionMiddleware; use SilverStripe\SessionManager\Model\LoginSession; @@ -124,4 +127,64 @@ function () use (&$next) { "Middleware logs user out if session has mismatched member" ); } + + /** + * Assert the RememberLoginHash for un-revoked LoginSessions are untouched + */ + public function testOtherDeviceRememberLoginHashUntouched() + { + /** @var Member $member */ + $member = $this->objFromFixture(Member::class, 'member_rmh'); + Security::setCurrentUser($member); + + $session1 = $this->objFromFixture(LoginSession::class, 'rmh1'); + $hash1 = RememberLoginHash::generate($member); + $hash1->LoginSessionID = $session1->ID; + $hash1->DeviceID = 'IE2'; + $hash1->write(); + + $session2 = $this->objFromFixture(LoginSession::class, 'rmh2'); + $hash2 = RememberLoginHash::generate($member); + $hash2->LoginSessionID = $session2->ID; + $hash2->DeviceID = 'C64'; + $hash2->write(); + + $deviceFilter = ['DeviceID' => ['IE2', 'C64']]; + + $this->assertSame(2, RememberLoginHash::get()->filter($deviceFilter)->count()); + $this->assertSame( + [ + 'Internet Explorer 2', + 'Commodore 64 browser' + ], + LoginSession::get() + ->filter(['ID' => RememberLoginHash::get()->filter($deviceFilter)->column('LoginSessionID')]) + ->column('UserAgent') + ); + + // revoke the 2nd device + $member->LoginSessions()->find('UserAgent', 'Commodore 64 browser')->delete(); + + // "press f5 to refresh" the 2nd device which will trigger the middleware to call IdentityStore logOut() + $default = Cookie::get('alc_device'); + Cookie::set('alc_device', 'C64'); + $session = new Session(['activeLoginSession' => $session2->ID]); + $request = $this->buildRequestMock('/', [], [], null, $session); + $request->method('getIP')->willReturn('192.168.0.1'); + $middleware = new LoginSessionMiddleware(); + $middleware->process($request, function () { + // noop + }); + Cookie::set('alc_device', $default); + + $this->assertSame(1, RememberLoginHash::get()->filter($deviceFilter)->count()); + $this->assertSame( + [ + 'Internet Explorer 2' + ], + LoginSession::get() + ->filter(['ID' => RememberLoginHash::get()->filter($deviceFilter)->column('LoginSessionID')]) + ->column('UserAgent') + ); + } } diff --git a/tests/php/Control/LoginSessionMiddlewareTest.yml b/tests/php/Control/LoginSessionMiddlewareTest.yml index 92bea0b..ff61e9a 100644 --- a/tests/php/Control/LoginSessionMiddlewareTest.yml +++ b/tests/php/Control/LoginSessionMiddlewareTest.yml @@ -5,9 +5,22 @@ SilverStripe\Security\Member: member2: FirstName: 'Garion' Email: 'garion@example.org' + member_rmh: + FirstName: 'Bob' + Email: 'bob@example.com' SilverStripe\SessionManager\Model\LoginSession: x1: LastAccessed: '2003-02-15 10:00:00' IPAddress: '192.168.0.1' Member: =>SilverStripe\Security\Member.member1 + rmh1: + LastAccessed: '2003-02-15 10:00:00' + IPAddress: '192.168.0.1' + UserAgent: 'Internet Explorer 2' + Member: =>SilverStripe\Security\Member.member_rmh + rmh2: + LastAccessed: '2003-02-15 10:00:00' + IPAddress: '192.168.0.1' + UserAgent: 'Commodore 64 browser' + Member: =>SilverStripe\Security\Member.member_rmh