From bbf873442a222c1f3a4b90a0dd9972654894c80e Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Tue, 21 Feb 2023 15:28:55 +0100 Subject: [PATCH] feat: global logout to clear all sessions, access tokens, email tokens and password tokens (#3605) * chore: re-organize security locale keys alphabetically * test: can globally logout * feat: add global logout controller * feat: add global logout UI to user security page * test: re-adapt tests to changes * feat: add boolean to indicate if logout even is global * chore(review): split loading property * chore: follow-up branch update Signed-off-by: Sami Mazouz --- .../src/forum/components/UserSecurityPage.tsx | 51 ++++++++-- .../src/forum/states/UserSecurityPageState.ts | 11 +-- framework/core/locale/core.yml | 10 +- .../Controller/GlobalLogOutController.php | 74 +++++++++++++++ .../src/Forum/Controller/LogOutController.php | 2 +- framework/core/src/Forum/routes.php | 6 ++ framework/core/src/User/Event/LoggedOut.php | 11 ++- .../integration/forum/GlobalLogoutTest.php | 94 +++++++++++++++++++ 8 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 framework/core/src/Forum/Controller/GlobalLogOutController.php create mode 100644 framework/core/tests/integration/forum/GlobalLogoutTest.php diff --git a/framework/core/js/src/forum/components/UserSecurityPage.tsx b/framework/core/js/src/forum/components/UserSecurityPage.tsx index 96d97dd19..d0fdac8aa 100644 --- a/framework/core/js/src/forum/components/UserSecurityPage.tsx +++ b/framework/core/js/src/forum/components/UserSecurityPage.tsx @@ -57,12 +57,30 @@ export default class UserSecurityPage +
{this[sectionName]().toArray()}
); }); + if (this.user!.id() === app.session.user!.id()) { + items.add( + 'globalLogout', +
+ {app.translator.trans('core.forum.security.global_logout.help_text')} + +
+ ); + } + return items; } @@ -141,7 +159,12 @@ export default class UserSecurityPage + ); @@ -174,7 +197,7 @@ export default class UserSecurityPage { app.alerts.show({ type: 'error' }, app.translator.trans('core.forum.security.session_termination_failed')); }) - .finally(() => this.state.setLoading(false)); + .finally(() => { + this.state.loadingTerminateSessions = false; + m.redraw(); + }); + } + + globalLogout() { + this.state.loadingGlobalLogout = true; + + return app + .request({ + method: 'POST', + url: app.forum.attribute('baseUrl') + '/global-logout', + }) + .then(() => window.location.reload()) + .finally(() => { + this.state.loadingGlobalLogout = false; + m.redraw(); + }); } } diff --git a/framework/core/js/src/forum/states/UserSecurityPageState.ts b/framework/core/js/src/forum/states/UserSecurityPageState.ts index 4bf863969..f3da40bcc 100644 --- a/framework/core/js/src/forum/states/UserSecurityPageState.ts +++ b/framework/core/js/src/forum/states/UserSecurityPageState.ts @@ -2,20 +2,13 @@ import AccessToken from '../../common/models/AccessToken'; export default class UserSecurityPageState { protected tokens: AccessToken[] | null = null; - protected loading: boolean = false; - - public isLoading(): boolean { - return this.loading; - } + public loadingTerminateSessions: boolean = false; + public loadingGlobalLogout: boolean = false; public hasLoadedTokens(): boolean { return this.tokens !== null; } - public setLoading(loading: boolean): void { - this.loading = loading; - } - public getTokens(): AccessToken[] | null { return this.tokens; } diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 007fb0f07..c41df6366 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -472,11 +472,16 @@ core: # These translations are used in the Security page. security: - developer_tokens_heading: Developer Tokens - current_active_session: Current Active Session browser_on_operating_system: "{browser} on {os}" cannot_terminate_current_session: Cannot terminate the current active session. Log out instead. created: Created + current_active_session: Current Active Session + developer_tokens_heading: Developer Tokens + empty_text: It looks like there is nothing to see here. + global_logout: + heading: Global Logout + help_text: "Clears current cookie session, terminates all sessions, revokes developer tokens, and invalidates any email confirmation or password reset emails." + log_out_button: => core.ref.log_out hide_access_token: Hide Token last_activity: Last activity never: Never @@ -485,7 +490,6 @@ core: submit_button: Create Token title: => core.ref.new_token title_placeholder: Title - empty_text: It looks like there is nothing to see here. revoke_access_token: Revoke revoke_access_token_confirmation: => core.ref.generic_confirmation_message sessions_heading: Active Sessions diff --git a/framework/core/src/Forum/Controller/GlobalLogOutController.php b/framework/core/src/Forum/Controller/GlobalLogOutController.php new file mode 100644 index 000000000..0a9b3ed97 --- /dev/null +++ b/framework/core/src/Forum/Controller/GlobalLogOutController.php @@ -0,0 +1,74 @@ +events = $events; + $this->authenticator = $authenticator; + $this->rememberer = $rememberer; + $this->url = $url; + } + + public function handle(Request $request): ResponseInterface + { + $session = $request->getAttribute('session'); + $actor = RequestUtil::getActor($request); + + $actor->assertRegistered(); + + $this->authenticator->logOut($session); + + $actor->accessTokens()->delete(); + $actor->emailTokens()->delete(); + $actor->passwordTokens()->delete(); + + $this->events->dispatch(new LoggedOut($actor, true)); + + return $this->rememberer->forget(new EmptyResponse()); + } +} diff --git a/framework/core/src/Forum/Controller/LogOutController.php b/framework/core/src/Forum/Controller/LogOutController.php index 5c8f30e0a..c2ef4800e 100644 --- a/framework/core/src/Forum/Controller/LogOutController.php +++ b/framework/core/src/Forum/Controller/LogOutController.php @@ -108,7 +108,7 @@ class LogOutController implements RequestHandlerInterface $actor->accessTokens()->delete(); - $this->events->dispatch(new LoggedOut($actor)); + $this->events->dispatch(new LoggedOut($actor, false)); return $this->rememberer->forget($response); } diff --git a/framework/core/src/Forum/routes.php b/framework/core/src/Forum/routes.php index dd5c00094..d0264cb8c 100644 --- a/framework/core/src/Forum/routes.php +++ b/framework/core/src/Forum/routes.php @@ -49,6 +49,12 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\LogOutController::class) ); + $map->post( + '/global-logout', + 'globalLogout', + $route->toController(Controller\GlobalLogOutController::class) + ); + $map->post( '/login', 'login', diff --git a/framework/core/src/User/Event/LoggedOut.php b/framework/core/src/User/Event/LoggedOut.php index d2b2d20df..6beb2d4a6 100644 --- a/framework/core/src/User/Event/LoggedOut.php +++ b/framework/core/src/User/Event/LoggedOut.php @@ -13,10 +13,19 @@ use Flarum\User\User; class LoggedOut { + /** + * @var User + */ public $user; - public function __construct(User $user) + /** + * @var bool + */ + public $isGlobal; + + public function __construct(User $user, bool $isGlobal = false) { $this->user = $user; + $this->isGlobal = $isGlobal; } } diff --git a/framework/core/tests/integration/forum/GlobalLogoutTest.php b/framework/core/tests/integration/forum/GlobalLogoutTest.php new file mode 100644 index 000000000..909a003ef --- /dev/null +++ b/framework/core/tests/integration/forum/GlobalLogoutTest.php @@ -0,0 +1,94 @@ +extend( + (new Extend\Csrf) + ->exemptRoute('globalLogout') + ->exemptRoute('login') + ); + + $this->prepareDatabase([ + 'users' => [ + $this->normalUser() + ], + 'access_tokens' => [ + ['id' => 1, 'token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['id' => 2, 'token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'], + ['id' => 3, 'token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + + ['id' => 4, 'token' => 'd', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['id' => 5, 'token' => 'e', 'user_id' => 2, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ], + 'email_tokens' => [ + ['token' => 'd', 'email' => 'test1@machine.local', 'user_id' => 1, 'created_at' => Carbon::parse('2021-01-01 02:00:00')], + ['token' => 'e', 'email' => 'test2@machine.local', 'user_id' => 2, 'created_at' => Carbon::parse('2021-01-01 02:00:00')], + ], + 'password_tokens' => [ + ['token' => 'd', 'user_id' => 1, 'created_at' => Carbon::parse('2021-01-01 02:00:00')], + ['token' => 'e', 'user_id' => 2, 'created_at' => Carbon::parse('2021-01-01 02:00:00')], + ] + ]); + } + + /** + * @dataProvider canGloballyLogoutDataProvider + * @test + */ + public function can_globally_log_out(int $authenticatedAs, string $identification, string $password) + { + $loginResponse = $this->send( + $this->request('POST', '/login', [ + 'json' => compact('identification', 'password') + ]) + ); + + $response = $this->send( + $this->requestWithCookiesFrom( + $this->request('POST', '/global-logout'), + $loginResponse, + ) + ); + + $this->assertEquals(204, $response->getStatusCode()); + + $this->assertEquals(0, AccessToken::query()->where('user_id', $authenticatedAs)->count()); + $this->assertEquals(0, EmailToken::query()->where('user_id', $authenticatedAs)->count()); + $this->assertEquals(0, PasswordToken::query()->where('user_id', $authenticatedAs)->count()); + } + + public function canGloballyLogoutDataProvider(): array + { + return [ + // Admin + [1, 'admin', 'password'], + + // Normal user + [2, 'normal', 'too-obscure'], + ]; + } +}