This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.
Enter Guardian
Now we’re getting to the meat of our authentication system. We have our
User
model set up, but we need to associate users with active
sessions.
This is where Guardian comes in. Guardian is an authentication framework that leverages JSON Web Tokens (JWT) and plays nicely with Phoenix Channels.
To use Guardian, we’ll first add it as a depenency to our application:
{:guardian, "~> 0.12.0"}
Next, we need to do some configuring:
config :guardian, Guardian,
allowed_algos: ["HS512"], # optional
verify_module: Guardian.JWT, # optional
issuer: "PhoenixTodos",
ttl: { 30, :days },
verify_issuer: true, # optional
secret_key: %{"kty" => "oct", "k" => System.get_env("GUARDIAN_SECRET_KEY")},
serializer: PhoenixTodos.GuardianSerializer
You’ll notice that I’m pulling my secret_key
from my system’s
environment variables. It’s a bad idea to keep secrets in version control.
I also specified a serializer
module. This is Guardian’s bridge into
your system. It acts as a translation layer between Guardian’s JWT and
your User
model.
Because it’s unique to our system, we’ll need to build the
PhoenixTodos.GuardianSerializer
ourselves.
Our serializer will need two fuctions. The first, for_token
translates a
User
model into a token string. An invalid User
should return an
:error
:
test "generates token for valid user", %{user: user} do
assert {:ok, _} = GuardianSerializer.for_token(user)
end
test "generates error for invalid user", %{} do
assert {:error, "Invalid user"} = GuardianSerializer.for_token(%{})
end
Thanks to Elixir’s pattern matching, for_token
is a very simple
function:
def for_token(%User{id: id}), do: {:ok, "User:#{id}"}
def for_token(_), do: {:error, "Invalid user"}
Similarly, we need to define a from_token
function, which takes a
token string and returns the corresponding User
model:
test "finds user from valid token", %{user: user} do
{:ok, token} = GuardianSerializer.for_token(user)
assert {:ok, _} = GuardianSerializer.from_token(token)
end
test "doesn't find user from invalid token", %{} do
assert {:error, "Invalid user"} = GuardianSerializer.from_token("bad")
end
To implement this, we’ll pull the User
id out of the token string, and
look it up in the database:
def from_token("User:" <> id), do: {:ok, Repo.get(User, String.to_integer(id))}
def from_token(_), do: {:error, "Invalid user"}
Now that we’ve finished our serializer, we’re in a position to wire up the rest of our authentication system!
config/config.exs
... binary_id: false + +config :guardian, Guardian, + allowed_algos: ["HS512"], # optional + verify_module: Guardian.JWT, # optional + issuer: "PhoenixTodos", + ttl: { 30, :days }, + verify_issuer: true, # optional + secret_key: %{"kty" => "oct", "k" => System.get_env("GUARDIAN_SECRET_KEY")}, + serializer: PhoenixTodos.GuardianSerializer
lib/phoenix_todos/guardian_serializer.ex
+defmodule PhoenixTodos.GuardianSerializer do + @behavior Guardian.Serializer + + alias PhoenixTodos.{User, Repo} + + def for_token(%User{id: id}), do: {:ok, "User:#{id}"} + def for_token(_), do: {:error, "Invalid user"} + + def from_token("User:" <> id), do: {:ok, Repo.get(User, String.to_integer(id))} + def from_token(_), do: {:error, "Invalid user"} +end
mix.exs
... {:mix_test_watch, "~> 0.2", only: :dev}, - {:comeonin, "~> 2.0"}] + {:comeonin, "~> 2.0"}, + {:guardian, "~> 0.12.0"}] end
mix.lock
-%{"comeonin": {:hex, :comeonin, "2.5.2"}, +%{"base64url": {:hex, :base64url, "0.0.1"}, + "comeonin": {:hex, :comeonin, "2.5.2"}, "connection": {:hex, :connection, "1.0.4"}, "gettext": {:hex, :gettext, "0.11.0"}, + "guardian": {:hex, :guardian, "0.12.0"}, + "jose": {:hex, :jose, "1.8.0"}, "mime": {:hex, :mime, "1.0.1"}, "postgrex": {:hex, :postgrex, "0.11.2"}, - "ranch": {:hex, :ranch, "1.2.1"}} + "ranch": {:hex, :ranch, "1.2.1"}, + "uuid": {:hex, :uuid, "1.1.4"}}
test/lib/guardian_serializer_test.exs
+defmodule PhoenixTodos.GuardianSerializerTest do + use ExUnit.Case, async: true + + alias PhoenixTodos.{User, Repo, GuardianSerializer} + + setup_all do + user = User.changeset(%User{}, %{ + email: "email@example.com", + password: "password" + }) + |> Repo.insert! + + {:ok, user: user} + end + + test "generates token for valid user", %{user: user} do + assert {:ok, _} = GuardianSerializer.for_token(user) + end + + test "generates error for invalid user", %{} do + assert {:error, "Invalid user"} = GuardianSerializer.for_token(%{}) + end + + test "finds user from valid token", %{user: user} do + {:ok, token} = GuardianSerializer.for_token(user) + assert {:ok, _} = GuardianSerializer.from_token(token) + end + + test "doesn't find user from invalid token", %{} do + assert {:error, "Invalid user"} = GuardianSerializer.from_token("bad") + end +end
Sign-Up Route and Controller
The first step to implementing authentication in our application is creating a back-end sign-up route that creates a new user in our system.
To do this, we’ll create an "/api/users"
route that sends POST
requests to the UserController.create
function:
post "/users", UserController, :create
We expect the user’s email
and password
to be sent as parameters to
this endpoint. UserController.create
takes those params
, passes them
into our User.changeset
, and then attempts to insert
the resulting
User
into the database:
User.changeset(%User{}, params)
|> Repo.insert
If the insert
fails, we return the changeset
errors to the client:
conn
|> put_status(:unprocessable_entity)
|> render(PhoenixTodos.ApiView, "error.json", error: changeset)
Otherwise, we’ll use Guardian to sign the new user’s JWT and return the
jwt
and user
objects down to the client:
{:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :token)
conn
|> put_status(:created)
|> render(PhoenixTodos.ApiView, "data.json", data: %{jwt: jwt, user: user})
Now all a user needs to do to sign up with our Todos application is send a
POST
request to /api/users
with their email and password. In turn,
they’ll receive their JWT which they can send along with any subsequent
requests to verify their identity.
test/controllers/user_controller_test.exs
+defmodule PhoenixTodos.UserControllerTest do + use PhoenixTodos.ConnCase + + test "creates a user", %{conn: conn} do + conn = post conn, "/api/users", user: %{ + email: "email@example.com", + password: "password" + } + %{ + "jwt" => _, + "user" => %{ + "id" => _, + "email" => "email@example.com" + } + } = json_response(conn, 201) + end + + test "fails user validation", %{conn: conn} do + conn = post conn, "/api/users", user: %{ + email: "email@example.com", + password: "pass" + } + %{ + "errors" => [ + %{ + "password" => "should be at least 5 character(s)" + } + ] + } = json_response(conn, 422) + end +end
web/controllers/user_controller.ex
+defmodule PhoenixTodos.UserController do + use PhoenixTodos.Web, :controller + + alias PhoenixTodos.{User, Repo} + + def create(conn, %{"user" => params}) do + User.changeset(%User{}, params) + |> Repo.insert + |> handle_insert(conn) + end + + defp handle_insert({:ok, user}, conn) do + {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :token) + conn + |> put_status(:created) + |> render(PhoenixTodos.ApiView, "data.json", data: %{jwt: jwt, user: user}) + end + defp handle_insert({:error, changeset}, conn) do + conn + |> put_status(:unprocessable_entity) + |> render(PhoenixTodos.ApiView, "error.json", error: changeset) + end +end
web/models/user.ex
... use PhoenixTodos.Web, :model + @derive {Poison.Encoder, only: [:id, :email]}
web/router.ex
... + scope "/api", PhoenixTodos do + pipe_through :api + + post "/users", UserController, :create + end + scope "/", PhoenixTodos do ... - # Other scopes may use custom stacks. - # scope "/api", PhoenixTodos do - # pipe_through :api - # end end
web/views/api_view.ex
+defmodule PhoenixTodos.ApiView do + use PhoenixTodos.Web, :view + + def render("data.json", %{data: data}) do + data + end + + def render("error.json", %{error: changeset = %Ecto.Changeset{}}) do + errors = Enum.map(changeset.errors, fn {field, detail} -> + %{} |> Map.put(field, render_detail(detail)) + end) + + %{ errors: errors } + end + + def render("error.json", %{error: error}), do: %{error: error} + + def render("error.json", %{}), do: %{} + + defp render_detail({message, values}) do + Enum.reduce(values, message, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end) + end + + defp render_detail(message) do + message + end + +end
Sign-In Route and Controller
Now that users have the ability to join our application, how will they sign into their accounts?
We’ll start implementing sign-in functionality by adding a new route to our Phoenix application:
post "/sessions", SessionController, :create
When a user sends a POST
request to /sessions
, we’ll route them to
the create
function in our SessionController
module. This function
will attempt to sign the user in with the credentials they provide.
At a high level, the create
function will be fairly straight-forward.
We want to look up the user based on the email
they gave, check if the
password
they supplied matches what we have on file:
def create(conn, %{"email" => email, "password" => password}) do
user = get_user(email)
user
|> check_password(password)
|> handle_check_password(conn, user)
end
If get_user
returns nil
, we couldn’t find the user based on the
email address they provided. In that case, we’ll return false
from
check_password
:
defp check_password(nil, _password), do: false
Otherwise, we’ll use Comeonin
to compare the hashed password we have
saved in encrypted_password
with the hash of the password the user
provided:
defp check_password(user, password) do
Comeonin.Bcrypt.checkpw(password, user.encrypted_password)
end
If all goes well, we’ll return a jwt
and the user
object for the
now-authenticated user:
render(PhoenixTodos.ApiView, "data.json", data: %{jwt: jwt, user: user})
We can test this sign-in route/controller combination just like we’ve tested our sign-up functionality.
test/controllers/session_controller_test.exs
+defmodule PhoenixTodos.SessionControllerTest do + use PhoenixTodos.ConnCase + + alias PhoenixTodos.{User, Repo} + + test "creates a session", %{conn: conn} do + %User{} + |> User.changeset(%{ + email: "email@example.com", + password: "password" + }) + |> Repo.insert! + + conn = post conn, "/api/sessions", email: "email@example.com", password: "password" + %{ + "jwt" => _jwt, + "user" => %{ + "id" => _id, + "email" => "email@example.com" + } + } = json_response(conn, 201) + end + + test "fails authorization", %{conn: conn} do + conn = post conn, "/api/sessions", email: "email@example.com", password: "wrong" + %{ + "error" => "Unable to authenticate" + } = json_response(conn, 422) + end +end
web/controllers/session_controller.ex
+defmodule PhoenixTodos.SessionController do + use PhoenixTodos.Web, :controller + + alias PhoenixTodos.{User, Repo} + + def create(conn, %{"email" => email, "password" => password}) do + user = get_user(email) + user + |> check_password(password) + |> handle_check_password(conn, user) + end + + defp get_user(email) do + Repo.get_by(User, email: String.downcase(email)) + end + + defp check_password(nil, _password), do: false + defp check_password(user, password) do + Comeonin.Bcrypt.checkpw(password, user.encrypted_password) + end + + defp handle_check_password(true, conn, user) do + {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :token) + conn + |> put_status(:created) + |> render(PhoenixTodos.ApiView, "data.json", data: %{jwt: jwt, user: user}) + end + defp handle_check_password(false, conn, _user) do + conn + |> put_status(:unprocessable_entity) + |> render(PhoenixTodos.ApiView, "error.json", error: "Unable to authenticate") + end + +end
web/router.ex
... plug :accepts, ["json"] + plug Guardian.Plug.VerifyHeader + plug Guardian.Plug.LoadResource end ... post "/users", UserController, :create + + post "/sessions", SessionController, :create end
Sign-Out Route and Controller
The final piece of our authorization trifecta is the ability for users to sign out once they’ve successfully joined or signed into the application.
To implement sign-out functionality, we’ll want to create a route that destroys a user’s session when its called by an authenticated user:
delete "/sessions", SessionController, :delete
This new route points to SessionController.delete
. This function
doesn’t exist yet, so let’s create it:
def delete(conn, _) do
conn
|> revoke_claims
|> render(PhoenixTodos.ApiView, "data.json", data: %{})
end
revoke_claims
will be a private function that simply looks up the
current user’s token and claims,
and then revokes them:
{:ok, claims} = Guardian.Plug.claims(conn)
Guardian.Plug.current_token(conn)
|> Guardian.revoke!(claims)
In implementing this feature, we cleaned up our SessionControllerTest
module a bit. We added a create_user
function, which creates a user
with a given email address and password, and a create_session
function
that logs that user in.
Using those functions we can create a user’s session, and then construct
a DELETE
request with the user’s JWT (session_response["jwt"]
) in
the "authorization"
header. If this request is successful, we’ve
successfully deleted the user’s session.
test/controllers/session_controller_test.exs
... - test "creates a session", %{conn: conn} do + defp create_user(email, password) do %User{} |> User.changeset(%{ - email: "email@example.com", - password: "password" - }) + email: email, + password: password + }) |> Repo.insert! + end - conn = post conn, "/api/sessions", email: "email@example.com", password: "password" - %{ - "jwt" => _jwt, - "user" => %{ - "id" => _id, - "email" => "email@example.com" - } - } = json_response(conn, 201) + defp create_session(conn, email, password) do + post(conn, "/api/sessions", email: email, password: password) + |> json_response(201) + end + + test "creates a session", %{conn: conn} do + create_user("email@example.com", "password") + + response = create_session(conn, "email@example.com", "password") + + assert response["jwt"] + assert response["user"]["id"] + assert response["user"]["email"] end ... end + + test "deletes a session", %{conn: conn} do + create_user("email@example.com", "password") + session_response = create_session(conn, "email@example.com", "password") + + conn + |> put_req_header("authorization", session_response["jwt"]) + |> delete("/api/sessions") + |> json_response(200) + end + end
web/controllers/session_controller.ex
... + def delete(conn, _) do + conn + |> revoke_claims + |> render(PhoenixTodos.ApiView, "data.json", data: %{}) + end + + defp revoke_claims(conn) do + {:ok, claims} = Guardian.Plug.claims(conn) + Guardian.Plug.current_token(conn) + |> Guardian.revoke!(claims) + conn + end + def create(conn, %{"email" => email, "password" => password}) do
web/router.ex
... post "/sessions", SessionController, :create + delete "/sessions", SessionController, :delete end
Final Thoughts
As a Meteor developer, it seems like we’re spending an huge amount of time implementing authorization in our Phoenix Todos application. This functionality comes out of the box with Meteor!
The truth is that authentication is a massive, nuanced problem. Meteor’s Accounts system is a shining example of what Meteor does right. It abstracts away an incredibly tedious, but extremely important aspect of building web applications into an easy to use package.
On the other hand, Phoenix’s approach of forcing us to implement our own authentication system has its own set of benefits. By implementing authentication ourselves, we always know exactly what’s going on in every step of the process. There is no magic here. Complete control can be liberating.
Check back next week when we turn our attention back to the front-end, and wire up our sign-up and sign-in React templates!