Authentication
Authenticate a user
The API spec for authentication is as follows:
| HTTP verb | URL | Required fields |
|---|---|---|
| POST | /api/users/login | email, password |
Example request body:
{
"user":{
"email": "jake@jake.jake",
"password": "jakejake"
}
}
Example response body:
{
"user": {
"email": "jake@jake.jake",
"token": "jwt.token.here",
"username": "jake",
"bio": null,
"image": null
}
}
Example failure response body:
{
"errors": {
"email or password": [
"is invalid"
]
}
}
The successful login response includes a JSON Web Token (JWT). This token is included in the HTTP headers on subsequent requests to authorize the user’s actions. We’ll use Guardian to authenticate users and take advantage of its support for JWT tokens.
An authentication framework for use with Elixir applications.
– Guardian
Guardian provides a number of Plug modules to include within the Phoenix request handling pipeline. We’ll make use of the following three plugs for the Conduit API:
| Plug | Usage |
|---|---|
Guardian.Plug.VerifyHeader |
Looks for a token in the Authorization header. |
| Useful for APIs. | |
| If one is not found, this does nothing. | |
Guardian.Plug.EnsureAuthenticated |
Looks for a previously verified token. |
| If one is found, continues. | |
Otherwise it will call the :unauthenticated function of your handler. |
|
Guardian.Plug.LoadResource |
Looks in the sub field of the token, |
| fetches the resource from the configured serializer | |
and makes it available via Guardian.Plug.current_resource(conn). |
In mix.exs, add the guardian package as a dependency:
defp deps do
[
# ...
{:guardian, "~> 0.14"},
]
end
Fetch and compile the mix dependencies:
$ mix do deps.get, deps.compile
Guardian requires a secret key to be generated for our application. We can use the “secret generator” mix task provided by Phoenix to do this:
$ mix phx.gen.secret
IOjbrty1eMEBzc5aczQn0FR4Gd8P9IF1cC7tqwB7ThV/uKjS5mrResG1Y0lCzTNJ
Configure Guardian in config/config.exs, including copying the key from above into secret_key:
config :guardian, Guardian,
allowed_algos: ["HS512"],
verify_module: Guardian.JWT,
issuer: "Conduit",
ttl: {30, :days},
allowed_drift: 2000,
verify_issuer: true,
secret_key: "IOjbrty1eMEBzc5aczQn0FR4Gd8P9IF1cC7tqwB7ThV/uKjS5mrResG1Y0lCzTNJ",
serializer: Conduit.Auth.GuardianSerializer
Guardian requires you to implement a serializer, as specified in the config above, to encode and decode your resources into and out of the JWT token. The only resource we’re interested in are users. We can encode the user’s UUID into the token, and later use it to fetch the user projection from the read model.
At this point we will move the existing Conduit.Auth module into its own context. This will allows us to keep authentication concerns, such as password hashing, separate from user accounts.
The Guardian serializer module is created at lib/conduit/auth/guardian_serializer.ex:
# lib/conduit/auth/guardian_serializer.ex
defmodule Conduit.Auth.GuardianSerializer do
@moduledoc """
Used by Guardian to serialize a JWT token
"""
@behaviour Guardian.Serializer
alias Conduit.Accounts
alias Conduit.Accounts.Projections.User
def for_token(%User{} = user), do: {:ok, "User:#{user.uuid}"}
def for_token(_), do: {:error, "Unknown resource type"}
def from_token("User:" <> uuid), do: {:ok, Accounts.user_by_uuid(uuid)}
def from_token(_), do: {:error, "Unknown resource type"}
end
We need to add the user_by_uuid/1 function to the accounts context:
defmodule Conduit.Accounts do
@doc """
Get a single user by their UUID
"""
def user_by_uuid(uuid) when is_binary(uuid) do
Repo.get(User, uuid)
end
end
The Conduit API specs show the authentication header is in the following format:
Authorization: Token jwt.token.here
So we need to prefix the JWT token with the word Token. To do this we configure the Phoenix web router, in lib/conduit_web/router.ex, and instruct Guardian to use Token as the realm:
defmodule ConduitWeb.Router do
use ConduitWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug Guardian.Plug.VerifyHeader, realm: "Token"
plug Guardian.Plug.LoadResource
end
# ... routes omitted
end
We will create a new session controller to support user login. It will authenticate the user from the provided email and password and return the user’s details as JSON:
# lib/conduit_web/controllers/session_controller.ex
defmodule ConduitWeb.SessionController do
use ConduitWeb, :controller
alias Conduit.Auth
alias Conduit.Accounts.Projections.User
action_fallback ConduitWeb.FallbackController
def create(conn, %{"user" => %{"email" => email, "password" => password}}) do
case Auth.authenticate(email, password) do
{:ok, %User{} = user} ->
conn
|> put_status(:created)
|> render(ConduitWeb.UserView, "show.json", user: user)
{:error, :unauthenticated} ->
conn
|> put_status(:unprocessable_entity)
|> render(ConduitWeb.ValidationView, "error.json", errors: %{"email or password" => ["is invalid"]\
})
end
end
end
An error is returned with a 422 HTTP status code and a generic “is invalid” error message for the email or password on login failure. The existing user and validation views are reused for rendering the response as JSON.
The session controller uses a new public function in the auth context: authenticate/2
This function will look for an existing user by their email address, and then compare their stored hashed password with the password provided hashed using the same bcrypt hash function. An {:error, :unauthenticated} tagged tuple is returned on failure:
# lib/conduit/auth/auth.ex
defmodule Conduit.Auth do
@moduledoc """
Boundary for authentication.
Uses the bcrypt password hashing function.
"""
alias Comeonin.Bcrypt
alias Conduit.Accounts
alias Conduit.Accounts.Projections.User
def authenticate(email, password) do
with {:ok, user} <- user_by_email(email) do
check_password(user, password)
else
reply -> reply
end
end
def hash_password(password), do: Bcrypt.hashpwsalt(password)
def validate_password(password, hash), do: Bcrypt.checkpw(password, hash)
defp user_by_email(email) do
case Accounts.user_by_email(email) do
nil -> {:error, :unauthenticated}
user -> {:ok, user}
end
end
defp check_password(%User{hashed_password: hashed_password} = user, password) do
case validate_password(password, hashed_password) do
true -> {:ok, user}
_ -> {:error, :unauthenticated}
end
end
end
The POST /api/users/login action, mapped to the new session controller, is added to the router:
defmodule ConduitWeb.Router do
use ConduitWeb, :router
# ... pipeline omitted
scope "/api", ConduitWeb do
pipe_through :api
post "/users/login", SessionController, :create
post "/users", UserController, :create
end
end
With the controller and routing configured we can write a web integration test to verify the functionality. In test/conduit_web/controllers/session_controller_test.exs we use the Phoenix connection test case to access helper functions for controllers.
There are three scenarios to test:
- Successfully authenticating an existing user and valid password.
- Failing to authenticate a known user when the password is incorrect.
- Failing to authenticate an unknown user.
# test/conduit/web/controllers/session_controller_test.exs
defmodule ConduitWeb.SessionControllerTest do
use ConduitWeb.ConnCase
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "authenticate user" do
@tag :web
test "creates session and renders session when data is valid", %{conn: conn} do
register_user()
conn = post conn, session_path(conn, :create), user: %{
email: "jake@jake.jake",
password: "jakejake"
}
assert json_response(conn, 201)["user"] == %{
"bio" => nil,
"email" =>
"jake@jake.jake",
"image" => nil,
"username" => "jake",
}
end
@tag :web
test "does not create session and renders errors when password does not match", %{conn: conn} do
register_user()
conn = post conn, session_path(conn, :create), user: %{
email: "jake@jake.jake",
password: "invalidpassword"
}
assert json_response(conn, 422)["errors"] == %{
"email or password" => [
"is invalid"
]
}
end
@tag :web
test "does not create session and renders errors when user not found", %{conn: conn} do
conn = post conn, session_path(conn, :create), user: %{
email: "doesnotexist",
password: "jakejake"
}
assert json_response(conn, 422)["errors"] == %{
"email or password" => [
"is invalid"
]
}
end
end
defp register_user, do: fixture(:user)
end
Run the new web tests, mix test --only web, to confirm that our changes are good.
Generating a JWT token
User authentication is now working, but we’ve omitted a necessary part of the user data returned as JSON from the login and register user actions. In both cases our response does not include the JWT token as shown in the example response:
{
"user": {
"email": "jake@jake.jake",
"token": "jwt.token.here",
"username": "jake"
}
}
We need to rectify that omission by including the token in the response. First, we’ll include the token property in the session controller test. We assert that it is not empty when successfully authenticating a user:
defmodule ConduitWeb.SessionControllerTest do
use ConduitWeb.ConnCase
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "authenticate user" do
@tag :web
test "creates session and renders session when data is valid", %{conn: conn} do
register_user()
conn = post conn, session_path(conn, :create), user: %{
email: "jake@jake.jake",
password: "jakejake"
}
json = json_response(conn, 201)["user"]
token = json["token"]
assert json == %{
"bio" => nil,
"email" => "jake@jake.jake",
"token" => token,
"image" => nil,
"username" => "jake",
}
refute token == ""
end
end
end
Let’s use Guardian to generate the token for us. It will use the Conduit.Auth.GuardianSerializer module we’ve already written and configured to serialize our user resource into a string for inclusion in the token.
To generate the JWT we use Guardian.encode_and_sign/2 by adding a Conduit.JWT module and wrapper function in lib/conduit_web/jwt.ex:
# lib/conduit_web/jwt.ex
defmodule ConduitWeb.JWT do
@moduledoc """
JSON Web Token helper functions, using Guardian
"""
def generate_jwt(resource, type \\ :token) do
case Guardian.encode_and_sign(resource, type) do
{:ok, jwt, _full_claims} -> {:ok, jwt}
end
end
end
Since the token generation will be used in both the session and user controllers we will import the ConduitWeb.JWT module in the Phoenix controller macro, in lib/conduit_web/web.ex. This makes the generate_jwt/2 function available to use in all of our web controllers.
defmodule ConduitWeb do
def controller do
quote do
use Phoenix.Controller, namespace: ConduitWeb
import Plug.Conn
import ConduitWeb.Router.Helpers
import ConduitWeb.Gettext
import ConduitWeb.JWT
end
end
# ... view, router, and channel definitions omitted
end
The session controller needs to be updated to generate the JWT after authenticating the user. The JWT token is passed to the render function to make it available to the view:
# lib/conduit_web/controllers/session_controller.ex
defmodule ConduitWeb.SessionController do
use ConduitWeb, :controller
alias Conduit.Auth
alias Conduit.Accounts.Projections.User
action_fallback ConduitWeb.FallbackController
def create(conn, %{"user" => %{"email" => email, "password" => password}}) do
with {:ok, %User{} = user} <- Auth.authenticate(email, password),
{:ok, jwt} <- generate_jwt(user) do
conn
|> put_status(:created)
|> render(ConduitWeb.UserView, "show.json", user: user, jwt: jwt)
else
{:error, :unauthenticated} ->
conn
|> put_status(:unprocessable_entity)
|> render(ConduitWeb.ValidationView, "error.json", errors: %{"email or password" => ["is invalid"]\
})
end
end
end
The render function in the user view for a single user merges the JWT token into the user data that is rendered as JSON:
# lib/conduit_web/views/user_view.ex
defmodule ConduitWeb.UserView do
use ConduitWeb, :view
alias ConduitWeb.UserView
def render("index.json", %{users: users}) do
%{users: render_many(users, UserView, "user.json")}
end
def render("show.json", %{user: user, jwt: jwt}) do
%{user: user |> render_one(UserView, "user.json") |> Map.merge(%{token: jwt})}
end
def render("user.json", %{user: user}) do
%{
username: user.username,
email: user.email,
bio: user.bio,
image: user.image,
}
end
end
Running the web tests again, mix test --only web, confirms that the token is successfully generated and included in the response.
Getting the current user
An authenticated HTTP GET request to /api/user should return a JSON representation of the current user. Authentication is determined by the presence of a valid HTTP request header containing the JWT token: Authorization: Token jwt.token.here.
We will start by adding two new tests to the user controller to verify the following scenarios:
- Successful authentication, with a valid JWT token, returns the current user as JSON data.
- An invalid request, missing a JWT token, returns a
401 Unauthorizedresponse.
To support a valid request we must register a user and generate a JWT token for them in the test setup. The token is included in the request headers of the test connection using the authenticated_conn/1 function:
defmodule ConduitWeb.UserControllerTest do
use ConduitWeb.ConnCase
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "get current user" do
@tag :web
test "should return user when authenticated", %{conn: conn} do
conn = get authenticated_conn(conn), user_path(conn, :current)
json = json_response(conn, 200)["user"]
token = json["token"]
assert json == %{
"bio" => nil,
"email" => "jake@jake.jake",
"token" => token,
"image" => nil,
"username" => "jake",
}
refute token == ""
end
@tag :web
test "should not return user when unauthenticated", %{conn: conn} do
conn = get conn, user_path(conn, :current)
assert response(conn, 401) == ""
end
end
def authenticated_conn(conn) do
with {:ok, user} <- fixture(:user),
{:ok, jwt} <- ConduitWeb.JWT.generate_jwt(user)
do
conn
|> put_req_header("authorization", "Token " <> jwt)
end
end
end
The failing tests guide us towards our next code change, we need to register the /api/user route in the router:
defmodule ConduitWeb.Router do
use ConduitWeb, :router
# ... pipeline omitted
scope "/api", ConduitWeb do
pipe_through :api
get "/user", UserController, :current
post "/users/login", SessionController, :create
post "/users", UserController, :create
end
end
Next we add a current function to the user controller module. Before doing so we’ll take advantage of Guardian’s built in support for Phoenix controllers. Using the Guardian.Phoenix.Controller module in our controller provides easier access to the current user and their claims. The public controller functions are extended to accept two additional parameters, user and claims, as shown below.
Before: def current(conn, params) do
After: def current(conn, params, user, claims) do
We will also use two plugs provided by Guardian:
-
Guardian.Plug.EnsureAuthenticatedensures a verified token exists. -
Guardian.Plug.EnsureResourceguards against a resource not found.
Both plugs require us to implement an error handler module that deals with failure cases. In lib/conduit_web/error_handler.ex we provide functions for the three main error cases. They each return an appropriate HTTP error status code and an empty response body:
# lib/conduit_web/error_handler.ex
defmodule ConduitWeb.ErrorHandler do
import Plug.Conn
@doc """
Return 401 for "Unauthorized" requests
A request requires authentication but it isn't provided
"""
def unauthenticated(conn, _params), do: respond_with(conn, :unauthorized)
@doc """
Return 403 for "Forbidden" requests
A request may be valid, but the user doesn't have permissions to perform the action
"""
def unauthorized(conn, _params), do: respond_with(conn, :forbidden)
@doc """
Return 401 for "Unauthorized" requests
A request requires authentication but the resource has not been found
"""
def no_resource(conn, _params), do: respond_with(conn, :unauthorized)
defp respond_with(conn, status) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, "")
end
end
The Guardian plugs are configured with our error handler module and to only apply to the current controller action. This action returns the authenticated current user, and their JWT token, as JSON data:
# lib/conduit_web/controllers/user_controller.ex
defmodule ConduitWeb.UserController do
use ConduitWeb, :controller
use Guardian.Phoenix.Controller
alias Conduit.Accounts
alias Conduit.Accounts.Projections.User
action_fallback ConduitWeb.FallbackController
plug Guardian.Plug.EnsureAuthenticated, %{handler: ConduitWeb.ErrorHandler} when action in [:current]
plug Guardian.Plug.EnsureResource, %{handler: ConduitWeb.ErrorHandler} when action in [:current]
def create(conn, %{"user" => user_params}, _user, _claims) do
with {:ok, %User{} = user} <- Accounts.register_user(user_params),
{:ok, jwt} = generate_jwt(user) do
conn
|> put_status(:created)
|> render("show.json", user: user, jwt: jwt)
end
end
def current(conn, _params, user, _claims) do
jwt = Guardian.Plug.current_token(conn)
conn
|> put_status(:ok)
|> render("show.json", user: user, jwt: jwt)
end
end
When an unauthenticated user requests /api/user the Guardian.Plug.EnsureAuthenticated plug will step in. It redirects the request to our error handler module, which responds with a 401 unauthorized status code.
Run the web tests, mix test --only web, to confirm the new route is working as per the API spec.
We’ve now built out the basic user registration and authentication features required for Conduit. Let’s move on to authoring articles in the next chapter.