Simple Guardian - Multiple Sessions

So far in the Simple Guardian we've covered browser login, api authentication and using permissions. Today I want to cover using multiple sessions.

There's many reasons you may want to have multiple sessions. You may have a 'secure' part of your site that has a short lived session. You might have a team based setup where you have one session - with different permissions each - for each team, or you might have something as simple as an admin section of your site. In any of these cases Guardian can help you by allowing you to store multiple 'sessions' in you browser session.

Before we proceed

In todays example we're going to look at having a separate admin login. We could have chosen to use a simple permission to declare someone an admin or use a different token type and guard on that. I've almost always found that when you implement an admin session there are many differences in how the current user is handled for login and also admins are usually also users. To keep these concerns separate we're going to use a different session rather than permission or token type.

If you want to see this type of code in action you can refer to the Phoenix Guardian example app. In that application we use Überauth so it will look a little different.

Login

We're going to use code very similar to the browser login code. What we'll end up with is two login handlers. One for normal site usage, and one for the admin section of the site. I'm going to assume that you've already setup the normal login section of the site and we'll just focus on the admin part.

The first thing we're going to want to do is create an admin login endpoint.

defmodule MyApp.Admin.SessionController do
  use PhoenixGuardian.Web, :controller

  def login(conn, params) do
    user = # fetch your user
    if user.is_admin do
      conn
      |> put_flash(:info, "Signed in as #{user.name}")
      |> Guardian.Plug.sign_in(user, :token, key: :admin, perms: %{default: Guardian.Permissions.max})
      |> redirect(to: admin_user_path(conn, :index))
    else
      conn
      |> put_flash(:error, "Unauthorized")
      |> redirect(to: admin_login_path(conn, :new))
    end
  end
end

Here we're using a boolean flag on the user struct is_admin to tell if someone is allowed to have access to the admin section of the site.

The key part to notice in this Guardian.Plug.sign_in is the key: :admin. This tells guardian to to store the token in a different location. The :admin location. The location can be anything, but it feeds into all of Guardian.Plug functions for fetching tokens, permissions, login, logout etc.

Protecting admin endpoints

The first step to protecting your endpoints is setting up a pipeline. You'll need a new pipeline to load the JWT from the :admin part of Guardians session. The pipeline is the same as the normal browser pipeline, but we'll tell it to look in the different location.

pipeline :admin_browser_auth do
  plug Guardian.Plug.VerifySession, key: :admin
  plug Guardian.Plug.LoadResource, key: :admin
end

scope "/admin", MyApp.Admin, as: :admin do
  pipe_through [:browser, :admin_browser_auth]

  get "/login", SessionController, :login

  resources "/users", UserController
end

Notice how similar it is to the browser_auth pipeline. The only difference is again the key: :admin part that tells Guardian.Plug to look in the admin part of the session.

Now that we have an admin login and a pipeline, we can protect our admin endpoints.

defmodule MyApp.Admin.UserController do
  use PhoenixGuardian.Web, :controller

  alias Guardian.Plug.EnsureAuthenticated

  plug EnsureAuthenticated, handler: __MODULE__, key: :admin

  def index(conn, params) do
    users = Repo.all(User)
    render conn, "index.html", users: users
  end
end

Again this is the same as the normal part of the site, we just use key: :admin. Once you've stored a token in the :admin location you use it just the same as before - only now we tell Guardian to look in the right place.

Logout

With a separate login for admin and normal users, it's possible that you'll be logged in as both an admin and a normal user, giving you two sessions.

When logging out we can choose to either logout entirely - both user and admin - or we can just logout the admin session.

Say we just want to logout of the admin section of the site.

defmodule MyApp.Admin.SessionController do
  # snip
 
  plug EnsureAuthenticated, [key: :admin, handler: __MODULE__] when action in [:delete]

  def delete(conn, _params) do
    conn
    |> Guardian.Plug.sign_out(:admin)
    |> put_flash(:info, "Admin signed out")
    |> redirect(to: "/")
  end
end

This will only logout the token found in the :admin section. If we want to logout all sessions we can just call sign_out passing only the conn struct.

  def delete(conn, _params) do
    conn
    |> Guardian.Plug.sign_out
    |> put_flash(:info, "Logged out")
    |> redirect(to: "/")
  end
Impersonation

Now that we have normal users and admin users able to sign in at the same time, we can implement an impersonation feature. We'll implement it on the admin side. This is straying outside of the 'simple guardian' a little bit, but I've done impersonation so many times that I think it's useful enough to cover.

defmodule MyApp.Admin.SessionController do
  plug EnsureAuthenticated, [key: :admin, handler: __MODULE__] when action in [:delete, :impersonate, :stop_impersonating]

  # snip

  def impersonate(conn, params) do
    admin = Guardian.Plug.current_resource(conn, :admin)
    user = Repo.get(User, params["user_id"])
    conn
    |> Guardian.Plug.sign_out(:default)
    |> Guardian.Plug.sign_in(user, :token, perms: %{default: Guardian.Permissions.max}, imp: admin.id)
    |> redirect(to: "/")
  end

  def stop_impersonating(conn, params) do
    conn
    |> Guardian.Plug.sign_out(:default)
    |> redirect(to: admin_user_path(conn, :index))
  end
end
Impersonate function

In the impersonate function, first we grab the admin and the user. We then want to sign out anyone who is currently logged into the default login. This will revoke the current token in preparation for us to login the user to the default session.

We then login to the session as normal, but we're going to put one extra piece of information into the token so that we know that the token is an impersonation one. We'll put the admins id into the :imp key (note this is non-standard). The imp key will now be present in the claims on each request when the JWT is decoded.

At this point, I like to have a way on the front end to know that I'm impersonating and allow me to stop impersonating (logout that user from the default location). To do this I put a simple template in my layout that will be a bar at the top of the page.

<%= if admin_logged_in?(@conn) && logged_in?(@conn) do %>
  <div class='impersonation-bar'>
    <%= link "Stop impersonating #{current_user(@conn).name}",
             to: admin_session_path(@conn, :stop_impersonating),
             method: "DELETE",
             class: "btn btn-xs btn-warning"
    %>
  </div>
<% end %>

Where the helper methods are imported in web.ex:

defmodule MyApp.ViewHelpers do
  def admin_logged_in?(conn) do 
    Guardian.Plug.authenticated?(conn, :admin)
  end

  def admin_user(conn) do
    Guardian.Plug.current_resource(conn, :admin)
  end

  def logged_in?(conn) do
    Guardian.Plug.authenticated?(conn)
  end

  def current_user(conn) do   
    Guardian.Plug.current_resource(conn)
  end
end

We're almost done. For this to work, we need to verify the admin token in the normal site. We don't need to load the admin resource, just verify the token. We'll add another pipeline.

  pipeline :impersonation_browser_auth do
    plug Guardian.Plug.VerifySession, key: :admin
  end

  scope "/", MyApp do
    pipe_through [
      :browser, 
      :browser_auth, 
      :impersonation_browser_auth
    ]
  end

By verifying the token, we'll be able to see if the admin is logged in and valid in our template for the impersonation bar.

That's almost it. The stop_impersonating function is just a simple logout function where we only sign out of the :default location rather than all.