Our forum is nearly ready for deployment, but presently logging in requires a GitHub account. Some users won't have a GitHub account, and we'd still like them to be able to log in to our system. Let's provide a basic username/password login mechanism. To do this, we'll use comeonin to hash passwords and tie it into Ueberauth via ueberauth_identity. Let's get started.

Project

We're starting with the dailydrip/firestorm repo tagged before this episode.

comeonin

To begin with, we'll bring in comeonin so we can hash passwords. We'll use comeonin for a bcrypt implementation.

vim mix.exs
defmodule FirestormWeb.Mixfile do
  # ...
  defp deps do
    [
      # ...
      {:comeonin, "~> 3.0.2"},
      # We'll also install ueberauth_identity now, for later
      {:ueberauth_identity, "~> 0.2.3"},
      # ...
    ]
  end
  # ...
end
mix deps.get

bcrypt is designed as a hashing algorithm that's intentionally slow. This helps as it takes longer for an attacker that's gotten hold of your database to brute force passwords. However, this is the sort of thing that can make your tests unacceptably slow. We can configure it to be less secure in test mode, to speed it up.

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

Next, let's add a password_hash field to our forums_users table to store our hashed passwords in:

mix ecto.gen.migration add_password_hash_to_forums_users
defmodule FirestormWeb.Repo.Migrations.AddPasswordHashToForumsUsers do
  use Ecto.Migration

  def change do
    alter table(:forums_users) do
      add :password_hash, :string
    end
  end
end
mix ecto.migrate

Next, we'll update our User schema to add this field as well as a virtual field for entering the password when registering initially:

vim lib/firestorm_web/forums/user.ex
defmodule FirestormWeb.Forums.User do
  # ...
  schema "forums_users" do
    # ...
    field :password_hash, :string
    field :password, :string, virtual: true
    # ...
  end
end

We'll also add a changeset explicitly meant for modifying a user's password at registration.

vim lib/firestorm_web/forums/forums.ex
defmodule FirestormWeb.Forums do
  # ...
  def register_user(attrs \\ %{}) do
    %User{}
    |> user_registration_changeset(attrs)
    |> Repo.insert()
  end

  def user_registration_changeset(%User{} = user, attrs) do
    user
    |> user_changeset(attrs)
    |> cast(attrs, [: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}} ->
        changeset
        |> put_change(:password_hash, Comeonin.Bcrypt.hashpwsalt(pass))
      _ ->
        changeset
    end
  end

  # We'll also stop requiring emails for users - we'll have to let them update
  # them later
  defp user_changeset(%User{} = user, attrs) do
    user
    |> cast(attrs, [:username, :email, :name])
    |> validate_required([:username, :name])
    |> unique_constraint(:username)
  end
  # ...
end

This should be enough to hash the password and store it. We'll move on from here to bring in ueberauth_identity, because we're going to handle registration a little unusually.

ueberauth_identity

We've already installed the package. We need to configure this strategy for ueberauth:

vim config/config.exs
# ...
# Configures Ueberauth for OAuth stuff
config :ueberauth, Ueberauth,
  providers: [
    github: {Ueberauth.Strategy.Github, [default_scope: ""]},
    identity: {Ueberauth.Strategy.Identity, [
      callback_methods: ["POST"]
    ]},
  ]
# ...

Here we've told ueberauth that for this strategy, the callback should be a POST rather than the default GET. This way the form that we'll generate in our "request" phase for this provider will be posted back as the response.

Next, let's create a form to show in the request phase for our identity provider:

vim lib/firestorm_web/web/controllers/auth_controller.ex
defmodule FirestormWeb.Web.AuthController do
  # ...
  alias Ueberauth.Strategy.Helpers
  alias FirestormWeb.Forums.User

  def request(conn, %{"provider" => "identity"}) do
    # We'll create a registration changeset and use Ueberauth helpers to provide
    # a callback url for our form to submit to
    changeset = Forums.user_registration_changeset(%User{}, %{})
    render(conn, "request.html", callback_url: Helpers.callback_url(conn), changeset: changeset)
  end
  # ...
end

We need to make the AuthView and the corresponding templates directory:

mkdir lib/firestorm_web/web/templates/auth
vim lib/firestorm_web/web/views/auth_view.ex
defmodule FirestormWeb.Web.AuthView do
  use FirestormWeb.Web, :view
end

Next, we'll generate a form to be used for this authentication mechanism.

vim lib/firestorm_web/web/templates/auth/request.html.eex
<h2>Log In</h2>

<%= form_for @changeset, @callback_url, [class: "pure-form"], fn f -> %>
  <div>
    <label>
      Username: <%= text_input f, :username %>
    </label>
    <%= error_tag f, :username %>
  </div>
  <div>
    <label>
      Password: <%= password_input f, :password %>
    </label>
    <%= error_tag f, :password %>
  </div>
  <%= submit "Log in or register", class: "pure-button button-primary" %>
<% end %>

If a user already exists in our system, we'll confirm the password. Otherwise, we'll create the user and log them in. Let's add a link to the side drawer to let us log in with either github or via the identity provider:

vim lib/firestorm_web/web/templates/layout/_drawer.html.eex
<ul>
  <%= if current_user(@conn) do %>
    <li><%= link "Log Out #{current_user(@conn).username}", to: auth_path(@conn, :delete) %></li>
  <% else %>
    <li><%= link "Log In (github)", to: auth_path(@conn, :request, :github) %></li>
    <li><%= link "Log In (username)", to: auth_path(@conn, :request, :identity) %></li>
  <% end %>
</ul>

Now if we go to the browser and click the username login link, we should see our form. If we fill it out with a new username and submit it, we get an error because we haven't handled the callback in our AuthController for the identity provider. Let's do that:

vim lib/firestorm_web/web/controllers/auth_controller.ex
defmodule FirestormWeb.Web.AuthController do
  # ...
  def callback(%{assigns: %{ueberauth_auth: auth}} = conn, params) do
    case auth.provider do
      :github ->
        with %{name: name, nickname: nickname, email: email} <- auth.info do
          case Forums.login_or_register_from_github(%{name: name, nickname: nickname, email: email}) do
            {:ok, user} ->
              conn
              |> ok_login(user)

            {:error, reason} ->
              conn
              |> put_flash(:error, reason)
              |> redirect(to: "/")
          end
        end
      :identity ->
        case Forums.login_or_register_from_identity(%{username: params["user"]["username"], password: params["user"]["password"]}) do
          {:ok, user} ->
            conn
            |> ok_login(user)

          {:error, %Ecto.Changeset{} = changeset} ->
            conn
            |> put_flash(:error, "Login unsuccessful")
            |> render("request.html", callback_url: Helpers.callback_url(conn), changeset: changeset)

          {:error, reason} ->
            changeset = Forums.user_registration_changeset(%User{}, %{})

            conn
            |> put_flash(:error, reason)
            |> render("request.html", callback_url: Helpers.callback_url(conn), changeset: changeset)
        end
    end
  end

  defp ok_login(conn, user) do
    conn
    |> put_flash(:info, "Successfully authenticated.")
    |> put_session(:current_user, user.id)
    |> redirect(to: "/")
  end
end

Now we just need to implement Forums.login_or_register_from_identity:

vim lib/firestorm_web/forums/forums.ex
defmodule FirestormWeb.Forums do
  # ...
  def login_or_register_from_identity(%{username: username, password: password}) do
    import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]

    case get_user_by_username(username) do
      nil ->
        # No user, let's register one!
        register_user(%{username: username, name: username, password: password})
      user ->
        # We'll check the password with checkpw against the user's stored
        # password hash
        case checkpw(password, user.password_hash) do
          true ->
            # Everything checks out, success
            {:ok, user}
          _ ->
            # User existed, we checked the password, but no dice
            {:error, "No user found with that username or password"}
        end
    end
  end
  # ...
end

We can try it out in the browser, and everything should work!

Summary

In today's episode, we introduced comeonin and ueberauth_identity. Now, we can log in with a username and password, and if no user exists with that username we'll create one automatically. This might not be what we want to do, but it's easy enough to change. For now, it works. I hope you enjoyed it. See you soon!

Resources