Ridiculously Fast API Authentication with Phoenix

Written by: Micah Woods

With Phoenix, productivity is a first-class citizen. Last time, we started an API and looked at how Phoenix promises similar if not more productivity than Rails. We scaffolded out a resource and talked about key concepts.

Generators and scaffolds are a great way to see how things are done or to get an initial understanding. However, when building real-world applications, generators are rarely used outside of migrations. Today we'll look at how we can build on other people's work (dependency management), and then we'll create a simple token-based auth from scratch.

If you played with the API we generated last time, you'll notice that it doesn't work from a browser. This is because modern browsers look for a CORS access control to help prevent cross site scripting.

Much of Rails' popularity comes from its community. There are thousands of libraries for developers, and they're super simple to install thanks to RubyGems and Bundler. But there are a lot of concepts and gotchas. For one, gems are installed globally. Because of this, we need Gemsets and version managers to help.

Once again, Elixir has taken the hard-earned lessons from Ruby and made things a little better. Elixir uses Hex as a package manager. As with all things in Elixir, Hex uses Mix (just like Phoenix). You add dependencies to the mix.exs file. Then tell Mix to get the files.

Let's install CORSPlug to handle CORS for the API. First, add the dependency to the mix.exs file:

# mix.exs
def deps do
  # ...
  {:cors_plug, "~> 1.1"},
  #...
end

Then run the Mix command deps.get to install it:

$ mix deps.get

Notice that it was installed locally to the deps folder. Installing dependencies locally prevents problems associated with global dependencies. It wouldn't be hard to add CORS to Phoenix yourself, but CORSPlug is very easy to use.

Add the following to lib/todo_api/endpoint.ex before the router plug:

defmodule TodoApi.Endpoint do
  use Phoenix.Enpoint, otp_app: :your_app
  # ...
  plug CORSPlug
  # add before this line
  plug TodoApi.Router
end

That's it. By default, CORSPlug allows all requests. Restart your server (mix phoenix.server), and now everything's working with the browser.

Creating Users

Our simple token-based auth system is going to require a user to authenticate. Rails has a helper called has_secure_password that is added to a model. This gives the model industrial strength encryption via bcrypt.

Phoenix doesn't have a helper method like has_secure_password, but I think encryption is just as easy. We will use a Hex package named comeonin. This package uses bcrypt and will do the heavy lifting for us.

Add comeonin to the applications list and the dependencies:

# mix.exs
# ...
def application do
  [mod: {TodoApi, []},
   applications: [:phoenix, :cowboy, :logger, :gettext,
                  :phoenix_ecto, :postgrex, :comeonin]]
end
# ...
defp deps do
  [{:phoenix, "~> 1.1.2"},
   {:phoenix_ecto, "~> 2.0"},
   {:postgrex, ">= 0.0.0"},
   {:gettext, "~> 0.9"},
   {:comeonin, "~> 2.0"},
   {:cowboy, "~> 1.0"}]
end
# ...

Use Mix to download the dependencies:

$ mix deps.get

The algorithm for bcrypt is purposefully slow. This extra slow hashing helps to prevent brute force attacks. If each attempt at guessing a password takes a fraction of a second, then millions of guesses take an eternity. A fraction of a second is imperceivable to humans who make one attempt at a time to hash a password.

However, if that human is a developer and running a test suite, the tests can quickly take an eternity too. Luckily, comeonin allows us to speed up our tests by turning down the encryption. Add the following to the config/test.exs so the tests stay fast:

# config/test.exs
config :comeonin, :bcrypt_log_rounds, 4
config :comeonin, :pbkdf2_rounds, 1

Now the system needs a user. Since Phoenix is a mature framework, it gives us migrations, just like Rails. Migrations are a programatic way to make changes to the database in a way that is reversible.

Use mix to generate a migration:

mix ecto.gen.migration create_user

Email and password are all that's needed for this simple API authentication system. But we don't want to store the password in the database -- instead we want to store the hash that comeonin will generate for us. So add email and password_hash to the migration:

