Laravel Impersonate Another User

Feb 02, 2022 5 minutes read
User next to desktop (mevelix.com)

Impersonation feature is a functionality that allows Administrators to access the User account and use the application as if they were logged in to that User. This is probably one of the best and safer ways to troubleshoot.

When it comes to testing, Laravel provides such functionality. In the TestCase, you can use the actingAs method, which allows you to send the Request as a given user.

class ExampleTest extends TestCase
{
    public function test_example()
    {
        $user = User::query()->findOrFail(1);
        $this
            ->actingAs($user)
            ->get('/');
        # ...
    }
}

All in all, this is enough to perform unit, functional or even end-to-end testing with Laravel Dust. But sometimes customers expect to create a functionality that will allow them to check the profile of a specific user in production system. This is a common feature on small and large systems (e.g. Workday Inc, Oracle etc). In Laravel, creating this feature should take about 10 minutes. Let's check how to do it.

Register Middleware

In Laravel, the best way to create this feature is to use Middleware. It is part of the framework that can be invoked when receiving the Request. To create it let's use the artisan command.

php artisan make:middleware UserProxy

It will generate one file, in the default namespace App\Http\Middleware. This snippet already contains all the logic we need for user substitution.

<?php

namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class UserProxy
{
    public function handle(Request $request, Closure $next)
    {
        if (!$request->user()) {
            return $next($request);
        }

        if ($request->user()->can('user-proxy')) {
            if ($request->ajax()) {
                $id = $request->header('X-USER-PROXY-ID');
            } else {
                $id = $request->session()->get('user-proxy-id');
            }
            if (!$id) {
                return $next($request);
            }
            $user = User::query()->findOrFail($id);
            Auth::setUser($user);
        }

        return $next($request);
    }
}

Next, open the App\Http\Kernel and register it in $middlewareGroups property.

protected $middlewareGroups = [
	'web' => [
		// ...
		\App\Http\Middleware\UserProxy::class,
	],
	'api' => [
		// ...
		\App\Http\Middleware\UserProxy::class,
	],
];

In this case, it was registered for two groups: api and web. This means that from now on all requests (standard and ajax) will call the handle method in UserProxy before reaching the specific controller.

Create Policy

Before we go any further, this feature needs to be secured. Let's use Policies for this, but without creating a new file, let's just create an entry in AuthServiceProvider. In place of the isAdmin method, you need to create such a method or check if the current user is the administrator according to your own logic.

<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [];

    public function boot()
    {
        $this->registerPolicies();
        // ...
        Gate::define('user-proxy', function (User $user) {
            return $user->isAdmin(); // your logic here
        });
    }
}

Add routes

The next step is to create routes that will allow you to enter and exit impersonation mode. Let's create it in the file routes/web.php because the way routes are organized is very individual. For additional security, you can put these endpoints in the auth group so that only logged in users can access them.

You can also move it to a dedicated controller if you prefer. The advantage of that movement would be the ability to cache routes, since routes with closures cannot be cached. It's up to you.

// Enter Impersonation Mode
Route::any('/user-proxy/enter/{id}', function ($id) {
    request()->session()->put('user-proxy-id', $id);
    return redirect('/');
});

// Exit Impersonation Mode
Route::any('/user-proxy/exit', function () {
    request()->session()->remove('user-proxy-id');
    return redirect('/');
});

API requests

Since we added middleware to the api group as well, this means we have to somehow tell the frontend that impersonate mode is currently enabled. There are several solutions, it all depends on whether you use templates or SPA (vue or react). In this case, we will do as if the templates were used (MVC standard). This snippet should be embedded in every page. Try to put it in the main template. It contains a meta tag on the basis of which we will check if and for what user the impersonation mode is enabled.

@if(session('user-proxy-id'))
<meta name="user-proxy-id" content="{{session('user-proxy-id')}}">
@endif

If you are using axios to handle http communication, you can use interceptors for that. However, the easiest way is to add a default header, which will always be attached for any request you send to API.

// e.g. app.js
let userProxyId = document.querySelector('meta[name="user-proxy-id"]')?.content;
axios.defaults.headers.common['X-USER-PROXY-ID'] = userProxyId;

It's required because each routes defined in web.php has access to the session, but routes defined in api.php are by definition stateless (no access to sessions at all), that's why we need that configuration. Otherwise if you do not need to proxy an API requests or you are using only routes defined in web middleware you do not need that.

That's all

So now we have a tool ready to use. If you want to enable impersonation mode for a user with ID 256, go to

/user-proxy/enter/256

and cancel by hitting that URL

/user-proxy/exit

As you can see it was easy! Additionally, you can create another table in the database that would log each switch of this mode. Remember that impersonation is a very sensitive feature, and it is worth storing information in the database who was the real user of the action.

To avoid adding more columns to your existing entities with who the real user is, check what is a CQRS, Event store or other architecture that allows you to create a command bus. Then you could save this information in one place - the global table of all actions.

Disclaimer: The opinions expressed here are my own and do not necessarily represent those of current or past employers.
Comments (0)