User Authentication - SolarisJapan/lunaris-wiki GitHub Wiki

User Authentication

Since the basic idea for most user authentications is basically the same, we can set up a basic login flow for most apps.

🔥 In all code examples, don't forget to replace YourApp and your_app with the name of your app! 🔥

This wiki includes code examples, please make sure you understand the code, don't just copy and paste. If you find any errors, or have suggestions for improvements feel free to make a PR

Accounts User schema and context

Accounts will be the context responsible for interactions with user authentication. We will be using Comeonin with Argon2 as hashing algorithm. The basic User schema is quite simple:

defmodule YourApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  alias YourApp.Encryption

  schema "users" do
    field(:name, :string)
    field(:password, :string, virtual: true)
    field(:password_hash, :string)

    timestamps()
  end

  @doc false
  def changeset(%__MODULE__{} = user, %{password: _} = attrs) do
    user
    |> cast(attrs, [:name, :password])
    |> validate_required([:name, :password])
    |> put_password_hash()
    |> unique_constraint(:name, message: "Name is already taken")
  end

  def changeset(%__MODULE__{} = user, attrs) do
    user
    |> cast(attrs, [:name, :password_hash])
    |> validate_required([:name, :password_hash])
    |> unique_constraint(:name)
  end

  defp put_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
        put_change(changeset, :password_hash, Encryption.hash(password))

      _ ->
        changeset
    end
  end
end

In Accounts we want some basic functions to be able to create and update our user accordingly.

defmodule YourApp.Accounts do
  def create_user(args)

  def validate_password(user, password)

  def get_user_by_name(name)

  # This might or might not be a good idea for your app
  def update_password(user, %{old_password: _, new_password: _})

  # ..
end

What we need to do

Dependencies

Add Comeonin and Argon2Elixir to mix.exs and run mix deps.get

defp deps do
  [
    # ...
    {:comeonin, "~> 5.1"},
    {:argon2_elixir, "~> 2.0"}
  ]

Create the Accounts context and User

Unless you want to use the phoenix scaffold when using mix phx.gen.X, it is probably easier to create the files yourself and add functions when you need them, instead of having to clean up unused functions in the scaffold.

Run mix ecto.gen.migration create_users and add this to the generated migration file in priv/repo/migrations

def change do
  create table(:users) do
    add(:name, :string, null: false)

    add(:password_hash, :string, null: false)

    timestamps()
  end

  create(unique_index(:users, [:name]))
end

Add the basic schema, context outline and Encryption modules.

touch lib/your_app/accounts.ex && mkdir lib/your_app/accounts && touch lib/your_app/accounts/user.ex && touch lib/your_app/encryption.ex
# lib/your_app/accounts/user.ex
defmodule YourApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  alias YourApp.Encryption

  schema "users" do
    field(:name, :string)
    field(:password, :string, virtual: true)
    field(:password_hash, :string)

    timestamps()
  end

  @doc false
  def changeset(%__MODULE__{} = user, %{password: _} = attrs) do
    user
    |> cast(attrs, [:name, :password])
    |> validate_required([:name, :password])
    |> put_password_hash()
    |> unique_constraint(:name, message: "Name is already taken")
  end

  def changeset(%__MODULE__{} = user, attrs) do
    user
    |> cast(attrs, [:name, :password_hash])
    |> validate_required([:name, :password_hash])
    |> unique_constraint(:name)
  end

  defp put_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
        put_change(changeset, :password_hash, Encryption.hash(password))

      _ ->
        changeset
    end
  end
end
# lib/your_app/accounts.ex
defmodule YourApp.Accounts do
  alias YourApp.{Accounts, Repo}

  @doc """
  Creates a user entry and creates the password hash.

  ## Examples

      iex> create_user(%{name: "Test User", password: "123password"})
      {:ok, %Accounts.User{}}

      iex> create_user(%{name: nil, password: nil})
      {:error, %Ecto.Changeset{}}
  """
  def create_user(args)

  @doc """
  Finds a user by name, returns the user or `nil`.

  ## Examples

      iex> get_user_by_name("Test User")
      %Accounts.User{}

      iex> get_user_by_name("Does not exist")
      nil
  """
  def get_user_by_name(nil), do: nil

  def get_user_by_name(name)

  @doc """
  Securely compares a users password and returns `true` or `false`.

  ## Examples

      iex> validate_password(%Accounts.User{password_hash: "hashed_pwd", "123Password"})
      true

      iex> validate_password(%Accounts.User{password_hash: "hashed_pwd", "WrongPassword"})
      false
  """
  def validate_password(user, password)
