Ridiculously Fast API Authentication with Phoenix

Development

Reading Time: 11 minutes

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.

No CORS access control

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"].

Sign up for a free Codeship Account

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.

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.

  • Jan

    Thanks for your articles. I am definitely looking forward to seeing your next blog posts!
    1) I think you got a typo on SessionsController (you have Session.crate_changeset on line 14).

    2) Another thing. When I run tests, there are 4 failures each saying: ** (UndefinedFunctionError) undefined function TodoApi.Session.create_changeset/2. Its on session_test line 15, 20, 26 and session_controller_test line 15.

    • Micah Woods

      Thanks for pointing that out. I was modifying the code as I wrote the blog, so it looks like I missed that. Line 25 of the `TodoApi.Session` model should be `def create_changeset(….`.

      • NobbZ

        But still, there is no Session.create_changeset/2.

        • Cristiano Carvalho

          just need to add registration_changeset as he did in user_test.exs

  • Jayson Bailey

    Very nice! Is this in a public repo anywhere so I can more easily check my code for typos?

  • Claire

    What is in ChangesetView ?

  • Thanks, this article is very useful.
    Really like pattern matching and approach with two different changeset methods in one model.
    Minor comments:

    1) User’s field :password_hash can be null: false (in database migration)
    as well as Session’s fields :token and :user_id

    2) File test/models/user_test.exs has 2 tests with same name (“registration_changeset, password too short”), this causes error on run tests:


    ** (ExUnit.DuplicateTestError) "test registration_changeset, password too short" is already defined in TodoApi.UserTest
    (ex_unit) lib/ex_unit/case.ex:416: ExUnit.Case.register_test/4
    test/models/user_test.exs:33: (module)
    (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6
    (elixir) lib/code.ex:363: Code.require_file/2
    (elixir) lib/kernel/parallel_require.ex:56: anonymous fn/2 in Kernel.ParallelRequire.spawn_requires/5

    Probably one of them should be called something like registration_changeset with valid attributes


    test "registration_changeset with valid attributes" 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

    3) I think need to add validate_required([:email]) into User’s changeset method and validate_required([:password]) into User’s registration_changeset method respectively.
    Same for Session model: validate_required([:user_id]) in changeset method.
    Not sure since which version of Ecto method validate_required exists.

    4) Source codes for the files test/models/session_test.exs and web/models/session.ex have no comment with file path.

    • Cristiano Carvalho

      How could it be inside the user.ex?

      “`
      def registration_changeset(model, params \ :empty) do
      model
      |> changeset(params)
      |> cast(params, ~w(password), [])
      |> validate_length(:password, min: 6)
      |> put_password_hash
      validate_required([:user_id])
      end
      “`
      this way?

  • NobbZ

    I can’t find the follow up post. Can someone point me to the right one?

  • Cristiano Carvalho

    i caught in a error:

    == Compilation error on file web/controllers/user_controller.ex ==
    ** (CompileError) web/controllers/user_controller.ex:9: SchoolDiary.User.__struct__/1 is undefined, cannot expand struct SchoolDiary.User
    (stdlib) lists.erl:1354: :lists.mapfoldl/3
    (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6

    • Cristiano Carvalho

      I found the solution: i created the model with the extension (.exs) while the correct form is .ex because exs is not compiled.

  • Hello, thanks for this nice article.

    I’m not sure if I’m right or wrong but it seems to me that your system suffers from a security flaw. Old sessions are never invalidated and thus become “stalled” and can be re-used by an attacker who can figured out an old token.

    Is this a real flaw or am I missing something?

  • Alex Afshar

    Great article. Typo in Session controller “Session.crate_changeset” should be “Session.create_changeset”. Good job, keep up the elixir posts!

  • Walther11

    Hi all, thanks for the article. Ran into a persistent issue with the session controller test. Sacrificially the
    “1) test creates and renders resource when data is valid (TodoApi.SessionControllerTest)” test.

    I get ;
    1) test creates and renders resource when data is valid (TodoApi.SessionControllerTest)
    test/controllers/session_controller_test.exs:19
    Expected truthy, got nil
    code: token = IO.inspect(json_response(conn, 201)[“data”][“token”]) |> IO.inspect(label: “SessionControllerTest 23”)

    Here is the controller test code.

    test “creates and renders resource when data is valid”, %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: @valid_attrs
    IO.inspect(conn)
    |> IO.inspect(label: “SessionControllerTest 22”)
    assert token = IO.inspect(json_response(conn, 201)[“data”][“token”]) ##warning:??
    |> IO.inspect(label: “SessionControllerTest 23”)
    assert Repo.get_by(Session, token: token)
    |> IO.inspect(label: “SessionControllerTest 25”)
    end

    The inspect of conn reveals the following snippets.

    session: %TodoApi.Session{__meta__: #Ecto.Schema.Metadata,
    id: 40, inserted_at: ~N[2017-06-05 21:36:10.490987], token: nil,
    updated_at: ~N[2017-06-05 21:36:10.490991],
    user: #Ecto.Association.NotLoaded,
    user_id: 208}}, before_send: [#Function]
    body_params: %{“user” => %{“email” => “foo@bar.com”, “password” => “s3cr3t”}},

    I don’t remember anywhere in the code where the build_assoc was called to set up this association. I think this results in my token being nil which in turn causes the test with valid data to fail. Any help or suggestions would be appreciated.

  • Geovane Fedrecheski

    Hi, thanks for the article, helped me (complete newbie in authentication) to get started with password stuff + Comeonin. Now, when it comes to sessions, I am confused, is there any reason why I would not use JWT here? (I just discovered it around the web, and I don’t understand well the differences between the two approaches)

  • Watwatwat

    I suppose a year later is a bit late to chastise you for implying that you’re doing TDD when you’re not, but I’ll do it anyway.

    You have been chastised!

  • Aleksey Ivanov

    You absolutely don’t need gemsets in Ruby because you already have Bundler. Why is it bad to install gem globally? You have all gems in one place and don’t copy them to use in another projects.