Simple impersonation with Guardian

Hi there! Today I would like to write about how to add the feature of impersonation in your Phoenix app. In this post we won’t talk about how to add Guardian login. I’m going to assume that you already have Guardian in your app.

What’s Guardian:

An authentication framework for use with Elixir applications.

You can read about it here

The approach:

The main idea it’s pretty simple: Guardian allow us to have multiple sessions. So we’ll take in advantage this feature and we’re going to create a couple of sessions, with different key, every time a user do login. Thereby when we perform an impersonation we’ll change the current user, but we’ll maintain the second session untouched, in order to know who we were before.

The code:

Main idea

The first of all, that cover the main idea, it’s to add in your create method of your sessions_controller.ex, a new sign in with a custom key. In this example I’m going to use impersonated_user:

def create(conn, params = %{}) do
  conn
  |> put_flash(:info, "Logged in.")
  |> Guardian.Plug.sign_in(user)
  |> Guardian.Plug.sing_in(user, :access, key: :impersonated_user)
  |> redirect(to: user_path(conn, :index))
end

Also, we need to «activate» this new key in our pipelines. So you need to go to your router.ex file, and edit the browser_session pipeline. It should look like this:

pipeline :browser_session do
  plug Guardian.Plug.VerifySession
  plug Guardian.Plug.LoadResource
  plug Guardian.Plug.VerifySession, key: :impersonated_user
  plug Guardian.Plug.LoadResource, key: :impersonated_user
end

(Also you can create a new pipeline for impersonated_user and add it wherever you need)

With this, in our Guardian.Plug resource, we have a couple of JWT with the user info, but differentiated by its key.

You can access to the info with:

Guardian.Plug.current_resource(conn)
Guardian.Plug.current_resource(conn, :impersonated_user)

The next thing we need to do, it’s to store this info into the connection assigns. In order to be able to access it by conn.assigns.impersonated_user. For this pourpose, we’re going to create a Plug, in our plugs folder.

defmodule App.Plug.ImpersonatedUser do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    impersonated_user = Guardian.Plug.current_resource(conn, :impersonated_user)
    assign(conn, :impersonated_user, impersonated_user)
  end
end

Now we need to add this Plug to our pipelines. For example, in our authenticated_user. That manage the user authentication. Once again, in our router.ex:

pipeline :authenticated_user do
  plug Guardian.Plug.EnsureAuthenticated, handler: App.SessionController
  plug Guardian.Plug.EnsureResource, handler: App.SessionController
  plug App.Plug.CurrentUser
  plug App.Plug.ImpersonatedUser
end

Impersonation

Okay! Now we’re prepared to make the impersonation. For doing this, we’re creating a new controller that will change the current user by the one we want to supplant.

defmodule App.ImpersonateController do
  use App.Web, :controller

  alias App.User
  alias App.Repo

  def impersonate(conn, %{"impersonated" => %{"user_id" => user_id}}) do
    user = Repo.get(User, user_id)
    impersonated_user = conn.assigns.current_user

    conn
    |> Guardian.Plug.sign_out
    |> Guardian.Plug.sign_in(user)
    |> Guardian.Plug.sign_in(impersonated_user, :access, key: :impersonated_user)
    |> redirect(to: "/")
  end

  def stop_impersonation(conn, _params) do
    impersonated_user = conn.assigns.impersonated_user

    conn
    |> Guardian.Plug.sign_out
    |> Guardian.Plug.sign_in(impersonated_user)
    |> Guardian.Plug.sign_in(impersonated_user, :access, key: :impersonated_user)
    |> redirect(to: "/")
  end
end

Easy peasy, right? We are login as the requested user and storing the original/real user in the impersonated_user. Thus we’re managing 2 sessions at the same time.

The next thing you need to add it’s a form that sends the info to our impersonate method. Also, you need to add a link to stop_impersonation. But I don’t want to spread out. I think that the main, and important, idea it’s explained xP

Test!

Yep, tests always matters. I’m going to assume that you already have a conn_case.ex or similar, that have a guardian_login method. So we need to add the new :impersonated_user sing_in in there. After that, our guardian_login method should look like this:

def guardian_login(%Plug.Conn{} = conn, user, token, opts) do
  conn
    |> bypass_through(App.Router, [:browser])
    |> get("/")
    |> Guardian.Plug.sign_in(user)
    |> Guardian.Plug.sign_in(user, :access, key: :impersonated_user)
    |> send_resp(200, "Flush the session yo")
    |> recycle()
end

Then, our tests:

defmodule App.ImpersonateControllerTest do
  use App.ConnCase
  alias App.Test.Helper

  setup do
    admin = insert(:admin)
    user = insert(:user)
    conn = guardian_login(conn, admin, :token, [])

    {:ok, %{conn: conn, admin: admin, user: user}}
  end

  describe "[POST] impersonate" do
    test "change current user when impersonating", %{conn: conn, admin: admin, user: user} do
      conn = post conn, impersonate_path(conn, :impersonate), impersonated: %{"user_id" => user.id}

      assert Guardian.Plug.current_resource(conn).id == user.id
      assert Guardian.Plug.current_resource(conn, :impersonated_user).id == admin.id
    end
  end

  describe "[GET] stop_impersonation" do
    test "stop impersonation when user is impersonated", %{conn: conn, admin: admin, user: user} do
      conn = post conn, impersonate_path(conn, :impersonate), impersonated: %{"user_id" => user.id}
      conn = get conn, stop_impersonation_path(conn, :stop_impersonation)

      assert Guardian.Plug.current_resource(conn).id == admin.id
      assert Guardian.Plug.current_resource(conn, :impersonated_user).id == admin.id
    end
  end
end

I wanted to talk about the test because as you can see, the assert is against Guardian.Plug.current_resource instead of conn.assigns. This is because the assigns are set in every request, passing through the pipelines. So, when the server returns the response, no pipeline are executed. And the assigns are not updated with the new info. So we need to assert against the info in the plug. That extracts the info directly from the JWT token, instead of the conn. (I hope I have explained this xD)

And that’s all! I hope you find it useful. You can find me on twitter @enoliglesias for whatever you want to ask x)

comments powered by Disqus