end
defmodule YourApp.Encryption do
  @moduledoc false

  def hash(string),
    do: Argon2.hash_pwd_salt(string)

  def check_pass(user, pwd),
    do: Argon2.check_pass(user, pwd)
end

To implement the business logic for these functions, we are going to create the tests first.

Accounts Test

Having factories for your tests will probably make things easier, but if you prefer you can also create test database entries in your test code.

# without factories
touch test/accounts_test.exs

# with factories
touch test/accounts_test.exs && mkdir /test/factories && touch test/factories/user_factory.ex && touch test/factories/factory.ex

Factories (You can skip this part if you do not want to use factories)

Factories are a convenient way to populate your database with necessary entries, and provide reasonable, overwritable default values. Add the factories path to elixir_paths/1 in mix.exs

defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"]

We want to introduce a generic Factory to be usable as a DSL in our factories. In this tutorial, we are going to build a custom ex_machina - this is mainly for educational reasons, in most apps using ex_machina works perfectly well. The reason why you would maybe want to implement the factory yourself is that is easier to customize your own code, and especially creating associations is not always exactly the way you might need it with ex_machina.

If you do not want to use a custom made factory, install ex_machina and skip this part

We will implement insert! and sequence as well as define the __using__ macro:

defmodule Factory do
  @moduledoc """
  A DSL to create factories.

  ## Usage

  Create a factory module and implement the build function

      defmodule MyFactory do
        use Factory

        def build(:my_thing, args) do
          %MyApp.MyThing{
            name: "A thing"
          }
        end
      end
  """

  defmacro __using__(_opts) do
    quote do
      alias YourApp.Repo

      import Factory, only: [sequence: 1]

      @doc false
      def build(factory_name) do
        raise(Factory.BuildNotImplementedError, factory_name)
      end

      defoverridable(build: 1)

      @doc false
      def build(factory_name, attributes),
        do: factory_name |> build() |> struct(attributes)

      @doc false
      def insert!(factory_name, attributes \\ []),
        do: factory_name |> build(attributes) |> Repo.insert!()
    end
  end

  defmodule BuildNotImplementedError do
    @moduledoc """
    Error raised when a `build/1` function is not implemented.
    """

    defexception [:message]

    def exception(factory_name) do
      message = """
      No build function defined for #{inspect(factory_name)}

      Please implement the function in your factory:

          def build(#{inspect(factory_name)}) do
            %MyApp.MyThing{
              name: "A thing"
            }
          end
      """

      %BuildNotImplementedError{message: message}
    end
  end

  @doc false
  def sequence(fun) when is_function(fun),
    do: System.unique_integer([:positive]) |> to_string() |> fun.()

  def sequence(argument) do
    raise(
      ArgumentError,
      "Factory.sequence/1 expects the argument to be a function, got #{inspect(argument)}"
    )
  end
end

Now we can build our user factory

defmodule UserFactory do
  use Factory

  alias YourApp.{Accounts, Repo, Encryption}

  def build(:user) do
    %Accounts.User{
      password_hash: Encryption.hash("password123"),
      name: sequence(&"#{&1}-user")
    }
  end
end

And use it like this

user = UserFactory.insert!(:user, %{name: "hello there"})

The AccountsTest and Accounts.User

We want to test the 3 basic functions in Accounts: create_user, get_user_by_name, and validate_password

defmodule YourApp.AccountsTest do
  use YourApp.DataCase

  describe "Accounts.create_user/1" do
    test "creates a user when given valid arguments"

    test "returns {:error, %Ecto.Changeset{}} when given invalid arguments"
  end

  describe "Accounts.get_user_by_name/1" do
    test "returns %Accounts.User{} when a user with that name exists"

    test "returns nil when no user with that name exists"
  end

  describe "Accounts.validate_password/2" do
    test "returns true if the password matches the password hash"

    test "returns false if the password does not match the password hash"
  end
end

If you are going with the factory and haven't set up the User schema yet, running these empty tests will already give us our first error message

== Compilation error in file test/factories/user_factory.ex ==
** (CompileError) test/factories/user_factory.ex:7: YourApp.Accounts.User.__struct__/1 is undefined, cannot expand struct YourApp.Accounts.User
    test/factories/user_factory.ex:6: (module)

This means we haven't implemented the user schema yet, but already used it in our factory. You can use the User schema described earlier. The test should now fail with the message Not Implemented.

The caveat with the factory is, that we need to create users with the password_hash, instead of just setting the password.

