Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Kernel extends HttpKernel
\BookStack\Http\Middleware\StartSessionExtended::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\Impersonate::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
\BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class,
Expand Down
32 changes: 32 additions & 0 deletions app/Http/Middleware/Impersonate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace BookStack\Http\Middleware;

use BookStack\Permissions\Permission;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class Impersonate
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$impersonateId = session('impersonate', null);
if (empty($impersonateId)) {
return $next($request);
}

$realUser = auth()->user();
if ($realUser && $realUser->can(Permission::UsersManage)) {
Auth::onceUsingId($impersonateId);
}

return $next($request);
}
}
30 changes: 30 additions & 0 deletions app/Users/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,36 @@ public function delete(int $id)
return view('users.delete', ['user' => $user]);
}

/**
* Start impersonating the specified user.
*/
public function impersonate(int $id)
{
$this->checkPermission(Permission::UsersManage);

$user = $this->userRepo->getById($id);

if ($user->isGuest() || $user->id === user()->id) {
$this->showErrorNotification(trans('errors.users_cannot_impersonate'));
return redirect("/settings/users/{$id}");
}

session(['impersonate' => $user->id]);

return redirect('/');
}

/**
* Stop impersonating and return to user edit page.
*/
public function stopImpersonate()
{
$userId = session('impersonate');
session()->forget('impersonate');

return redirect("/settings/users/{$userId}");
}

