Session Authentication Example For Phoenix 1.3 Using Guardian 1.0-beta
If you are in a hurry you can just copy all of the code and paste it into the file that is indicated. There are also a few mix commands to run.
UPDATE: This article was written about the beta
version and things have changed slightly. This article is still mostly accurate, but there is a more up-to-date version of this tutorial in the Guardian Repo.
Recently I have been programming in Elixir for work, and wanted to play around with Phoenix for a personal project. My project needed to have different “zones” of authentication:
- logged in
- maybe logged in
I created a small example project to test authentication (github link), which we will re-produce in this post.
0) Create a Phoenix Application
For this exercise I created a new Phoenix application called auth_ex
(authentication_example) by running:
## On the command linemix phx.new auth_ex
If you already have an application you don’t need this step, just note that file paths will be different, and to replace occurences of :auth_ex
with the atom for your project.
1) Specify Dependencies
We are going to be using Guardian (currently version 1.0-beta
) for authentication, and Comeonin (with bcrypt) for our password hashing. Add the dependencies to your mix.exs
file:
## mix.exsdefp deps do
[
{:guardian, "~> 1.0-beta"},
{:comeonin, "~> 4.0"},
{:bcrypt_elixir, "~> 0.12"}
]
end
2) Create a User Model
Guardian uses Json Web Tokens (JWT) to keep track of sessions by storing claims in an encoded JSON object. For our project, we will be doing a simple claim based on a user model. Generate your user model like this, unless you already have one in your project:
## On the command linemix phx.gen.context Auth User users username:string password:string
3) Implement Guardian Callbacks
Now that we have a user model, we need to implement a few callback functions that Guardian relies on for using our model:
## lib/auth_ex/auth/guardian.exdefmodule AuthEx.Auth.Guardian do
use Guardian, otp_app: :auth_ex alias AuthEx.Auth def subject_for_token(user, _claims) do
{:ok, to_string(user.id)}
end def resource_from_claims(claims) do
user = claims["sub"]
|> Auth.get_user!
{:ok, user} # If something goes wrong here return {:error, reason}
endend
The subject_for_token
needs to return something that can identify our user, so we will return the id
field. The resource_from_claims
is just the opposite, where we extract an id
from the claims of JWT, then return the matching user.
4) Password Hashing
We added :comeonin
and :bcrypt_elixir
to our dependencies for having the passwords we are storing. This happens in two places.
First is when a new user is created, the incoming request gives us a plain-text password, and we will need to hash it before storing it in our database. Alter the user model’s changeset like so:
## lib/auth_ex/auth/user.exalias Comeonin.Bcryptdef changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:username, :password])
|> validate_required([:username, :password])
|> put_pass_hash()
enddefp put_pass_hash(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
change(changeset, password: Bcrypt.hashpwsalt(password))
end
defp put_pass_hash(changeset), do: changeset
Now we will also need a way to check the username and password we receive on a login request are valid. For that we will add a new function in the auth
context, where will look for the user based on the username, and then check that the hash of the password matches what was in the database:
## lib/auth_ex/auth/auth.exalias Comeonin.Bcryptdef authenticate_user(username, plain_text_password) do
query = from u in User, where: u.username == ^username
Repo.one(query)
|> check_password(plain_text_password)
enddefp check_password(nil, _), do: {:error, "Incorrect username or password"}defp check_password(user, plain_text_password) do
case Bcrypt.checkpw(plain_text_password, user.password) do
true -> {:ok, user}
false -> {:error, "Incorrect username or password"}
end
end
5) Config
Next we need to add a bit of configuration. First generate yourself a secret, which will be used by Guardian to secure the JWTs that your application generates:
## On the command linemix guardian.gen.secret
Then add this to your config.exs
file:
## config.exsconfig :auth_ex, AuthEx.Auth.Guardian,
issuer: "auth_ex", # Name of your app/company/product
secret_key: "" # Replace this with the output of the mix command
Note that in a production environment the secret_key
is a secret and should not be hard-coded into your configuration file or checked into Github; you should replace it with an environment variable, but that is outside the scope of this post.
6) Pipelines
Now its time to create the pipleines for the different “zones” of authentication in the application (maybe-logged-in and definitely-logged-in). We will create the “base” pipeline first, which is our “maybe logged in” pipeline. The other pipeline will leverage this one, and be defiend in our router. This pipeline checks for a resource (a user) but does not reject the request if one is not found:
## lib/auth_ex/auth/pipeline.exdefmodule AuthEx.Auth.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :auth_ex,
error_handler: AuthEx.Auth.ErrorHandler,
module: AuthEx.Auth.Guardian # If there is a session token, validate it
plug Guardian.Plug.VerifySession, claims: %{"typ" => "access"} # If there is an authorization header, validate it
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"} # Load the user if either of the verifications worked
plug Guardian.Plug.LoadResource, allow_blank: true
end
We will also have to create an error handler, which in our case will just return some text with the error:
## lib/auth_ex/auth/error_handler.exdefmodule AuthEx.Auth.ErrorHandler do
import Plug.Conn def auth_error(conn, {type, _reason}, _opts) do
body = to_string(type)
conn
|> put_resp_content_type("text/plain")
|> send_resp(401, body)
end
end
7) Controller
Its time to setup our controller so that we can wire together the functionality we need to test our new pipeline. For my example I am just using the exisintg PageController with some login/logout stuff added on:
## lib/auth_ex_web/controllers/page_controller.exdefmodule AuthExWeb.PageController do
use AuthExWeb, :controller alias AuthEx.Auth
alias AuthEx.Auth.User
alias AuthEx.Auth.Guardian def index(conn, _params) do
changeset = Auth.change_user(%User{})
maybe_user = Guardian.Plug.current_resource(conn)
message = if maybe_user != nil do
"Someone is logged in"
else
"No one is logged in"
end conn
|> put_flash(:info, message)
|> render("index.html", changeset: changeset, action: page_path(conn, :login), maybe_user: maybe_user)
end def login(conn, %{"user" => %{"username" => username, "password" => password}}) do
Auth.authenticate_user(username, password)
|> login_reply(conn)
end defp login_reply({:error, error}, conn) do
conn
|> put_flash(:error, error)
|> redirect(to: "/")
end defp login_reply({:ok, user}, conn) do
conn
|> put_flash(:success, "Welcome back!")
|> Guardian.Plug.sign_in(user)
|> redirect(to: "/")
end def logout(conn, _) do
conn
|> Guardian.Plug.sign_out()
|> redirect(to: page_path(conn, :login))
end def secret(conn, _params) do
render(conn, "secret.html")
endend
We will also need to update the index.html.eex
to provide a place to log in an logout. This page will be visible regardless of if someone is logged in or not, but it will use the maybe_user
value from our controller to present different things for each case:
## lib/auth_ex_web/templates/page/index.html.eex<h2>Login Page</h2><%= if @maybe_user == nil do %>
<%= form_for @changeset, @action, fn f -> %>
<div class="form-group">
<%= label f, :username, class: "control-label" %>
<%= text_input f, :username, class: "form-control" %>
<%= error_tag f, :username %>
</div> <div class="form-group">
<%= label f, :password, class: "control-label" %>
<%= password_input f, :password, class: "form-control" %>
<%= error_tag f, :password %>
</div> <div class="form-group">
<%= submit "Submit", class: "btn btn-primary" %>
</div> <% end %>
<% else %>
<h1>Hello <%= @maybe_user.username%>!</h1>
<span><%= link "Logout", to: page_path(@conn, :logout), method: :post%></span>
<% end %>
And define the secret.html.eex
file, which will only be visible to users that are logged in:
## lib/auth_ex_web/templates/page/secret.html.eex<h2>Secret Page</h2>
<p>You can only see this page if you are logged in</p>
8) Router
Now that everything is ready we can tie it all together by adding this in the router.ex
file:
## lib/auth_ex_web/router.expipeline :auth do
plug AuthEx.Auth.Pipeline
endpipeline :ensure_auth do
plug Guardian.Plug.EnsureAuthenticated
end# Maybe logged in scope
scope "/", AuthExWeb do
pipe_through [:browser, :auth] get "/", PageController, :index
post "/", PageController, :login
post "/logout", PageController, :logout
end# Definitely logged in scope
scope "/", AuthExWeb do
pipe_through [:browser, :auth, :ensure_auth] get "/secret", PageController, :secret
end
A couple things are happening here. We are defining two new pipes, :auth
and :ensure_auth
. The first one just calls the pipeline we created in step 6, and the second one is just composed of a single Guardian Plug that makes sure that somone is logged in.
Using the pipelines works as you would expect, just note that for the definitely-logged-in scope we construct the pipe_through
using :auth
and then :ensure_auth
together (along with the default browser pipeline).
Testing
Since I didn’t create any of the forms for my user model I will just create one my opening up an iex
session and creating a user via Repo
. Enter into an iex
session:
## On the command lineiex -S mix
And then create a user:
## On the command line (in a iex session)AuthEx.Auth.create_user(%{username: "tyler", password: "elixir"})
Now we are ready to test. As usual startup your phoenix app by running:
## On the command linemix phx.server
And open up localhost:4000
in your browser, where you will be able to login and logout of our application. Also test localhost:4000/secret
in each state to observe that it is protected by the :ensure_auth
pipeline from our router.
Github Repo
A completed working example of this code can be found here in a Github repository.