defmodule YourApp.AccountsTest do
  use YourApp.DataCase

  alias YourApp.Accounts

  describe "Accounts.create_user/1" do
    test "creates a user when given valid arguments" do
      valid_args = %{name: "Some new user", password: "123password"}
      assert {:ok, %Accounts.User{}} = Accounts.create_user(valid_args)
    end

    test "returns {:error, %Ecto.Changeset{}} when given invalid arguments" do
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(%{name: "Some new user"})
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(%{password: "123password"})
    end
  end

  describe "Accounts.get_user_by_name/1" do
    setup do
      %{user: UserFactory.insert!(:user)}
    end

    test "returns %Accounts.User{} when a user with that name exists", %{user: user} do
      assert user.id == Map.get(Accounts.get_user_by_name(user.name), :id)
    end

    test "returns nil when no user with that name exists", %{user: user} do
      assert is_nil(Accounts.get_user_by_name("#{user.name} - does not exist"))
    end
  end

  describe "Accounts.validate_password/2" do
    alias YourApp.Encryption

    setup do
      password = "greatPassword"

      %{
        password: password,
        user: UserFactory.insert!(:user, %{password_hash: Encryption.hash(password)})
      }
    end

    test "returns true if the password matches the password hash", args do
      assert Accounts.validate_password(args.user, args.password)
    end

    test "returns false if the password does not match the password hash", args do
      refute Accounts.validate_password(args.user, "#{args.password} - is wrong")
    end
  end
end

All the tests should fail with an error message that the function we are trying to test is undefined or private!

Implement Accounts

The implementation for create_user and validate_user is pretty straight_forward:

# lib/accounts.ex

defmodule YourApp.Accounts do
  alias YourApp.{Accounts, Repo, Encryption}

  # ...

  def create_user(args) do
    %Accounts.User{}
    |> Accounts.User.changeset(args)
    |> Repo.insert()
  end

  # ...

  def validate_password(user, password) do
    case Encryption.check_pass(user, password) do
      {:ok, _} -> true
      {:error, _} -> false
    end
  end
end

For the implementation of get_user_by_name, we need an Ecto.Query. To separate concerns, queries should have their own modules.

touch lib/accounts/your_app/queries.ex
defmodule YourApp.Accounts.Queries do
  @moduledoc """
  Queries in the Accounts context.

  All queries should be usable with a `queryable` as the first argument so you can use them
  with pipes

      iex> MySchema |> Queries.first_query(%{field: "value"}) |> Queries.second_query(%{field2: "value2", field3: "value3"})
  """

  import Ecto.Query

  @doc """
  User where name equals the name given.

  ## Examples

      iex> user_where_name(Accounts.User, %{name: "A User"})
      %Ecto.Query{}
  """
  def user_where_name(queryable, %{name: name}),
    do: from(u in queryable, where: u.name == ^name)

  def user_where_name(queryable, _),
    do: queryable
end

We can now use the queries module to get a user by name in Accounts

defmodule YourApp.Accounts do
  # ...

  def get_user_by_name(name) do
    Accounts.User
    |> Accounts.Queries.user_where_name(%{name: name})
    |> Repo.one()
  end
end

We can do the same for get_session_by_token

# lib/your_app/accounts/queries.ex

# ...

@doc """
Session where token equals the token given.

## Examples

    iex> session_where_token(Accounts.Session, %{token: "A Session"})
    %Ecto.Query{}
"""
def session_where_token(queryable, %{token: token}),
  do: from(s in queryable, where: s.token == ^token)

def session_where_token(queryable, _),
  do: queryable
# lib/your_app/accounts.ex

# ...

@doc """
Finds a session by token, returns the session or `nil`.

## Examples

    iex> get_session_by_token("Test Session")
    %Accounts.Session{}

    iex> get_session_by_token("Does not exist")
    nil
"""
def get_session_by_token(nil), do: nil

def get_session_by_token(token) do
  Accounts.Session
  |> Accounts.Queries.session_where_token(%{token: token})
  |> Repo.one()
end

Login page

We will set up a basic login page, with Zurb Foundation [Setup-Phoenix-Zurb-Foundation]

Lets use the phoenix generator for a change

mix phx.gen.html Accounts Session sessions token:string user_id:references:users

We are going to add session logic to the Accounts context, the main object in this context is the session. This creates more than we actually need, so this is more of a showcase why you probably hardly ever want to use the phoenix generators unless you want to look something up in the generated files. We can enforce a single active session if we want, but for this example we will have a user with multiple active sessions, so if you log in on a new device or browser you will not be logged out of your other sessions.

For the session, we only need new, create and delete. Go to the session_controller.ex file and delete all the other actions (same in session_controller_test.exs), then delete the templates show.html.eex, edit.html.eex and index.html.eex. We also don't need half of the generated functions in accounts.ex, you can delete list_sessions, and update_session (and again in accounts_test.exs). We need to clean up the schema a little bit as well, in accounts/session.ex remove the field user and use the belongs_to function instead - we also want to be able to put the user association into the changeset. The changeset should be the one to put the session token as well