# priv/repo/migrations/20160120025135_create_user.exs
defmodule TodoApi.Repo.Migrations.CreateUser do
  use Ecto.Migration
  def change do
    create table(:users) do
      add :email, :string, null: false
      add :password_hash, :string
      timestamps
    end
    create unique_index(:users, [:email])
  end
end

Migrate the database to create the new table:

$ mix ecto.migrate

In the last blog post, we looked at tests and talked about changesets. Here we'll create two changesets. One is for updating a user, or when the password is NOT present. The other is for registering or creating a user; this is necessary because in this scenario we need a password.

We will start creating our functions by writing tests that exercise our current understanding of the system.

# test/models/user_test.exs
defmodule TodoApi.UserTest do
  use TodoApi.ModelCase
  alias TodoApi.User
  @valid_attrs %{email: "bar@baz.com", password: "s3cr3t"}
  test "changeset with valid attributes" do
    changeset = User.changeset(%User{}, @valid_attrs)
    assert changeset.valid?
  end
  test "changeset, email too short " do
    changeset = User.changeset(
      %User{}, Map.put(@valid_attrs, :email, "")
    )
    refute changeset.valid?
  end
  test "changeset, email invalid format" do
    changeset = User.changeset(
      %User{}, Map.put(@valid_attrs, :email, "foo.com")
    )
    refute changeset.valid?
  end
  test "registration_changeset, password too short" do
    changeset = User.registration_changeset(%User{}, @valid_attrs)
    assert changeset.changes.password_hash
    assert changeset.valid?
  end
  test "registration_changeset, password too short" do
    changeset = User.registration_changeset(
      %User{}, Map.put(@valid_attrs, :password, "12345")
    )
    refute changeset.valid?
  end
end

And now we create a model that meets our test assertions:

# web/models/user.ex
defmodule TodoApi.User do
  use TodoApi.Web, :model
  schema "users" do
    field :email, :string
    field :password_hash, :string
    field :password, :string, virtual: true
    timestamps
  end
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, ~w(email), [])
    |> validate_length(:email, min: 1, max: 255)
    |> validate_format(:email, ~r/@/)
  end
  def registration_changeset(model, params \\ :empty) do
    model
    |> changeset(params)
    |> cast(params, ~w(password), [])
    |> validate_length(:password, min: 6)
    |> put_password_hash
  end
  defp put_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
        put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(pass))
      _ ->
        changeset
    end
  end
end

The user model is straight forward but has three things to note:

  1. There is a virtual attribute password. This allows a password to be passed in, since we do not store the password in the database, only the encrypted hash.

  2. The registration_changeset function calls the other changeset function. This removes duplicated validations from the code. Since that function returns a changeset, you can just use it in your pipeline.

  3. The put_password_hash function pattern matches to see if the changeset is valid. If it is valid, it encrypts the password and adds it. In the case where the change is not valid, it doesn't calculate (or store) a password.

Now that we have a user model, we need an endpoint in the API to create a user. Once again, we start with a test:

# test/controllers/user_controller_test.exs
defmodule TodoApi.UserControllerTest do
  use TodoApi.ConnCase
  alias TodoApi.User
  @valid_attrs %{email: "foo@bar.com", password: "s3cr3t"}
  @invalid_attrs %{}
  setup %{conn: conn} do
    {:ok, conn: put_req_header(conn, "accept", "application/json")}
  end
  test "creates and renders resource when data is valid", %{conn: conn} do
    conn = post conn, user_path(conn, :create), user: @valid_attrs
    body = json_response(conn, 201)
    assert body["data"]["id"]
    assert body["data"]["email"]
    refute body["data"]["password"]
    assert Repo.get_by(User, email: "foo@bar.com")
  end
  test "does not create resource and renders errors when data is invalid", %{conn: conn} do
    conn = post conn, user_path(conn, :create), user: @invalid_attrs
    assert json_response(conn, 422)["errors"] != %{}
  end
