mirror of
https://github.com/flarum/core.git
synced 2025-08-03 15:07:53 +02:00
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:
@@ -57,12 +57,30 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
section,
|
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()}
|
{this[sectionName]().toArray()}
|
||||||
</FieldSet>
|
</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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +159,12 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
|
|||||||
const isDisabled = !this.state.hasOtherActiveSessions();
|
const isDisabled = !this.state.hasOtherActiveSessions();
|
||||||
|
|
||||||
let terminateAllOthersButton = (
|
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')}
|
{app.translator.trans('core.forum.security.terminate_all_other_sessions')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -174,7 +197,7 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
|
|||||||
terminateAllOtherSessions() {
|
terminateAllOtherSessions() {
|
||||||
if (!confirm(extractText(app.translator.trans('core.forum.security.terminate_all_other_sessions_confirmation')))) return;
|
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
|
return app
|
||||||
.request({
|
.request({
|
||||||
@@ -188,12 +211,28 @@ export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUser
|
|||||||
this.state.removeOtherSessionTokens();
|
this.state.removeOtherSessionTokens();
|
||||||
|
|
||||||
app.alerts.show({ type: 'success' }, app.translator.trans('core.forum.security.session_terminated', { count }));
|
app.alerts.show({ type: 'success' }, app.translator.trans('core.forum.security.session_terminated', { count }));
|
||||||
|
|
||||||
m.redraw();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
app.alerts.show({ type: 'error' }, app.translator.trans('core.forum.security.session_termination_failed'));
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,20 +2,13 @@ import AccessToken from '../../common/models/AccessToken';
|
|||||||
|
|
||||||
export default class UserSecurityPageState {
|
export default class UserSecurityPageState {
|
||||||
protected tokens: AccessToken[] | null = null;
|
protected tokens: AccessToken[] | null = null;
|
||||||
protected loading: boolean = false;
|
public loadingTerminateSessions: boolean = false;
|
||||||
|
public loadingGlobalLogout: boolean = false;
|
||||||
public isLoading(): boolean {
|
|
||||||
return this.loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
public hasLoadedTokens(): boolean {
|
public hasLoadedTokens(): boolean {
|
||||||
return this.tokens !== null;
|
return this.tokens !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLoading(loading: boolean): void {
|
|
||||||
this.loading = loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTokens(): AccessToken[] | null {
|
public getTokens(): AccessToken[] | null {
|
||||||
return this.tokens;
|
return this.tokens;
|
||||||
}
|
}
|
||||||
|
@@ -472,11 +472,16 @@ core:
|
|||||||
|
|
||||||
# These translations are used in the Security page.
|
# These translations are used in the Security page.
|
||||||
security:
|
security:
|
||||||
developer_tokens_heading: Developer Tokens
|
|
||||||
current_active_session: Current Active Session
|
|
||||||
browser_on_operating_system: "{browser} on {os}"
|
browser_on_operating_system: "{browser} on {os}"
|
||||||
cannot_terminate_current_session: Cannot terminate the current active session. Log out instead.
|
cannot_terminate_current_session: Cannot terminate the current active session. Log out instead.
|
||||||
created: Created
|
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
|
hide_access_token: Hide Token
|
||||||
last_activity: Last activity
|
last_activity: Last activity
|
||||||
never: Never
|
never: Never
|
||||||
@@ -485,7 +490,6 @@ core:
|
|||||||
submit_button: Create Token
|
submit_button: Create Token
|
||||||
title: => core.ref.new_token
|
title: => core.ref.new_token
|
||||||
title_placeholder: Title
|
title_placeholder: Title
|
||||||
empty_text: It looks like there is nothing to see here.
|
|
||||||
revoke_access_token: Revoke
|
revoke_access_token: Revoke
|
||||||
revoke_access_token_confirmation: => core.ref.generic_confirmation_message
|
revoke_access_token_confirmation: => core.ref.generic_confirmation_message
|
||||||
sessions_heading: Active Sessions
|
sessions_heading: Active Sessions
|
||||||
|
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
@@ -108,7 +108,7 @@ class LogOutController implements RequestHandlerInterface
|
|||||||
|
|
||||||
$actor->accessTokens()->delete();
|
$actor->accessTokens()->delete();
|
||||||
|
|
||||||
$this->events->dispatch(new LoggedOut($actor));
|
$this->events->dispatch(new LoggedOut($actor, false));
|
||||||
|
|
||||||
return $this->rememberer->forget($response);
|
return $this->rememberer->forget($response);
|
||||||
}
|
}
|
||||||
|
@@ -49,6 +49,12 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
|||||||
$route->toController(Controller\LogOutController::class)
|
$route->toController(Controller\LogOutController::class)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$map->post(
|
||||||
|
'/global-logout',
|
||||||
|
'globalLogout',
|
||||||
|
$route->toController(Controller\GlobalLogOutController::class)
|
||||||
|
);
|
||||||
|
|
||||||
$map->post(
|
$map->post(
|
||||||
'/login',
|
'/login',
|
||||||
'login',
|
'login',
|
||||||
|
@@ -13,10 +13,19 @@ use Flarum\User\User;
|
|||||||
|
|
||||||
class LoggedOut
|
class LoggedOut
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var User
|
||||||
|
*/
|
||||||
public $user;
|
public $user;
|
||||||
|
|
||||||
public function __construct(User $user)
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $isGlobal;
|
||||||
|
|
||||||
|
public function __construct(User $user, bool $isGlobal = false)
|
||||||
{
|
{
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
|
$this->isGlobal = $isGlobal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
94
framework/core/tests/integration/forum/GlobalLogoutTest.php
Normal file
94
framework/core/tests/integration/forum/GlobalLogoutTest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user