defmodule YourApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias YourApp.Accounts

  schema "sessions" do
    field :token, :string
    belongs_to :user, Accounts.User

    timestamps()
  end

  @doc false
  def changeset(session, attrs) do
    session
    |> cast(attrs, [:user_id])
    |> validate_required([:user_id])
    |> put_token()
  end

  @doc false
  defp put_token(changeset) do
    if changeset.valid? do
      put_change(changeset, :token, SecureRandom.uuid())
    else
      changeset
    end
  end
end

and add the association to accounts/user.ex

defmodule YourApp.Accounts.User do
  # ...
  alias YourApp.{Accounts, Encryption}

  schema "users" do
    field(:name, :string)
    field(:password, :string, virtual: true)
    field(:password_hash, :string)
    has_many(:sessions, Accounts.Session)

    timestamps()
  end

  # ...
end

Secure Random is an elixir package to generate unique, random strings. We need to add it to our dependencies and run mix deps.get

# mix.exs

#...
defp deps do
  [
    #...
    {:secure_random, "~> 0.5"}
  ]
end

For now we can ignore most of the business logic and focus on the templates. We have to add the routes though:

resources "/sessions", SessionController, only: [:new, :create, :delete]

The generated code in the session controller refers to the index and show path we deleted, so it will raise an error if we try to run phx.server and go to localhost:4000/sessions/new. For this example, we will create a quick dashboard.

touch lib/your_app_web/controllers/dashboard_controller.ex && touch lib/your_app_web/views/dashboard_view.ex && mkdir lib/your_app_web/templates/dashboard && touch lib/your_app_web/templates/dashboard/inde
x.html.eex
defmodule YourAppWeb.DashboardController do
  use YourAppWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html")
  end
end
defmodule YourAppWeb.DashboardView do
  use YourAppWeb, :view
end
# router.ex

#...
scope "/", YourAppWeb do
  pipe_through :browser

  resources "/sessions", SessionController, only: [:new, :create, :delete]
  get "/dashboard", DashboardController, :index
end
#...

and in templates/session/new.html.eex delete <span><%= link "Back", to: Routes.session_path(@conn, :index) %></span>

Now we can redirect to the empty dashboard after creating a new user session, and back to the new session page after deleting a session (logout)

defmodule YourAppWeb.SessionController do
  use YourAppWeb, :controller

  alias YourApp.Accounts
  alias YourApp.Accounts.Session

  def new(conn, _params) do
    changeset = Accounts.change_session(%Session{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"session" => session_params}) do
    case Accounts.create_session(session_params) do
      {:ok, session} ->
        conn
        |> put_flash(:info, "Session created successfully.")
        |> redirect(to: Routes.dashboard_path(conn, :index))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    session = Accounts.get_session!(id)
    {:ok, _session} = Accounts.delete_session(session)

    conn
    |> put_flash(:info, "Session deleted successfully.")
    |> redirect(to: Routes.session_path(conn, :new))
  end
end

The generated form will have an input field for the token, which is of course not what we want. Lets add virtual fields for name and password to the session schema, and validate the user password. We also want to be ambiguous with the error message if there is no user in the attributes.

defmodule YourApp.Accounts.Session do
  # ...

  @doc false
  def changeset(session, %{user: %Accounts.User{}} = attrs) do
    user_id = attrs.user.id
    attrs = Map.put_new(attrs, :user_id, user_id)

    session
    |> cast(attrs, [:user_id, :name, :password])
    |> validate_required([:user_id, :name, :password])
    |> validate_user_password(attrs)
    |> put_token()
  end

  def changeset(session, attrs) do
    session
    |> cast(attrs, [:user_id, :name, :password])
    |> add_error(:password, "User name-password combination not found.")
  end

  @doc false
  defp validate_user_password(changeset, attrs) do
    cond do
      !changeset.valid? ->
        changeset

      Accounts.validate_password(attrs.user, attrs.password) ->
        changeset

      true ->
        add_error(changeset, :password, "User name-password combination not found.")
    end
  end

  # ...
end

Since there is a lot going on in this changeset, we should probably write a test for it

