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.