Migrating from pow to phx.gen.auth
- François
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 namedconfirmed_at
- the column
password_hash
is nowhashed_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.