/**
* Remove the specified user from storage.
*
Expand Down
1 change: 1 addition & 0 deletions lang/en/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
// Users
'users_cannot_delete_only_admin' => 'You cannot delete the only admin',
'users_cannot_delete_guest' => 'You cannot delete the guest user',
'users_cannot_impersonate' => 'You cannot impersonate this user',
'users_could_not_send_invite' => 'Could not create user since invite email failed to send',

// Roles
Expand Down
5 changes: 5 additions & 0 deletions lang/en/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@
'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.',
'users_password_warning' => 'Only fill the below if you would like to change the password for this user.',
'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.',
'users_impersonate' => 'Impersonate User',
'users_impersonate_desc' => 'Log in and browse the application as this user.',
'users_impersonate_action' => 'Impersonate',
'users_impersonating' => 'Impersonating: :name',
'users_impersonate_stop' => 'Stop Impersonating',
'users_delete' => 'Delete User',
'users_delete_named' => 'Delete user :userName',
'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.',
Expand Down
7 changes: 7 additions & 0 deletions resources/views/layouts/base.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ class="@stack('body-class')">
@include('layouts.parts.base-body-start')
@include('layouts.parts.skip-to-content')
@include('layouts.parts.notifications')
@if(session('impersonate'))
<div style="background-color:#c0392b;color:#fff;text-align:center;padding:8px 16px;font-size:0.9em;">
{{ trans('settings.users_impersonating', ['name' => user()->name]) }}
&nbsp;|&nbsp;
<a href="{{ url('/impersonate/stop') }}" style="color:#fff;text-decoration:underline;">{{ trans('settings.users_impersonate_stop') }}</a>
</div>
@endif
@include('layouts.parts.header')

<div id="content" components="@yield('content-components')" class="block">
Expand Down
33 changes: 20 additions & 13 deletions resources/views/layouts/parts/header-user-menu.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,27 @@ class="icon-item">
</li>
<li role="presentation"><hr></li>
<li>
@php
$logoutPath = match (config('auth.method')) {
'saml2' => '/saml2/logout',
'oidc' => '/oidc/logout',
default => '/logout',
}
@endphp
<form action="{{ url($logoutPath) }}" method="post">
{{ csrf_field() }}
<button class="icon-item" role="menuitem" data-shortcut="logout">
@if(session('impersonate'))
<a href="{{ url('/impersonate/stop') }}" role="menuitem" class="icon-item">
@icon('logout')
<div>{{ trans('auth.logout') }}</div>
</button>
</form>
<div>{{ trans('settings.users_impersonate_stop') }}</div>
</a>
@else
@php
$logoutPath = match (config('auth.method')) {
'saml2' => '/saml2/logout',
'oidc' => '/oidc/logout',
default => '/logout',
}
@endphp
<form action="{{ url($logoutPath) }}" method="post">
{{ csrf_field() }}
<button class="icon-item" role="menuitem" data-shortcut="logout">
@icon('logout')
<div>{{ trans('auth.logout') }}</div>
</button>
</form>
@endif
</li>
</ul>
</div>
11 changes: 11 additions & 0 deletions resources/views/users/edit.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ class="button outline">{{ trans('settings.users_mfa_configure') }}</a>
@endif

@include('users.api-tokens.parts.list', ['user' => $user, 'context' => 'settings'])

@if(!$user->isGuest() && $user->id !== user()->id && userCan('users-manage') && !session('impersonate'))
<section class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.users_impersonate') }}</h2>
<p class="text-muted text-small">{{ trans('settings.users_impersonate_desc') }}</p>
<form action="{{ url("/settings/users/{$user->id}/impersonate") }}" method="post">
{!! csrf_field() !!}
<button type="submit" class="button outline">{{ trans('settings.users_impersonate_action') }}</button>
</form>
</section>
@endif
</div>

@stop
2 changes: 2 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@
Route::get('/settings/users/{id}', [UserControllers\UserController::class, 'edit']);
Route::put('/settings/users/{id}', [UserControllers\UserController::class, 'update']);
Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']);
Route::post('/settings/users/{id}/impersonate', [UserControllers\UserController::class, 'impersonate']);
Route::get('/impersonate/stop', [UserControllers\UserController::class, 'stopImpersonate']);

// User Account
Route::get('/my-account', [UserControllers\UserAccountController::class, 'redirect']);
Expand Down
194 changes: 194 additions & 0 deletions tests/User/UserImpersonationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

namespace Tests\User;

use Tests\TestCase;

class UserImpersonationTest extends TestCase
{
protected function tearDown(): void
{
session()->forget('impersonate');
parent::tearDown();
}

public function test_impersonate_button_shown_on_edit_page_for_admin()
{
$viewer = $this->users->viewer();

$resp = $this->asAdmin()->get("/settings/users/{$viewer->id}");

$this->withHtml($resp)->assertElementExists("form[action$=\"/settings/users/{$viewer->id}/impersonate\"]");
$resp->assertSee('Impersonate User');
}

public function test_impersonate_button_not_shown_for_non_admin()
{
$viewer = $this->users->viewer();
$editor = $this->users->editor();

$resp = $this->actingAs($editor)->get("/settings/users/{$viewer->id}");

$resp->assertDontSee('Impersonate User');
$this->withHtml($resp)->assertElementNotExists("form[action$=\"/settings/users/{$viewer->id}/impersonate\"]");
}

public function test_impersonate_button_not_shown_for_own_user()
{
$admin = $this->users->admin();

$resp = $this->actingAs($admin)->get("/settings/users/{$admin->id}");

$resp->assertDontSee('Impersonate User');
$this->withHtml($resp)->assertElementNotExists("form[action$=\"/settings/users/{$admin->id}/impersonate\"]");
}

public function test_impersonate_button_not_shown_for_guest_user()
{
$guest = $this->users->guest();

$resp = $this->asAdmin()->get("/settings/users/{$guest->id}");

$resp->assertDontSee('Impersonate User');
$this->withHtml($resp)->assertElementNotExists("form[action$=\"/settings/users/{$guest->id}/impersonate\"]");
}

public function test_impersonate_button_not_shown_when_already_impersonating()
{
$viewer = $this->users->viewer();
$editor = $this->users->editor();

$this->asAdmin()->post("/settings/users/{$viewer->id}/impersonate");

$resp = $this->get("/settings/users/{$editor->id}");
$resp->assertDontSee('Impersonate User');
}

public function test_impersonate_sets_session_and_redirects_to_home()
{
$viewer = $this->users->viewer();

$resp = $this->asAdmin()->post("/settings/users/{$viewer->id}/impersonate");

$resp->assertRedirect('/');
$this->assertSessionHas('impersonate', $viewer->id);
}

public function test_impersonate_requires_users_manage_permission()
{
$viewer = $this->users->viewer();
$editor = $this->users->editor();

$resp = $this->actingAs($editor)->post("/settings/users/{$viewer->id}/impersonate");

$resp->assertRedirect('/');
$this->assertSessionMissing('impersonate');
}

public function test_cannot_impersonate_guest_user()
{
$guest = $this->users->guest();

$resp = $this->asAdmin()->post("/settings/users/{$guest->id}/impersonate");

$resp->assertRedirect("/settings/users/{$guest->id}");
$this->assertSessionError('You cannot impersonate this user');
$this->assertSessionMissing('impersonate');
}

public function test_cannot_impersonate_self()
{
$admin = $this->users->admin();

$resp = $this->actingAs($admin)->post("/settings/users/{$admin->id}/impersonate");

$resp->assertRedirect("/settings/users/{$admin->id}");
$this->assertSessionError('You cannot impersonate this user');
$this->assertSessionMissing('impersonate');
}

public function test_impersonation_banner_shown_while_impersonating()
{
$viewer = $this->users->viewer();

$this->asAdmin()->post("/settings/users/{$viewer->id}/impersonate");
$resp = $this->get('/');

$resp->assertSee('Impersonating: ' . $viewer->name);
$resp->assertSee('Stop Impersonating');
}

public function test_impersonation_banner_not_shown_when_not_impersonating()
{
$resp = $this->asAdmin()->get('/');

$resp->assertDontSee('Impersonating:');
$resp->assertDontSee('Stop Impersonating');
}

public function test_requests_are_performed_as_impersonated_user()
{
$viewer = $this->users->viewer();
$admin = $this->users->admin();

$this->actingAs($admin)->post("/settings/users/{$viewer->id}/impersonate");

$resp = $this->get('/');
$resp->assertSee('Impersonating: ' . $viewer->name);
}

public function test_stop_impersonate_clears_session_and_redirects_to_user_edit()
{
$viewer = $this->users->viewer();

$this->asAdmin()->post("/settings/users/{$viewer->id}/impersonate");
$this->assertSessionHas('impersonate', $viewer->id);

$resp = $this->get('/impersonate/stop');

$resp->assertRedirect("/settings/users/{$viewer->id}");
$this->assertSessionMissing('impersonate');
}

public function test_stop_impersonate_banner_gone_after_stopping()
{
$viewer = $this->users->viewer();

$this->asAdmin()->post("/settings/users/{$viewer->id}/impersonate");
$this->get('/impersonate/stop');

$resp = $this->get('/');
$resp->assertDontSee('Impersonating:');
}

public function test_middleware_does_not_switch_user_without_impersonate_session()
{
$admin = $this->users->admin();

$resp = $this->actingAs($admin)->get('/');

$resp->assertDontSee('Impersonating:');
}

public function test_middleware_does_not_switch_user_if_actor_lacks_users_manage()
{
$viewer = $this->users->viewer();
$editor = $this->users->editor();

$this->actingAs($editor)->withSession(['impersonate' => $viewer->id]);

$resp = $this->get('/');

$resp->assertDontSee('Impersonating: ' . $viewer->name);
}

public function test_stop_impersonate_link_shown_in_user_menu_while_impersonating()
{
$viewer = $this->users->viewer();

$this->asAdmin()->post("/settings/users/{$viewer->id}/impersonate");
$resp = $this->get('/');

$this->withHtml($resp)->assertElementContains('a[href="' . url('/impersonate/stop') . '"]', 'Stop Impersonating');
}
}
Loading