mkdir test/lunaful/accounts && touch test/lunaful/accounts/session_changeset_test.exs
defmodule YourApp.Accounts.SessionChangesetTest do
  use ExUnit.Case, async: true

  alias YourApp.{Accounts, Encryption}

  describe "Accounts.Session.changeset/2" do
    test "returns an invalid changeset, if the password does not match the users password" do
      user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
      session = %Accounts.Session{}

      changeset =
        Accounts.Session.changeset(session, %{
          user: user,
          name: user.name,
          password: "wrongPassword"
        })

      refute changeset.valid?
    end

    test "puts the right error message, if the password does not match the users password" do
      user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
      session = %Accounts.Session{}

      changeset =
        Accounts.Session.changeset(session, %{
          user: user,
          name: user.name,
          password: "wrongPassword"
        })

      assert [password: {"User name-password combination not found.", []}] = changeset.errors
    end

    test "puts the right error message if no user given" do
      session = %Accounts.Session{}

      changeset =
        Accounts.Session.changeset(session, %{
          name: "User not found",
          password: "123Password"
        })

      assert [password: {"User name-password combination not found.", []}] = changeset.errors
    end

    test "is valid if the password is correct" do
      user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
      session = %Accounts.Session{}

      changeset =
        Accounts.Session.changeset(session, %{
          user: user,
          name: user.name,
          password: "123password"
        })

      assert changeset.valid?
    end

    test "puts the token if the changeset is valid" do
      user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
      session = %Accounts.Session{}

      changeset =
        Accounts.Session.changeset(session, %{
          user: user,
          name: user.name,
          password: "123password"
        })

      token = Map.get(changeset.changes, :token)

      refute is_nil(token)
    end
  end
end

Change the session form in templates/session/form.html.exs to use the new fields in the changeset

<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :name %>
  <%= text_input f, :name %>
  <%= error_tag f, :name %>

  <%= label f, :password %>
  <%= text_input f, :password %>
  <%= error_tag f, :password %>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

We can apply a little styling to the page. Lets create a file to hold application wide styling - app.scss should only hold imports.

touch assets/css/application.scss
.container {
  max-width: 1284px;
  padding: 4rem 0;
  margin-left: auto;
  margin-right: auto;
}

We should also use the foundations grid and button class to style our form

<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="grid-container" data-closable>
      <div class="grid-x grid-padding-x align-center">
        <div class="callout alert">
          <p>Oops, something went wrong! Please check the errors below.</p>
          <button class="close-button" aria-label="Dismiss alert" type="button" data-close>
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
      </div>
    </div>
  <% end %>

  <div class="grid-container">
    <div class="grid-x grid-padding-x align-center">
      <div class="medium-6 cell">
        <%= label f, :name %>
        <%= text_input f, :name, required: true %>
        <%= error_tag f, :name %>
      </div>
    </div>
    <div class="grid-x grid-padding-x align-center">
      <div class="medium-6 cell">
        <%= label f, :password %>
        <%= text_input f, :password, required: true %>
        <%= error_tag f, :password %>
      </div>
    </div>

    <div class="grid-x grid-padding-x align-center">
      <div class="medium-6 cell">
        <%= submit "Submit", class: "button" %>
      </div>
    </div>
  </div>
<% end %>
<h1 class="text-center">New Session</h1>

<%= render "form.html", Map.put(assigns, :action, Routes.session_path(@conn, :create)) %>

and also fix our app.html.eex just a little bit

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>YourApp · Phoenix Framework</title>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
  </head>
  <body>
    <header>
      <section class="grid-x align-center">
        <a href="http://phoenixframework.org/" class="phx-logo">
          <img src="<%= Routes.static_path(@conn, "/images/phoenix.png") %>" alt="Phoenix Framework Logo"/>
        </a>
      </section>
    </header>
    <main role="main" class="container">
      <%= if get_flash(@conn, :info) do %>
        <div class="grid-container" data-closable>
          <div class="grid-x grid-padding-x align-center">
            <div class="callout success">
              <p><%= get_flash(@conn, :info) %></p>
              <button class="close-button" aria-label="Dismiss alert" type="button" data-close>
                <span aria-hidden="true">&times;</span>
              </button>
            </div>
          </div>
        </div>
      <% end %>

      <%= if get_flash(@conn, :error) do %>
        <div class="grid-container" data-closable>
          <div class="grid-x grid-padding-x align-center">
            <div class="callout alert">
              <p><%= get_flash(@conn, :error) %></p>
            </div>
            <button class="close-button" aria-label="Dismiss alert" type="button" data-close>
              <span aria-hidden="true">&times;</span>
            </button>
          </div>
        </div>
      <% end %>

      <%= render @view_module, @view_template, assigns %>
    </main>
    <script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </body>
</html>

Your page should now look like this

Accounts Session tests

We are not going to test the get_session/1 function, since that simply passes the schema and the id to the Repo.one/2 function, that one should have tests in Ecto.

