This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.
Create Users Table
Let’s focus on adding users and authorization to our Todos application. The first thing we’ll need is to create a database table to hold our users and a corresponding users schema.
Thankfully, Phoenix comes with many generators that ease the process of creating things like migrations and models.
To generate our users migration, we’ll run the following mix
command:
mix phoenix.gen.model User users email:string encrypted_password:string
We’ll modify the migration file the generator created for us and add
NOT NULL
restrictions on both the email
and encrypted_password
fields:
add :email, :string, null: false
add :encrypted_password, :string, null: false
We’ll also add an index on the email
field for faster queries:
create unique_index(:users, [:email])
Great! Now we can run that migration with the mix ecto.migrate
command.
priv/repo/migrations/20160901141548_create_user.exs
+defmodule PhoenixTodos.Repo.Migrations.CreateUser do + use Ecto.Migration + + def change do + create table(:users) do + add :email, :string, null: false + add :encrypted_password, :string, null: false + + timestamps + end + + create unique_index(:users, [:email]) + end +end
Creating the Users Model
Now that we’re created our users table, we need to create a
corresponding User
model. Phoenix actually did most of the heavy
lifting for us when we ran the mix phoenix.gen.model
command.
If we look in /web/models
, we’ll find a user.ex
file that holds our
new User
model. While the defaults generated for us are very good,
we’ll need to make a few tweaks.
In addition to the :email
and :encrypted_password
fields, we’ll also
need a virtual
:password
field.
field :password, :string, virtual: true
:password
is virtual because it will be required by our changeset
function, but will not be stored in the database.
Speaking of required fields, we’ll need to update our @required_fields
and @optional_fields
attributes
to reflect the changes we’ve made:
@required_fields ~w(email password)
@optional_fields ~w(encrypted_password)
These changes to @required_fields
break our auto-generated tests
against the User
model. We’ll need to update the @valid_attrs
attribute in test/models/user_test.ex
and replace
:encrypted_password
with :password
:
@valid_attrs %{email: "user@example.com", password: "password"}
And with that, our tests flip back to green!
test/models/user_test.exs
+defmodule PhoenixTodos.UserTest do + use PhoenixTodos.ModelCase + + alias PhoenixTodos.User + + @valid_attrs %{email: "user@example.com", password: "password"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = User.changeset(%User{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = User.changeset(%User{}, @invalid_attrs) + refute changeset.valid? + end +end
web/models/user.ex
+defmodule PhoenixTodos.User do + use PhoenixTodos.Web, :model + + schema "users" do + field :email, :string + field :password, :string, virtual: true + field :encrypted_password, :string + + timestamps + end + + @required_fields ~w(email password) + @optional_fields ~w(encrypted_password) + + @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 +end
Additional Validation
While the default required/optional field validation is a good start, we
know that we’ll need additional validations on our User
models.
For example, we don’t want to accept email addresses without the "@"
symbol. We can write a test for this in our UserTest
module:
test "changeset with invalid email" do
changeset = User.changeset(%User{}, %{
email: "no_at_symbol",
password: "password"
})
refute changeset.valid?
end
Initially this test fails, but we can quickly make it pass by adding
basic regex validation to the :email
field in our User.changeset
function:
|> validate_format(:email, ~r/@/)
We can repeat this process for all of the additional validation we need, like checking password length, and asserting email uniqueness.
test/models/user_test.exs
... - alias PhoenixTodos.User + alias PhoenixTodos.{User, Repo} ... end + + test "changeset with invalid email" do + changeset = User.changeset(%User{}, %{ + email: "no_at_symbol", + password: "password" + }) + refute changeset.valid? + end + + test "changeset with short password" do + changeset = User.changeset(%User{}, %{ + email: "email@example.com", + password: "pass" + }) + refute changeset.valid? + end + + test "changeset with non-unique email" do + User.changeset(%User{}, %{ + email: "email@example.com", + password: "password", + encrypted_password: "encrypted" + }) + |> Repo.insert! + + assert {:error, _} = User.changeset(%User{}, %{ + email: "email@example.com", + password: "password", + encrypted_password: "encrypted" + }) + |> Repo.insert + end end
web/models/user.ex
... |> cast(params, @required_fields, @optional_fields) + |> validate_format(:email, ~r/@/) + |> validate_length(:password, min: 5) + |> unique_constraint(:email, message: "Email taken") end
Hashing Our Password
You might have noticed that we had to manually set values for the
encrypted_password
field for our "changeset with non-unique email"
test to run. This was to prevent the database from complaining about a
non-null constraint violation.
Let’s remove those lines from our test and generate the password hash ourselves!
:encrypted_password
was an unfortunate variable name choice. Our password is not being encrypted and stored in the database; that would be insecure. Instead we're storing the hash of the password.
We’ll use the comeonin
package to hash our passwords, so we’ll add it
as a dependency and an application in mix.exs
:
def application do
[...
applications: [..., :comeonin]]
end
defp deps do
[...
{:comeonin, "~> 2.0"}]
end
Now we can write a private method that will update the our
:encrypted_password
field on our User
model if its given a valid
changeset that’s updating the value of :password
:
defp put_encrypted_password(changeset = %Ecto.Changeset{
valid?: true,
changes: %{password: password}
}) do
changeset
|> put_change(:encrypted_password, Comeonin.Bcrypt.hashpwsalt(password))
end
We’ll use pattern matching to handle the cases where a changeset is
either invalid, or not updating the :password
field:
defp put_encrypted_password(changeset), do: changeset
Isn’t that pretty? And with that, our tests are passing once again.
mix.exs
... applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext, - :phoenix_ecto, :postgrex]] + :phoenix_ecto, :postgrex, :comeonin]] end ... {:cowboy, "~> 1.0"}, - {:mix_test_watch, "~> 0.2", only: :dev}] + {:mix_test_watch, "~> 0.2", only: :dev}, + {:comeonin, "~> 2.0"}] end
mix.lock
-%{"connection": {:hex, :connection, "1.0.4"}, +%{"comeonin": {:hex, :comeonin, "2.5.2"}, + "connection": {:hex, :connection, "1.0.4"}, "cowboy": {:hex, :cowboy, "1.0.4"},
test/models/user_test.exs
... email: "email@example.com", - password: "password", - encrypted_password: "encrypted" + password: "password" }) ... email: "email@example.com", - password: "password", - encrypted_password: "encrypted" + password: "password" })
web/models/user.ex
... |> unique_constraint(:email, message: "Email taken") + |> put_encrypted_password end + + defp put_encrypted_password(changeset = %Ecto.Changeset{ + valid?: true, + changes: %{password: password} + }) do + changeset + |> put_change(:encrypted_password, Comeonin.Bcrypt.hashpwsalt(password)) + end + defp put_encrypted_password(changeset), do: changeset end
Final Thoughts
Things are starting to look very different from our original Meteor application. While Meteor tends to hide complexity from application developers by withholding code in the framework itself, Phoenix expects developers to write much of this boilerplate code themselves.
While Meteor’s methodology lets developers get off the ground quickly, Phoenix’s philosophy of hiding nothing ensures that there’s no magic in the air. Everything works just as you would expect; it’s all right in front of you!
Additionally, Phoenix generators ease most of the burden of creating this boilerplate code.
Now that our User
model is in place, we’re in prime position to wire up our front-end authorization components. Check back next week to see those updates!