end

Notice we didn't write a test for the route first. The helper function user_path/2 in the above test would have been generated by the router. So when the test suite runs, it is expected to be red; however the reason is because there user_path/2 is not defined. Time to add a route:

# web/router.ex
scope "/api", TodoApi do
  pipe_through :api
  resources "/todos", TodoController, except: [:new, :edit]
  resources "/users", UserController, only: [:create]
end

Now our test suite requires us to implement a controller to pass:

defmodule TodoApi.UserController do
  use TodoApi.Web, :controller
  alias TodoApi.User
  plug :scrub_params, "user" when action in [:create]
  def create(conn, %{"user" => user_params}) do
    changeset = User.registration_changeset(%User{}, user_params)
    case Repo.insert(changeset) do
      {:ok, user} ->
        conn
        |> put_status(:created)
        |> render("show.json", user: user)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(TodoApi.ChangesetView, "error.json", changeset: changeset)
    end
  end
end

This controller calls the registration_changset/2 function on the user model. The code attempts to insert the changeset in the database and follows the similar boiler plate logic we saw in TodoController.create/2.

Like we learned in the last blog post, controllers render views of the same name by default. There is no magic, so you could just as easily render another view or put view logic in your controller.

For now, let's stick with the established patterns and create the conventional UserView.

defmodule TodoApi.UserView do
  use TodoApi.Web, :view
  def render("show.json", %{user: user}) do
    %{data: render_one(user, TodoApi.UserView, "user.json")}
  end
  def render("user.json", %{user: user}) do
    %{id: user.id,
      email: user.email}
  end
end

And that's it. Our API has a way to create a User. The only thing that is important to note is that the password was not returned in the JSON response. There was also a test case that covered that: refute body["data"]["password"].

Creating Sessions

When it comes to creating a user session, it would be easy to add a token to the user model. I, however, don't like this approach. If a user logs in from two devices, say a tablet and desktop, the only way to log out is to reset the token. This means if a user logs out from the tablet, they are also logged out on the desktop.

Instead we will create a session table in the database. Each device will have its own token. If we need to log out of one device, we simply delete the session record in the database. There are other benefits as well. In the future, we could store information about sessions, like the type of device, or the ip of the request.

Generate a Session model so you get the migration, the basic scaffold for changesets, and tests.

$ mix phoenix.gen.model Session sessions user_id:references:users token
# priv/repo/migrations/20160120043602_create_session.exs
defmodule TodoApi.Repo.Migrations.CreateSession do
  use Ecto.Migration
  def change do
    create table(:sessions) do
      add :token, :string
      add :user_id, references(:users, on_delete: :nothing)
      timestamps
    end
    create index(:sessions, [:user_id])
    create index(:sessions, [:token])
  end
end

We now need to create a token for the session; let's add the SecureRandom before we write our test. SecureRandom is an almost direct port of Ruby's SecureRandom gem. I like it because it's easy.

def deps do
  # ...
  {:secure_random, "~> 0.2"},
  #...
end

Then run the Mix command to install it:

$ mix deps.get

Now in our SessionTest, add a test case that asserts the token is generated for session creation:

defmodule TodoApi.SessionTest do
  use TodoApi.ModelCase
  alias TodoApi.Session
  @valid_attrs %{user_id: "12345"}
  @invalid_attrs %{}
  test "changeset with valid attributes" do
    changeset = Session.changeset(%Session{}, @valid_attrs)
    assert changeset.valid?
  end
  test "changeset with invalid attributes" do
    changeset = Session.changeset(%Session{}, @invalid_attrs)
    refute changeset.valid?
  end
  test "create_changeset with valid attributes" do
    changeset = Session.create_changeset(%Session{}, @valid_attrs)
    assert changeset.changes.token
    assert changeset.valid?
  end
  test "create_changeset with invalid attributes" do
    changeset = Session.create_changeset(%Session{}, @invalid_attrs)
    refute changeset.valid?
  end
