From 958949208176a563e620cb4c13f58668894acf64 Mon Sep 17 00:00:00 2001 From: JonatanRek Date: Tue, 7 Apr 2026 18:40:13 +0200 Subject: [PATCH 1/4] Implement Imper. --- app/Http/Kernel.php | 1 + app/Http/Middleware/Impersonate.php | 32 ++++++++++++++++++ app/Users/Controllers/UserController.php | 30 +++++++++++++++++ lang/en/errors.php | 1 + lang/en/settings.php | 5 +++ resources/views/layouts/base.blade.php | 7 ++++ .../layouts/parts/header-user-menu.blade.php | 33 +++++++++++-------- resources/views/users/edit.blade.php | 11 +++++++ routes/web.php | 2 ++ 9 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 app/Http/Middleware/Impersonate.php diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 00bf8cbe1c5..81bcfead91c 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -36,6 +36,7 @@ class Kernel extends HttpKernel \BookStack\Http\Middleware\CheckEmailConfirmed::class, \BookStack\Http\Middleware\RunThemeActions::class, \BookStack\Http\Middleware\Localization::class, + \BookStack\Http\Middleware\Impersonate::class, ], 'api' => [ \BookStack\Http\Middleware\ThrottleApiRequests::class, diff --git a/app/Http/Middleware/Impersonate.php b/app/Http/Middleware/Impersonate.php new file mode 100644 index 00000000000..b3c540a9594 --- /dev/null +++ b/app/Http/Middleware/Impersonate.php @@ -0,0 +1,32 @@ +user(); + if ($realUser && $realUser->can(Permission::UsersManage)) { + Auth::onceUsingId($impersonateId); + } + + return $next($request); + } +} diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 494221b143e..4ab838fc6a1 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -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. * diff --git a/lang/en/errors.php b/lang/en/errors.php index 20537d59f0c..9ee6ba0cf0b 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -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 diff --git a/lang/en/settings.php b/lang/en/settings.php index c4d1eb136eb..8448be5aee9 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -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.', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index b868cb88804..78b8a11bc2f 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -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')) +
+ {{ trans('settings.users_impersonating', ['name' => user()->name]) }} +  |  + {{ trans('settings.users_impersonate_stop') }} +
+ @endif @include('layouts.parts.header')
diff --git a/resources/views/layouts/parts/header-user-menu.blade.php b/resources/views/layouts/parts/header-user-menu.blade.php index c252deb8218..477760fb78e 100644 --- a/resources/views/layouts/parts/header-user-menu.blade.php +++ b/resources/views/layouts/parts/header-user-menu.blade.php @@ -39,20 +39,27 @@ class="icon-item">

  • - @php - $logoutPath = match (config('auth.method')) { - 'saml2' => '/saml2/logout', - 'oidc' => '/oidc/logout', - default => '/logout', - } - @endphp -
    - {{ csrf_field() }} - - +
    {{ trans('settings.users_impersonate_stop') }}
    +
    + @else + @php + $logoutPath = match (config('auth.method')) { + 'saml2' => '/saml2/logout', + 'oidc' => '/oidc/logout', + default => '/logout', + } + @endphp +
    + {{ csrf_field() }} + +
    + @endif
  • \ No newline at end of file diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 611653d6a80..e0e9141fa45 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -103,6 +103,17 @@ class="button outline">{{ trans('settings.users_mfa_configure') }} @endif @include('users.api-tokens.parts.list', ['user' => $user, 'context' => 'settings']) + + @if(!$user->isGuest() && $user->id !== user()->id && userCan('users-manage') && !session('impersonate')) +
    +

    {{ trans('settings.users_impersonate') }}

    +

    {{ trans('settings.users_impersonate_desc') }}

    +
    id}/impersonate") }}" method="post"> + {!! csrf_field() !!} + +
    +
    + @endif @stop diff --git a/routes/web.php b/routes/web.php index a20c0a3d3d0..248f9e380d1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); From b76e632f081711cb05c8a0b50e4b90b1a183f0de Mon Sep 17 00:00:00 2001 From: JonatanRek Date: Tue, 7 Apr 2026 18:42:39 +0200 Subject: [PATCH 2/4] Fix --- app/Http/Kernel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 81bcfead91c..49410fe0743 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -33,10 +33,10 @@ 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, - \BookStack\Http\Middleware\Impersonate::class, ], 'api' => [ \BookStack\Http\Middleware\ThrottleApiRequests::class, From 08481b0372fc1342b427803901c87edc470087d6 Mon Sep 17 00:00:00 2001 From: JonatanRek Date: Tue, 7 Apr 2026 18:45:47 +0200 Subject: [PATCH 3/4] Add Tests --- tests/User/UserImpersonationTest.php | 188 +++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/User/UserImpersonationTest.php diff --git a/tests/User/UserImpersonationTest.php b/tests/User/UserImpersonationTest.php new file mode 100644 index 00000000000..08ebdf95a6f --- /dev/null +++ b/tests/User/UserImpersonationTest.php @@ -0,0 +1,188 @@ +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'); + } +} From 25c5170da2155e24dd1582e7468f9b3fb2e2e6ae Mon Sep 17 00:00:00 2001 From: JonatanRek Date: Tue, 7 Apr 2026 19:29:13 +0200 Subject: [PATCH 4/4] Fix Tests race conditions --- tests/User/UserImpersonationTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/User/UserImpersonationTest.php b/tests/User/UserImpersonationTest.php index 08ebdf95a6f..66ad72d77ae 100644 --- a/tests/User/UserImpersonationTest.php +++ b/tests/User/UserImpersonationTest.php @@ -6,6 +6,12 @@ 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();