# test/accounts/accounts_test.exs

# ...

describe "sessions" do
  test "Accounts.create_session/1 with valid data creates a session"

  test "Accounts.create_session/1 with invalid data returns error changeset"

  test "Accounts.delete_session/1 deletes the session"

  test "Accounts.change_session/1 returns a session changeset"

  test "returns %Accounts.Session{} when a session with that token exists"

  test "returns nil when no session with that token exists"
end

We need a session factory for these tests

touch test/factories/session_factory.ex
defmodule SessionFactory do
  use Factory

  alias YourApp.Accounts.Session

  def build(:session) do
    %{id: user_id} = UserFactory.insert!(:user)

    %Session{
      token: sequence(&"#{&1}-token"),
      user_id: user_id
    }
  end
end

The first thing we are going to change in the accounts module is adding a get_session/1 function that will either return a session or nil, instead of raising an error if no session found

# ...
@doc """
Gets a single session.

Raises `Ecto.NoResultsError` if the Session does not exist.

## Examples

    iex> get_session!(123)
    %Session{}

    iex> get_session!(456)
    ** (Ecto.NoResultsError)

"""
def get_session!(id), do: Repo.get!(Session, id)

@doc """
Gets a single session.

Returns nil if the Session does not exist.

## Examples

    iex> get_session(123)
    %Session{}

    iex> get_session(456)
    ** (Ecto.NoResultsError)

"""
def get_session(id), do: Repo.get(Session, id)

#...

The test implementation for create_session/1 should make sure we get the correct errors for wrong user name - password combinations, other than that, it is a straight forward solution

alias YourApp.Accounts.Session
alias YourApp.{Repo, Encryption}

test "Accounts.create_session/1 with valid data creates a session" do
  user =
    UserFactory.insert!(:user, %{
      password: "123password",
      password_hash: Encryption.hash("123password")
    })

  valid_attrs = %{password: "123password", name: user.name}

  assert {:ok, %Session{} = session} = Accounts.create_session(valid_attrs)
  refute is_nil(session.token)

  assert [user_session] = user |> Ecto.assoc(:sessions) |> Repo.all()
  assert user_session.id == session.id
end

test "Accounts.create_session/1 with invalid data returns error changeset" do
  user = UserFactory.insert!(:user)

  assert {:error, %Ecto.Changeset{}} = Accounts.create_session(%{})

  assert {:error, %Ecto.Changeset{errors: [password: _]}} =
           Accounts.create_session(%{name: user.name, password: "wrong password"})

  assert {:error, %Ecto.Changeset{errors: [password: _]}} =
           Accounts.create_session(%{
             name: "#{user.name}-does not exist",
             password: "123password"
           })
end

test "Accounts.delete_session/1 deletes the session" do
  session = SessionFactory.insert!(:session)

  assert {:ok, %Session{}} = Accounts.delete_session(session)
  assert is_nil(Accounts.get_session(session.id))
end

test "change_session/1 returns a session changeset" do
  assert %Ecto.Changeset{} = Accounts.change_session(%Session{})
end

test "change_session/2 returns a session changeset" do
  assert %Ecto.Changeset{} = Accounts.change_session(%Session{}, %{})
end

describe "Accounts.get_session_by_token/1" do
  setup do
    %{session: SessionFactory.insert!(:session)}
  end

  test "returns %Accounts.Session{} when a session with that token exists", %{session: session} do
    assert session.id == Map.get(Accounts.get_session_by_token(session.token), :id)
  end

  test "returns nil when no session with that token exists", %{session: session} do
    assert is_nil(Accounts.get_session_by_token("#{session.token} - does not exist"))
  end
end

create_session should use the name to find a user and the changeset checks the password

alias YourApp.Accounts.Session

@doc """
Gets a single session.

Raises `Ecto.NoResultsError` if the Session does not exist.

## Examples

    iex> get_session!(123)
    %Session{}

    iex> get_session!(456)
    ** (Ecto.NoResultsError)

"""
def get_session!(id), do: Repo.get!(Session, id)

@doc """
Gets a single session.

Returns nil if the Session does not exist.

## Examples

    iex> get_session(123)
    %Session{}

    iex> get_session(456)
    ** (Ecto.NoResultsError)

"""
def get_session(id), do: Repo.get(Session, id)