end

Now it's time to make the Session model:

defmodule TodoApi.Session do
  use TodoApi.Web, :model
  schema "sessions" do
    field :token, :string
    belongs_to :user, TodoApi.User
    timestamps
  end
  @required_fields ~w(user_id)
  @optional_fields ~w()
  @doc """
  Creates a changeset based on the `model` and `params`.
  If no params are provided, an invalid changeset is returned
  with no validation performed.
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
  end
  def registration_changeset(model, params \\ :empty) do
    model
    |> changeset(params)
    |> put_change(:token, SecureRandom.urlsafe_base64())
  end
end

We went ahead and made two changesets even though we don't currently have a way to update one. My personal opinion is that when you see a small pattern like this, it's an easy win to extract early.

Now write a test for the SessionController:

# test/controllers/session_controller_test.exs
defmodule TodoApi.SessionControllerTest do
  use TodoApi.ConnCase
  alias TodoApi.Session
  alias TodoApi.User
  @valid_attrs %{email: "foo@bar.com", password: "s3cr3t"}
  setup %{conn: conn} do
    changeset =  User.registration_changeset(%User{}, @valid_attrs)
    Repo.insert changeset
    {:ok, conn: put_req_header(conn, "accept", "application/json")}
  end
  test "creates and renders resource when data is valid", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: @valid_attrs
    assert token = json_response(conn, 201)["data"]["token"]
    assert Repo.get_by(Session, token: token)
  end
  test "does not create resource and renders errors when password is invalid", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: Map.put(@valid_attrs, :password, "notright")
    assert json_response(conn, 401)["errors"] != %{}
  end
  test "does not create resource and renders errors when email is invalid", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: Map.put(@valid_attrs, :email, "not@found.com")
    assert json_response(conn, 401)["errors"] != %{}
  end
end

Time to do the TDD dance. Add the route:

# web/router.ex
resources "/sessions", SessionController, only: [:create]

And the controller:

# web/controllers/session_controller.ex
defmodule TodoApi.SessionController do
  use TodoApi.Web, :controller
  import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
  alias TodoApi.User
  alias TodoApi.Session
  def create(conn, %{"user" => user_params}) do
    user = Repo.get_by(User, email: user_params["email"])
    cond do
      user && checkpw(user_params["password"], user.password_hash) ->
        session_changeset = Session.crate_changeset(%Session{}, %{user_id: user.id})
        {:ok, session} = Repo.insert(session_changeset)
        conn
        |> put_status(:created)
        |> render("show.json", session: session)
      user ->
        conn
        |> put_status(:unauthorized)
        |> render("error.json", user_params)
      true ->
        dummy_checkpw
        conn
        |> put_status(:unauthorized)
        |> render("error.json", user_params)
    end
  end
end

This is the trickiest controller so far. There are three possible outcomes:

  1. If the user is found and the password is correct, we insert a session into the database and return the token.

  2. If the user is found but the password is incorrect, an error is rendered.

  3. If the user is NOT found, then dummy_checkpw simulates a password check on a user as one was found, and returns an error. This is an important security method and strengthens the application's defense against timing attacks.

Finally create the view:

# web/views/session_view.ex
defmodule TodoApi.SessionView do
  use TodoApi.Web, :view
  def render("show.json", %{session: session}) do
    %{data: render_one(session, TodoApi.SessionView, "session.json")}
  end
  def render("session.json", %{session: session}) do
    %{token: session.token}
  end
  def render("error.json", _anything) do
    %{errors: "failed to authenticate"}
  end
end

So far, we have seen how productive we can be with Phoenix. In this blog post, we created authentication from scratch and even set up a token system that allows multiple sessions. We also test drove our API. To top that off, we did it in a very small amount of code.

In my next blog post, we'll refactor the TodosController to validate the session tokens.

Stay up-to-date with the latest insights

Sign up today for the CloudBees newsletter and get our latest and greatest how-to’s and developer insights, product updates and company news!