Ridiculously Fast API Authentication with Phoenix
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:
- 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. - The
registration_changeset
function calls the otherchangeset
function. This removes duplicated validations from the code. Since that function returns achangeset
, you can just use it in your pipeline. - The
put_password_hash
function pattern matches to see if thechangeset
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:
- If the user is found and the password is correct, we insert a session into the database and return the token.
- If the user is found but the password is incorrect, an error is rendered.
- 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.
Reference: | Ridiculously Fast API Authentication with Phoenix from our WCG partner Florian Motlik at the Codeship Blog blog. |