Refactoring Faster Than You Can Spell Phoenix

Development

Reading Time: 7 minutes

Plug is a fantastic tool, and Phoenix is built on top of it! In my last blog post, we added a way to create sessions and tokens for authentication. However, we didn’t actually authenticate anything in our API. This time, we’re going to build a Plug that checks for an API token and inserts the current user into our application.

Creating a Plug

The anatomy of a plug is simple. If you come from the Ruby world, you can think of it like Rack. And luckily, we have already seen plugs used in our router.

plug :accepts, ["json"]

This is an example of a function Plug. A function plug takes two arguments, a Plug.Conn struct and options. In order for the plug chain to continue, the function must also return a Plug.Conn. You can find the definition of accepts/2 here.

The other form a Plug can take is a module. In order to use the module form, you must define two functions: init/1 and call/2. The init/1 is used to provide the options (the second argument) to the call/2 function.

Once again, the first argument to call/2 is the Plug.Conn, and the function must return a Plug.Conn for chaining. For more information about defining plugs, check out the README.

Creating Our Plug

Now that we know how to create a plug, let’s build one for our authentication layer. The system we built so far allows us to create a session in the database and returns the client a token. According to the spec for HTTP token access, we need a header that looks like this: Authorization: Token token="yourtokenhere". So let’s build a plug that checks for that token.

As always, we will start with a test. We have three scenarios:

  1. A token was not provided.
  2. An invalid token was provided.
  3. A valid token was provided.

In the first two scenarios, we simply need to respond with a 401 status code. In the last example, we want to add the current user to the conn, so that later down the plug chain (in our controller), we can use it. As always, let’s start with some tests.

defmodule TodoApi.AuthenticationTest do
  use TodoApi.ConnCase

  alias TodoApi.{Authentication, Repo, User, Session}

  @opts Authentication.init([])

  def put_auth_token_in_header(conn, token) do
    conn
    |> put_req_header("authorization", "Token token=\"#{token}\"")
  end

  test "finds the user by token", %{conn: conn} do
    user = Repo.insert!(%User{})
    session = Repo.insert!(%Session{token: "123", user_id: user.id})

    conn = conn
    |> put_auth_token_in_header(session.token)
    |> Authentication.call(@opts)

    assert conn.assigns.current_user
  end

  test "invalid token", %{conn: conn} do
    conn = conn
    |> put_auth_token_in_header("foo")
    |> Authentication.call(@opts)

    assert conn.status == 401
    assert conn.halted
  end

  test "no token", %{conn: conn} do
    conn = Authentication.call(conn, @opts)
    assert conn.status == 401
    assert conn.halted
  end
end

As you can see, all three of our scenarios are outlined and tested. There are two things to note particularly. First, we must ensure that the conn is halted; otherwise it will keep chaining. Second, we cached @opts from the init/1 function, in case we ever want to do something with them in the future.

Now for the fun part:

defmodule TodoApi.Authentication do
  import Plug.Conn
  alias TodoApi.{Repo, User, Session}
  import Ecto.Query, only: [from: 2]

  def init(options), do: options

  def call(conn, _opts) do
    case find_user(conn) do
      {:ok, user} -> assign(conn, :current_user, user)
      _otherwise  -> auth_error!(conn)
    end
  end

  defp find_user(conn) do
    with auth_header = get_req_header(conn, "authorization"),
         {:ok, token}   <- parse_token(auth_header),
         {:ok, session} <- find_session_by_token(token),
    do:  find_user_by_session(session)
  end

  defp parse_token(["Token token=" <> token]) do
    {:ok, String.replace(token, "\"", "")}
  end
  defp parse_token(_non_token_header), do: :error

  defp find_session_by_token(token) do
    case Repo.one(from s in Session, where: s.token == ^token) do
      nil     -> :error
      session -> {:ok, session}
    end
  end

  defp find_user_by_session(session) do
    case Repo.get(User, session.user_id) do
      nil  -> :error
      user -> {:ok, user}
    end
  end

  defp auth_error!(conn) do
    conn |> put_status(:unauthorized) |> halt()
  end
end

