Migrating from pow to phx.gen.auth

For years I have been using Pow on the indoorequal.com website. While I have been happy so far, the not-so-new kid in the block, phx.gen.auth has been around for some times. I thought it was a good time to migrate to it.

indoorequal.com is a small app with only a handful of authenticated endpoints and an API to check the keys. Besides register and login workflow, the reset password and confirmation email extensions are enabled. There is also a role system that is used only for administration purposes.

My goal was to switch the authentication system without breaking anything from a user point of view while keeping the same features.

Login with previous accounts

After generating the initial boilerplate I saw that phx.gen.auth use different column names than pow

  • the column email_confirmed_at is named confirmed_at
  • the column password_hash is now hashed_password

So I renamed I updated the code to use email_confirmed_at instead of confirmed_at (enabling me to go back to the master branch without resetting my database).

For the password, my initial thought was to use the same hashing library than Pow, namely pbkdf2. So I renamed hashed_password to password_hash in the code. But soon after trying to login with an account created with pow, I got the following error: (FunctionClauseError) no function clause matching in Pbkdf2.Base64.dec64/1.

The library pbkdf2_elixir used by phx.gen.auth and the pbkdf2 implementation by Pow have some incompatibilies. pbkdf2_elixir encode the different parts of the string using a restricted version of base64 (no padding, no point). My solution was to migrate the previous password to the new hashing algorithm on first login.

The initial migration was also updated to not create the users table (commented out for future references).

defmodule Indoorequalcom.Repo.Migrations.CreateUsersAuthTables do
  use Ecto.Migration

  def change do
    execute "CREATE EXTENSION IF NOT EXISTS citext", ""

    #create table(:users) do
    #  add :email, :citext, null: false
    #  add :hashed_password, :string, null: false
    #  add :confirmed_at, :naive_datetime
    #  timestamps()
    #end

    #create unique_index(:users, [:email])

    create table(:users_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false
      add :context, :string, null: false
      add :sent_to, :string
      timestamps(updated_at: false)
    end

    create index(:users_tokens, [:user_id])
    create unique_index(:users_tokens, [:context, :token])
  end
end

I created a new migration for the email column that has to be changed to citext, and the new column to the store the password:

defmodule Indoorequalcom.Repo.Migrations.UpdateUsersEmail do
  use Ecto.Migration

  def change do
    alter table(:users) do
      modify :email, :citext, from: :string
      add :hashed_password, :string
    end
  end
end

It’s now time to try the login with a previous user account. I created a test case like that in accounts_test.ex:

defmodule Indoorequalcom.AccountsTest do
  ...
  describe "get_user_by_email_and_password/2" do
    ...
    test "returns the user if the password has been generated by pow" do
      %{id: id} = user = user_fixture()
      user
      |> Ecto.Changeset.change(%{password_hash: Pow.Ecto.Schema.Password.pbkdf2_hash(valid_user_password())})
      |> Indoorequalcom.Repo.update()

      assert %User{id: ^id} =
        Accounts.get_user_by_email_and_password(user.email, valid_user_password())
    end
    ...
  end
  ...
end

The implementation in accounts.ex.

defmodule Indoorequalcom.Accounts do
  ...
  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    user = Repo.get_by(User, email: email)
    case User.has_password_from_pow?(user) do
      true ->
        if User.valid_password_from_pow?(user, password) do
          user
          |> User.password_from_pow_changeset(%{password: password})
          |> Repo.update!()
        end
      false ->
        if User.valid_password?(user, password), do: user
    end
  end
end

And the functions in user.ex:

defmodule Indoorequalcom.Accounts.User do
  ...
  @doc """
  A user changeset for changing the password from pow one to phx.gen.auth
  ## Options

    * `:hash_password` - Hashes the password so it can be stored securely
      in the database and ensures the password field is cleared to prevent
      leaks in the logs. If password hashing is not needed and clearing the
      password field is not desired (like when using this changeset for
      validations on a LiveView form), this option can be set to `false`.
      Defaults to `true`.
  """
  def password_from_pow_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> put_change(:password_hash, nil)
    |> maybe_hash_password(opts)
  end

  @doc """
  Check is there is a password previously set from pow
  """
  def has_password_from_pow?(%{password_hash: password_hash})
    when is_binary(password_hash) do
    true
  end

  def has_password_from_pow?(_) do
    false
  end

  @doc """
  Verify the password from pow
  """
  def valid_password_from_pow?(%Indoorequalcom.Accounts.User{password_hash: password_hash}, password) do
    Pow.Ecto.Schema.Password.pbkdf2_verify(password, password_hash)
  end
end

The solution was to look for the content of the password_hash column. If the column is non empty, then it means that the account was created via pow. The password is checked against Pow pbkdf2 implementation and if it matches, the password is hased with argon2 and stored in the column hashed_password and the previous column is resetted.

Check user email before login

To reproduce the Pow’ user email confirmation, there is 2 places to update:

  • on registration to not login the user
  • on login to check that the user is confirmed

After registration, update the file user_registration_controller.ex.

defmodule IndoorequalcomWeb.UserRegistrationController do
  ...
  def create(conn, %{"user" => user_params}) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        {:ok, _} =
          Accounts.deliver_user_confirmation_instructions(
            user,
            &url(~p"/users/confirm/#{&1}")
          )

        conn
        |> put_flash(:info, "You'll need to confirm your e-mail before you can sign in. An e-mail confirmation link has been sent to you.")
        |> redirect(to: ~p"/")

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

On login, the code involved an update to accounts.ex

defmodule Indoorequalcom.Accounts do
  ...
  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    case get_valid_user_password(email, password) do
      nil -> {:error, :invalid}
      user ->
        case user.email_confirmed_at do
          nil -> {:error, :email_not_confirmed, user}
         _ -> user
        end
    end
  end

  defp get_valid_user_password(email, password) do
    user = Repo.get_by(User, email: email)
    case User.has_password_from_pow?(user) do
      true ->
        if User.valid_password_from_pow?(user, password) do
          user
          |> User.password_from_pow_changeset(%{password: password})
          |> Repo.update!()
        end
      false ->
        if User.valid_password?(user, password), do: user
    end
  end
end

And in the session controller to catch the error to display a specific message and (re)sending the email.

defmodule IndoorequalcomWeb.UserSessionController do
  ...
  def create(conn, %{"user" => user_params}) do
    %{"email" => email, "password" => password} = user_params

    case Accounts.get_user_by_email_and_password(email, password) do
      {:error, :invalid} ->
        # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
        render(conn, :new, error_message: "Invalid email or password")
      {:error, :email_not_confirmed, user} ->
        Accounts.deliver_user_confirmation_instructions(
          user,
          &url(~p"/users/confirm/#{&1}")
        )
        render(conn, :new, error_message: "You'll need to confirm your e-mail before you can sign in. An e-mail confirmation link has been sent to you")
      user ->
        conn
        |> UserAuth.log_in_user(user, user_params)
    end
  end

Conclusion

Once I got the way to migrate from Pow with password, the process to migrate went smoothly. I only described the main points to look at, but there a lot of things to do before going live:

  • tests to update the new log_in workflow
  • style all forms
  • plug and customize emails

Have a comment? Contact me by email.