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 <sychocouldy@gmail.com>
This commit is contained in:
Sami Mazouz 2023-02-21 15:28:55 +01:00 committed by GitHub
parent d35bb873a8
commit bbf873442a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 239 additions and 20 deletions

View File

@ -57,12 +57,30 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
items.add(
section,
<FieldSet className={`Security-${section}`} label={app.translator.trans(`core.forum.security.${sectionLocale}_heading`)}>
<FieldSet className={`UserSecurityPage-${section}`} label={app.translator.trans(`core.forum.security.${sectionLocale}_heading`)}>
{this[sectionName]().toArray()}
</FieldSet>
);
});
if (this.user!.id() === app.session.user!.id()) {
items.add(
'globalLogout',
<FieldSet className="UserSecurityPage-globalLogout" label={app.translator.trans('core.forum.security.global_logout.heading')}>
<span className="helpText">{app.translator.trans('core.forum.security.global_logout.help_text')}</span>
<Button
className="Button"
icon="fas fa-sign-out-alt"
onclick={this.globalLogout.bind(this)}
loading={this.state.loadingGlobalLogout}
disabled={this.state.loadingTerminateSessions}
>
{app.translator.trans('core.forum.security.global_logout.log_out_button')}
</Button>
</FieldSet>
);
}
return items;
}
@ -141,7 +159,12 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
const isDisabled = !this.state.hasOtherActiveSessions();
let terminateAllOthersButton = (
<Button className="Button" onclick={this.terminateAllOtherSessions.bind(this)} loading={this.state.isLoading()} disabled={isDisabled}>
<Button
className="Button"
onclick={this.terminateAllOtherSessions.bind(this)}
loading={this.state.loadingTerminateSessions}
disabled={this.state.loadingGlobalLogout || isDisabled}
>
{app.translator.trans('core.forum.security.terminate_all_other_sessions')}
</Button>
);
@ -174,7 +197,7 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
terminateAllOtherSessions() {
if (!confirm(extractText(app.translator.trans('core.forum.security.terminate_all_other_sessions_confirmation')))) return;
this.state.setLoading(true);
this.state.loadingTerminateSessions = true;
return app
.request({
@ -188,12 +211,28 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
this.state.removeOtherSessionTokens();
app.alerts.show({ type: 'success' }, app.translator.trans('core.forum.security.session_terminated', { count }));
m.redraw();
})
.catch(() => {
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<string>('baseUrl') + '/global-logout',
})
.then(() => window.location.reload())
.finally(() => {
this.state.loadingGlobalLogout = false;
m.redraw();
});
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -0,0 +1,74 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Forum\Controller;
use Flarum\Http\Rememberer;
use Flarum\Http\RequestUtil;
use Flarum\Http\SessionAuthenticator;
use Flarum\Http\UrlGenerator;
use Flarum\User\Event\LoggedOut;
use Illuminate\Contracts\Events\Dispatcher;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
class GlobalLogOutController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $events;
/**
* @var SessionAuthenticator
*/
protected $authenticator;
/**
* @var Rememberer
*/
protected $rememberer;
/**
* @var UrlGenerator
*/
protected $url;
public function __construct(
Dispatcher $events,
SessionAuthenticator $authenticator,
Rememberer $rememberer,
UrlGenerator $url
) {
$this->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());
}
}

View File

@ -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);
}

View File

@ -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',

View File

@ -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;
}
}

View File

@ -0,0 +1,94 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\forum;
use Carbon\Carbon;
use Flarum\Extend;
use Flarum\Http\AccessToken;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\EmailToken;
use Flarum\User\PasswordToken;
class GlobalLogoutTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
$this->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'],
];
}
}