@doc """
Creates a session when given the correct `name` `password` combination.

## Examples

    iex> create_session(%{name: "User", password: "correctPassword"})
    {:ok, %Session{}}

    iex> create_session(%{name: "User", password: "WrongPassword"})
    {:error, %Ecto.Changeset{}}

    iex> create_session(%{name: "wrongUser", password: "correctPassword"})
    {:error, %Ecto.Changeset{}}

"""
def create_session(attrs) do
  user = attrs |> Map.get(:name) |> get_user_by_name()
  attrs = Map.put(attrs, :user, user)

  %Session{}
  |> change_session(attrs)
  |> Repo.insert()
end

@doc """
Deletes a Session.

## Examples

    iex> delete_session(session)
    {:ok, %Session{}}

    iex> delete_session(session)
    {:error, %Ecto.Changeset{}}

"""
def delete_session(%Session{} = session) do
  Repo.delete(session)
end

@doc """
Returns an `%Ecto.Changeset{}` for tracking session changes.

## Examples

    iex> change_session(session)
    %Ecto.Changeset{source: %Session{}}

"""
def change_session(%Session{} = session, attrs \\ %{}) do
  Session.changeset(session, attrs)
end

Login / Logout buttons

We haven't added any way for a user to log in or log out yet. Using the basic foundation navigation classes, we can add a navigation bar to our layout app.html.eex

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>YourApp · Phoenix Framework</title>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
  </head>
  <body>
    <header>
      <nav class="top-bar">
        <div class="top-bar-right">
          <ul class="menu">
            <li>
              <%= if YourApp.Accounts.logged_in?(@conn) do %>
                <%= form_for @conn, Routes.session_path(@conn, :delete), [method: :delete], fn _ -> %>
                  <%= submit "Logout", class: "button small secondary" %>
                <% end %>
              <% else %>
                <%= link "Login", to: Routes.session_path(@conn, :new), class: "button small secondary" %>
              <% end %>
            </li>
          </ul>
        </div>
      </nav>

      <section class="grid-x align-center">
        <a href="http://phoenixframework.org/" class="phx-logo">
          <img src="<%= Routes.static_path(@conn, "/images/phoenix.png") %>" alt="Phoenix Framework Logo"/>
        </a>
      </section>
    </header>
    <main role="main" class="container">
      <%= if get_flash(@conn, :info) do %>
        <div class="grid-container" data-closable>
          <div class="grid-x grid-padding-x align-center">
            <div class="callout success">
              <p><%= get_flash(@conn, :info) %></p>
              <button class="close-button" aria-label="Dismiss alert" type="button" data-close>
                <span aria-hidden="true">&times;</span>
              </button>
            </div>
          </div>
        </div>
      <% end %>

      <%= if get_flash(@conn, :error) do %>
        <div class="grid-container" data-closable>
          <div class="grid-x grid-padding-x align-center">
            <div class="callout alert">
              <p><%= get_flash(@conn, :error) %></p>
            </div>
            <button class="close-button" aria-label="Dismiss alert" type="button" data-close>
              <span aria-hidden="true">&times;</span>
            </button>
          </div>
        </div>
      <% end %>

      <%= render @view_module, @view_template, assigns %>
    </main>
    <script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </body>
</html>

This introduces 2 problems we have to deal with before we can actually see anything:

  • the session path to delete currently expects an id as a parameter, but we want to read the session from the session cookie
  • the logged_in? function in the session view

We can remove the session delete from the resources in router.ex and add our own

scope "/", YourAppWeb do
  pipe_through :browser

  resources "/sessions", SessionController, only: [:new, :create]
  delete "/sessions", SessionController, :delete
  get "/dashboard", DashboardController, :index
end

and add the logged_in? function to accounts.ex

# ...

@doc """
Checks the current session for a session token.
"""
def logged_in?(conn) do
  Plug.Conn.get_session(conn, :token) && Map.get(conn.private, :current_user)
end

Session Controller test

If we run mix test test/your_app_web/controllers/session_controller.ex with our current code base, the session controller tests should fail - lets fix that.

defmodule YourAppWeb.SessionControllerTest do
  use YourAppWeb.ConnCase

  alias YourApp.Encryption

  describe "new session" do
    test "renders form", %{conn: conn} do
      conn = get(conn, Routes.session_path(conn, :new))
      assert html_response(conn, 200) =~ "New Session"
    end
  end

  describe "create session" do
    test "redirects to dashboard when data is valid", %{conn: conn} do
      user =
        UserFactory.insert!(:user, %{
          password: "123password",
          password_hash: Encryption.hash("123password")
        })

      conn =
        post(conn, Routes.session_path(conn, :create),
          session: %{password: user.password, name: user.name}
        )

      assert redirected_to(conn) == Routes.dashboard_path(conn, :index)
    end

    test "renders errors when data is invalid", %{conn: conn} do
      conn =
        post(conn, Routes.session_path(conn, :create, %{}),
          session: %{name: "does not exist", password: "wrong password"}
        )

      assert html_response(conn, 200) =~ "New Session"
    end
  end

  describe "delete session" do
    setup %{conn: conn} do
      user =
        UserFactory.insert!(:user, %{
          password: "123password",
          password_hash: Encryption.hash("123password")
        })

      conn =
        post(conn, Routes.session_path(conn, :create),
          session: %{password: user.password, name: user.name}
        )

      %{conn: conn}
    end

    test "deletes chosen session", %{conn: conn} do
      conn = delete(conn, Routes.session_path(conn, :delete, %{}))
      redir_path = redirected_to(conn)
      assert redir_path == Routes.session_path(conn, :new, %{})

      conn = get(recycle(conn), redir_path)
      assert html_response(conn, 200) =~ "Login"
    end
  end
