mirror of
https://github.com/flarum/framework.git
synced 2024-11-25 09:41:49 +08: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:
parent
d35bb873a8
commit
bbf873442a
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
||||
$this->events->dispatch(new LoggedOut($actor));
|
||||
$this->events->dispatch(new LoggedOut($actor, false));
|
||||
|
||||
return $this->rememberer->forget($response);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
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'],
|
||||
];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user