Our call/2 function has a case statement. If find_user/1 returns {:ok, user}, then we assign the current user to the conn. Any other return value will put the 401 status and halt the Plug chain.

Notice we also used a rather new feature: with. This is brand new to Elixir (1.2.4). It’s a lot like a pipeline except anytime the thing on the right does not match the thing on the left, the pipeline is stopped, and the thing on the right is returned. This sounds confusing, so let’s look at an easier example.

def bar, do: :error
def baz, do: IO.puts("will never get executed")
with {:ok, foo} <- bar do
  baz
end

In this example, the bar/0 function returns :error, so the pipeline stops and :error is returned; baz/0 is never called. If the bar/0 function had returned {:ok, :whatever}, the pipeline would have continued, and baz/0 would have been called. AWESOME!

Sign up for a free Codeship Account

Refactor Our Controller

Now that we have a plug to do authentication, let’s modify the todo controller to use it. In the following example, we are only going to modify the index and create actions. I’ll leave the rest to you.

Change the todo controller tests:

defmodule TodoApi.TodoControllerTest do
  use TodoApi.ConnCase

  alias TodoApi.Todo
  alias TodoApi.User
  alias TodoApi.Session

  @valid_attrs %{complete: true, description: "some content"}
  @invalid_attrs %{}

  setup %{conn: conn} do
    user = create_user(%{name: "jane"})
    session = create_session(user)

    conn = conn
    |> put_req_header("accept", "application/json")
    |> put_req_header("authorization", "Token token=\"#{session.token}\"")
    {:ok, conn: conn, current_user: user }
  end

  def create_user(%{name: name}) do
    User.changeset(%User{}, %{email: "#{name}@example.com"}) |> Repo.insert!
  end

  def create_session(user) do
    # in the last blog post I had a copy-paste error
    # so you may need to use Session.registration_changeset
    Session.create_changeset(%Session{user_id: user.id}, %{}) |> Repo.insert!
  end

  def create_todo(%{description: _description, owner_id: _owner_id} = options) do
    Todo.changeset(%Todo{}, options) |> Repo.insert!
  end

  test "lists all entries on index", %{conn: conn, current_user: current_user} do
    create_todo(%{description: "our first todo", owner_id: current_user.id})

    another_user = create_user(%{name: "johndoe"})
    create_todo(%{description: "thier first todo", owner_id: another_user.id})

    conn = get conn, todo_path(conn, :index)

    assert Enum.count(json_response(conn, 200)["data"]) == 1
    assert %{"description" => "our first todo"} = hd(json_response(conn, 200)["data"])
  end

  test "creates and renders resource when data is valid", %{conn: conn, current_user: current_user} do
    conn = post conn, todo_path(conn, :create), todo: @valid_attrs
    assert json_response(conn, 201)["data"]["id"]
    todo = Repo.get_by(Todo, @valid_attrs)
    assert todo
    assert todo.owner_id == current_user.id
  end

end

The main changes are:

  1. We created some helper functions for sessions, users, and todos.
  2. We modified our setup function to pass in the current user.
  3. We modified our tests to pattern match the current user so we can assert against it.
  4. We asserted that we only see our todos.
  5. We asserted that new todos belong to the current user.

Now let’s get the tests passing. First create a migration:

$ mix ecto.gen.migration add_owner_id_to_todos
defmodule TodoApi.Repo.Migrations.AddOwnerIdToTodos do
  use Ecto.Migration

  def change do
    alter table(:todos) do
      add :owner_id, references(:users)
    end
  end
end

Add owner_id to the todo required fields:

# web/models/todo.ex
@required_fields ~w(description complete owner_id)

Notice at this point our todo model tests are failing. WOOT! That means they are working. Now add an integer to represent the owner_id in our todo model tests:

@valid_attrs %{complete: true, description: "some content", owner_id: 1}

Finally, let’s modify the controller:

# web/models/todo_controller.ex
defmodule TodoApi.TodoController do
  use TodoApi.Web, :controller

  alias TodoApi.Todo

  plug :scrub_params, "todo" when action in [:create, :update]

  plug TodoApi.Authentication

  def index(conn, _params) do
    user_id = conn.assigns.current_user.id
    query = from t in Todo, where: t.owner_id == ^user_id
    todos = Repo.all(query)
    render(conn, "index.json", todos: todos)
  end

  def create(conn, %{"todo" => todo_params}) do
    changeset = Todo.changeset(
      %Todo{owner_id: conn.assigns.current_user.id}, todo_params
    )

    case Repo.insert(changeset) do
      {:ok, todo} ->
        conn
        |> put_status(:created)
        |> put_resp_header("location", todo_path(conn, :show, todo))
        |> render("show.json", todo: todo)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(TodoApi.ChangesetView, "error.json", changeset: changeset)
    end
  end

end

Woohoo, the tests pass! In this example, we added plug TodoApi.Authentication directly to our controller, but we could have created a new plug pipeline and added it there. If we had more controllers that needed authentication, this may be a better solution.

We also changed the index and create actions to use the current user which is now assigned in our plug. Another strategy could be to override the action/2 function in our controller and pass the current user as the third argument to our actions. I will leave that as an exercise for you. Reading this will help you get started.

Conclusion

Plug is awesome — it allowed us to create a middleware that we essentially used as a before filter. When a request was made, we filtered our params and either returned a 401 status or set the user. Then we refactored our controller to use the plug.

And we did all of this in very little code. Code that is reusable. Code that is easy to understand. Small, understandable, and reusable code translates to blazing fast productivity.

Subscribe via Email

Over 60,000 people from companies like Netflix, Apple, Spotify and O'Reilly are reading our articles.
Subscribe to receive a weekly newsletter with articles around Continuous Integration, Docker, and software development best practices.



We promise that we won't spam you. You can unsubscribe any time.

Join the Discussion

Leave us some comments on what you think about this topic or if you like to add something.

  • Cai Linfeng

    Great! Thank you!
    I think in AuthenticationTest, line 14 should be user = Repo.insert!(%User{email: “some email address”}), for that the we have set the email should not be null.
    and after we have done the add_owner_id_to_todos migration, we should add `field :owner_id, :string` to todo model

    • I think even better to add association with User instead of field :owner_id:


      # web/models/todo.ex
      defmodule TodoApi.Todo do
      use TodoApi.Web, :model

      schema "todos" do
      field :description, :string
      field :complete, :boolean
      belongs_to :user, TodoApi.User, foreign_key: :owner_id

      timestamps
      end

      @doc """
      Builds a changeset based on the `struct` and `params`.
      """
      def changeset(struct, params \ %{}) do
      struct
      |> cast(params, [:description, :complete])
      |> validate_required([:description])
      end
      end

      Also add Boolean attribute complete to table todos if it isn’t added yet:


      defmodule TodoApi.Repo.Migrations.AddCompleteToTodos do
      use Ecto.Migration

      def change do
      alter table(:todos) do
      add :complete, :boolean, default: false, null: false
      end
      end
      end

      Thanks

  • Maybe this can help.
    at router.ex:
    pipeline :api do
    plug :accepts, [“json”]
    plug TodoeApi.Auth
    end

  • I think this test for TodoController is also important to prevent malicious User from creating Todo with owner_id that refers to other User:


    test "ignores parameter owner_id and always assigns current_user as entry's owner", %{conn: conn, current_user: current_user} do
    other_user = create_user(%{name: "dougal"})
    malicious_attrs = Map.merge(@valid_attrs, %{owner_id: other_user.id})
    conn = post conn, todo_path(conn, :create), todo: malicious_attrs
    assert json_response(conn, 201)["data"]["id"]
    todo = Repo.get_by(Todo, @valid_attrs)
    assert todo
    assert todo.owner_id == current_user.id
    end

    Thanks

  • morgzzz

    I needed to modify the auth error Plug to make sure the conn sent a response back to the client.

    def auth_error!(conn) do

    conn

    #|> put_status(:unauthorized)

    |> send_resp(:unauthorized,"")

    |> halt()

    end

  • morgzzz

    Thanks for the tutorials. It’s been a really satisfying experience and also shatters the illusion that you need a devise like gem to do auth.