end

We haven't really done anything in the session controller to actually handle the login and logout yet. Lets put the logic in the account module. For now, the basic logic is really simple but you could - for example - put the current user into the private section.

# lib/your_app/accounts.ex

# ...

@doc """
Puts the necessary information into conn
"""
def login_web_session(%Plug.Conn{} = conn, %Session{} = session),
  do: Plug.Conn.put_session(conn, :token, session.token)

@doc """
Resets a conn to a non logged in state
"""
def logout_web_session(%Plug.Conn{} = conn, %Session{} = session),
  do: Plug.Conn.delete_session(conn, :token)

With this in place we can implement the session controller

defmodule YourAppWeb.SessionController do
  use YourAppWeb, :controller

  alias YourApp.Accounts
  alias YourApp.Accounts.Session

  def new(conn, _params) do
    changeset = Accounts.change_session(%Session{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"session" => %{"name" => name, "password" => password}}) do
    case Accounts.create_session(%{name: name, password: password}) do
      {:ok, session} ->
        conn
        |> put_flash(:info, "Session created successfully.")
        |> Accounts.login_web_session(session)
        |> redirect(to: Routes.dashboard_path(conn, :index))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def delete(conn, _params) do
    session = get_session(conn, :token) |> Accounts.get_session_by_token()
    {:ok, _session} = Accounts.delete_session(session)

    conn
    |> put_flash(:info, "Session deleted successfully.")
    |> Accounts.logout_web_session(session)
    |> redirect(to: Routes.session_path(conn, :new))
  end
end

Redirect people to login if they are not logged in

Creating a session and all that is great, but now we need to be able send people away from pages that should only be available if you are logged in.

If you are not familiar with plugs, you might want to read up on it first

Lets create a plug called BouncerPlug

mkdir lib/your_app/plugs && touch lib/your_app/plugs/bouncer_plug.ex
defmodule YourApp.Plugs.BouncerPlug do
  alias YourApp.{Accounts, Repo}
  alias YourAppWeb.Router.Helpers, as: Routes

  def init(options), do: options

  def call(conn, _opts) do
    case conn |> Plug.Conn.get_session(:token) |> Accounts.get_session_by_token() do
      nil ->
        redirect_to_new_session(conn)

      session ->
        if user = session |> Ecto.assoc(:user) |> Repo.one() do
          Plug.Conn.put_private(conn, :current_user, user)
        else
          redirect_to_new_session(conn)
        end
    end
  end

  defp redirect_to_new_session(conn) do
    conn
    |> Phoenix.Controller.redirect(to: Routes.session_path(conn, :new))
    |> Plug.Conn.halt()
  end
end

We now have to split up our routes in protected routes you can only access when logged in, or routes that are publically available. The session/new and session/create routes have to be public, otherwise you could never log in.

# router.ex

pipeline :protected_browser do
  pipe_through :browser
  plug YourApp.Plugs.BouncerPlug
end

scope "/", YourAppWeb do
  pipe_through :browser

  resources "/sessions", SessionController, only: [:new, :create]
end

scope "/admin", YourAppWeb do
  pipe_through :protected_browser

  delete "/sessions", SessionController, :delete
  get "/dashboard", DashboardController, :index
end

Wrap it up

After running mix ecto.migrate, you should now be able to fire up the phoenix server mix phx.server and go to localhost:4000/session/new - but you can't log in yet, because there is no user in the database.

Run iex -S mix and create a user

iex> YourApp.Accounts.create_user(%{name: "myUser", password: "123Password"})

Try out your new user login

Optimizations

There are a lot of things we can do to make this more performant, or make it more versatile.

TODO:

  • Speed up Argon2 encryption algorithm for tests
  • Delete sessions on logout
  • Check sessions in the database to make sure they are active
⚠️ **GitHub.com Fallback** ⚠️