'Impersonate users with Sanctum

I'm implementing a simple impersonation system for Laravel with Sanctum. Earlier I used Tymondesigns/jwt-auth with Rickycezar/laravel-jwt-impersonate, but we recently abandoned Tymon JWT for Sanctum.

I had no luck implementing laravel-jwt-impersonate with Sanctum, or the original 404labfr/laravel-impersonate that it was forked from. So... I decided to try and implement a really simple impersonation system myself.

This is what I'm trying to do now:

When the admin calls the impersonate() function I create a token for the user that is being impersonated. This token is returned to Frontend and used as bearer token. It seems to work well and after doing this the app acts as if I'm the impersonated user (since I'm actually "logged in" as that user).

public function impersonate($user)
{
    $user = User::find($user);
    return $user->createToken('IMPERSONATE token');
}

The next step is what I'm wondering about. How to end the impersonation. If I log out, the created impersonation token is removed, so all good there... But that means that the admin is now logged out and has to log in again.

I would like to log the admin back in with the earlier token. But I don't know how to return the token that admin was logged in with.

So my questions:

  1. How would you go about solving this?
  2. What obvious security risks do you see (impersonation is only allowed from the admin account and only non admins can be impersonated)?


Solution 1:[1]

This is the solution I went for. Turned out a little bit bigger than first anticipated. It seems to work just as fine as when we used Tymondesigns/jwt-auth with Rickycezar/laravel-jwt-impersonate. I use the same structure for the responses so Front End didn't need to make any changes.

I also outlined a bit more in this blog post.

Migration

public function up()
{
    Schema::create('impersonations', function (Blueprint $table) {
        $table->id();
        $table->bigInteger('personal_access_token_id')->unsigned();
        $table->bigInteger('user_id')->unsigned();
        $table->timestamps();

        $table->foreign('personal_access_token_id')->references('id')->on('personal_access_tokens')->cascadeOnDelete();
        $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
    });
}

User model gets these three functions

public function canImpersonate()
{
    return $this->is_superadmin;
}


public function canBeImpersonated()
{
    return !$this->is_superadmin;
}


public function isImpersonated() {
    $token = $this->currentAccessToken();
    return $token->name == 'IMPERSONATION token';
}

Impersonate function. Put this wherever it makes sense. For me it's in my superadmin controller

public function impersonate($userId)
{
    $impersonator = auth()->user();
    $persona = User::find($userId);

    // Check if persona user exists, can be impersonated and if the impersonator has the right to do so.
    if (!$persona || !$persona->canBeImpersonated() || !$impersonator->canImpersonate()) {
        return false;
    }

    // Create new token for persona
    $personaToken = $persona->createToken('IMPERSONATION token');

    // Save impersonator and persona token references
    $impersonation = new Impersonation();
    $impersonation->user_id = $impersonator->id;
    $impersonation->personal_access_token_id = $personaToken->accessToken->id;
    $impersonation->save();

    // Log out impersonator
    $impersonator->currentAccessToken()->delete();

    $response = [
        "requested_id" => $userId,
        "persona" => $persona,
        "impersonator" => $impersonator,
        "token" => $personaToken->plainTextToken
    ];

    return response()->json(['data' => $response], 200);
}

Leave impersonation

public function leaveImpersonate()
{
    // Get impersonated user
    $impersonatedUser = auth()->user();

    // Find the impersonating user
    $currentAccessToken = $impersonatedUser->currentAccessToken();
    $impersonation = Impersonation::where('personal_access_token_id', $currentAccessToken->id)->first();
    $impersonator = User::find($impersonation->user_id);
    $impersonatorToken = $impersonator->createToken('API token')->plainTextToken;

    // Logout impersonated user
    $impersonatedUser->currentAccessToken()->delete();

    $response = [
        "requested_id" => $impersonator->id,
        "persona" => $impersonator,
        "token" => $impersonatorToken,
    ];

    return response()->json(['data' => $response], 200);
}

Solution 2:[2]

You're almost there! We've an app which is also using Sanctum, but using session instead of token. Before we to impersonate the user, we put the admin/actual user's id into a session:

$request->session()->put('impersonate', true); // if you need to check if session is impersonated or not
$request->session()->put('impersonate_admin_id', Auth::id());
Auth::login($user); // then impersonate

So we can log back into admin on logout/exit impersonation using the user id:

Auth::loginUsingId($request->session()->get('impersonate_admin_id'));

Although my example was session based, you get the gist. Since yours is token based, and you can't store this on a session or a cookie, I'd suggest either DB or Redis/Cache.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 sykez