Sep 9, 2021

Restricting registrations when using Phx.Gen.Auth - Part 1

Phx.Gen.Auth is a great authentication solution when building out Phoenix applications. I’ve previously gone over some options for customizing the functionality and workflow around Phx.Gen.Auth. Today we’ll look at how we can restrict user registrations via a registration token.

Updated 2021-11-04:
A previous version of this post used a UUID for the registration token. A reader pointed out this opens the registration tokens up to timing attacks. Essentially someone can systematically test the possible UUID values, track the resulting response times of the application and in this way discover registration token values. I have to admit I hadn’t considered timing attacks when deciding to go with UUIDs. I hadn’t really thought of the registration tokens in the same terms as say a password reset token. But really, this is faulty thinking, depending on the business logic of your application a registration token could be just as important as a password reset or confirmation token.

For those interested in some further details around timing attacks and UUIDs, I found the following articles very interesting:

I think the security considerations section of the UUID specification sums things up pretty succinctly: “Do not assume that UUIDs are hard to guess; they should not be used as security capabilities”.

So instead of a UUID, we’ll use the same technique as Phx.Gen.Auth. This means storing a hashed version of the registration token in the database and supplying a url encoded, non-hashed version of the token to the user. When the user provides the non-hashed token back to our application we hash the value and compare it with the hashed token in the database to determine whether it is valid.

Okay… on to our regularily scheduled programming… we’ll create a fairly flexible solution:

  • Registration will require a registration token, which will be included in the URL for the registration page.
  • The token itself will come in a few different flavors:
    • The token can be generic and anyone who has the token / URL can register with it.
    • The token can be scoped to an email address, in which case it will only work when registering with the scoped email address.
    • An optional generated_by field is available which associates a token to an existing user. This could be useful if we want to allow existing users to generate tokens and invite other users to the application. Having the generated_by field provides a way to trace back thru the invites to see who has invited a particular user or group of users.
  • We’ll also create a version where we store the url encoded, non-hashed token string in the database and a version where we don’t store the token string in the database. The advantage of storing the token string is we have a bit more flexibility in terms of how we distribute and manage the tokens; they don’t need to be distributed at generation time. The disadvantage is we now have plain text token values in our DB, if someone gains read access to the DB they can use the token strings to register.

Note: I’m not a fan of “flexible” solutions unless there is a use case to support the flexibility. Adding extra scenarios because “you might need them” is a great way to add extra code, effort and bloat. If your requirements are more locked down I’d suggest a targeted approach. For instance if I only needed email scoped tokens, I would not implement non-email scoped tokens.

Anyway, enough preamble, let’s get down to some code!

Getting started

First off let’s create a new Phoenix project. I’m using the latest and greatest version of Phoenix, Phoenix 1.6. If you are on an older version of Phoenix, you’ll need to install Phx.Gen.Auth as a dependency via your mix file as it was only merged into Phoenix as of version 1.6. There will also be some other minor differences you’ll encounter especially around templates as 1.6 introduced heex templates.

Terminal
mix phx.new reg_tokens

Note: add a --live flag to the above if using a Phoenix version older than 1.6.

Select yes when asked to install dependencies.

Now we can change into the directory where the application was created and build our database.

Terminal
cd reg_tokens
Terminal
mix do deps.get, ecto.create, ecto.migrate

Next we’ll generate the authentication code.

Terminal
mix phx.gen.auth Accounts User users

We’ll follow the instructions from the mix output:

Terminal
mix do deps.get, ecto.migrate

Finally let’s ensure all our tests are running out of the gate.

Terminal
mix test

Great! Everything is looking good, we’re ready to make our changes to the registration workflow. We’ll start off with the backend changes.

Updating the backend to restrict registrations

Starting with the data layer

Since we’ll be requiring a token in order to register, we need somewhere to store registration tokens. There is already a user_tokens table that gets created as part of phx.gen.auth.

We could look to re-use and augment this table to also work with registration tokens. I think it’s going to make more sense and be easier to create a specific table for the registration tokens however… so we’ll need a migration for that!

Terminal
mix ecto.gen.migration create_registration_tokens

The contents of the migration will be as follows.

/priv/repo/migrations/time_stamp_create_registration_tokens.exs
defmodule RegTokens.Repo.Migrations.CreateRegistrationTokens do
  use Ecto.Migration

  def change do
    create table(:registration_tokens) do
      add :token, :binary, null: false
      add :token_string, :string, null: false
      add :used_by_user_id, references(:users, on_delete: :delete_all)
      add :scoped_to_email, :string
      add :generated_by_user_id, references(:users, on_delete: :delete_all)

      timestamps()
    end

    create unique_index(:registration_tokens, [:used_by_user_id])
  end
end

Pretty straight forward. Just like the user_tokens table we use a binary column for the token. We’ll populate this column with a hashed version of the registration token. We’ve also added a token_string field which we’ll use to hold a base 64 encoded version of the non-hashed token. This is the representation of the token that will be provided to the user in order for them to register. Depending on how you supply tokens to your users this column might not be necessary. For instance if you send an email or SMS message with a registration link you might not need to store this column… however it does mean if the user misplaces the email or text, a new token would need to be generated for them; the token_string value can’t be regenerated from the token field.

As mentionned earlier, the advantage of storing the token_string is we have a bit more flexibility in terms of how we distribute and manage the tokens. The disadvantage is we now have plain text token string values in our DB, if someone gains read access to the DB they can use the token strings to register. We’ll look at how to alter the implementation to not store the token strings in part 2.

In addition to the above two fields, we’ve created a used_by_user_id column and a generated_by_user_id column which have references back to the user table. These will contain references to the user who generated the token (if applicable) and the user who registered with the token. Then we have a scoped_to_email column for the scenario where we want to restrict the token to a particular email address. Everything is nullable other than the token and token string columns.

Finally we have a unique index on the used_by_user_id column as a user’s registration should only ever be associated with a single token.

Let’s run the migration.

Terminal
mix ecto.migrate

And now we can create the schema file.

Terminal
touch lib/reg_tokens/accounts/registration_token.ex
/lib/reg_tokens/accounts/registration_token.ex
defmodule RegTokens.Accounts.RegistrationToken do
  use Ecto.Schema

  alias RegTokens.Accounts.User

  schema "registration_tokens" do
    field :token, :binary
    field :token_string, :string
    field :scoped_to_email, :string
    belongs_to :used_by_user, User, foreign_key: :used_by_user_id
    belongs_to :generated_by_user, User, foreign_key: :generated_by_user_id

    timestamps()
  end
end

Simple, we’re just representing the database table as an Ecto Schema.

That’s it for the data layer… let’s start building out the context functions.

The context functions

We need two things out of our context; the ability to generate registration tokens and the ability to register a user with a token. Since the second of these two options requires a token, let’s start with figuring out how to generate that token.

Token generation

This is pretty easy. We’ll add a new alias and function to the existing accounts.ex context module.

/lib/reg_tokens/accounts.ex
alias RegTokens.Accounts.{RegistrationToken, User, UserToken, UserNotifier}

We’ve added the RegistrationToken schema to our list of aliases… and now for the generation function:

/lib/reg_tokens/accounts.ex
def generate_registration_token(opts \\ []) do
  {token_string, hashed_token} = RegistrationToken.build_hashed_token()
  %{
    token: hashed_token,
    token_string: token_string,
    generated_by_user_id: Keyword.get(opts, :generated_by),
    scoped_to_email: Keyword.get(opts, :scoped_to_email)
  }
  |> RegistrationToken.create_changeset()
  |> Repo.insert()
end

Simple! We’re passing in our optional values (the generated_by and scoped_to_email fields) as a keyword list. This works great as Keyword.get returns nil when the key is missing from the keyword list… which is exactly what we want to insert when these fields are not supplied.

We generate the token via a RegistrationToken.build_hashed_token function we’ll need to implement. It returns both the url encoded non hashed string version of the token and the hashed token.

We create a map from the tokens and the opts parameters:

%{
  token: hashed_token,
  token_string: token_string,
  generated_by_user_id: Keyword.get(opts, :generated_by),
  scoped_to_email: Keyword.get(opts, :scoped_to_email)
}

… which we pipe into a changeset function (RegistrationToken.create_changeset), the result of which gets piped into Repo.insert.

|> RegistrationToken.create_changeset()
|> Repo.insert()

All standard stuff, we need to add the build_hashed_token and create_changeset functions referred to above in registration_token.ex.

/lib/reg_tokens/accounts/registration_token.ex
defmodule RegTokens.Accounts.RegistrationToken do
  use Ecto.Schema
  import Ecto.Changeset

  alias RegTokens.Accounts.User

  schema "registration_tokens" do
    field :token, :binary
    field :token_string, :string
    field :scoped_to_email, :string
    belongs_to :used_by_user, User, foreign_key: :used_by_user_id
    belongs_to :generated_by_user, User, foreign_key: :generated_by_user_id

    timestamps()
  end

  @hash_algorithm :sha256
  @rand_size 32
  def build_hashed_token() do
    token_string = :crypto.strong_rand_bytes(@rand_size)
    hashed_token = :crypto.hash(@hash_algorithm, token_string)

    {Base.url_encode64(token_string, padding: false), hashed_token}
  end

  def create_changeset(attrs) do
    %__MODULE__{}
    |> cast(attrs, [:token, :token_string, :scoped_to_email, :generated_by_user_id])
    |> foreign_key_constraint(:generated_by_user_id)
  end
end

We’ve added a new import for Ecto.Changeset. The build_hashed_token function doesn’t have too much going on:

def build_hashed_token() do
  token_string = :crypto.strong_rand_bytes(@rand_size)
  hashed_token = :crypto.hash(@hash_algorithm, token_string)

  {Base.url_encode64(token_string, padding: false), hashed_token}
end

Essentially the same as the build_hashed_token function in user_tokens.ex… there is some duplication we could look to refactor out between these 2 functions, but I’m not sure it’s worth it in this particular case.

As far as the changeset function goes…

def create_changeset(attrs) do
  %__MODULE__{}
  |> cast(attrs, [:token, :token_string, :scoped_to_email, :generated_by_user_id])
  |> foreign_key_constraint(:generated_by_user_id)
end

We cast the four fields we expect in the attrs map and then add a foreign key check for the generated_by_user_id field as we want to return an error if the user id for the generating user does not exist.

Let’s give it a spin, we’ll fire up iex.

Terminal
iex -S mix phx.server

… and generate some tokens.

Terminal
RegTokens.Accounts.generate_registration_token()

Terminal
RegTokens.Accounts.generate_registration_token(scoped_to_email: "sally@example.com")

If we want to create a token with a generated_by value, we’ll first need to create a user.

Terminal
RegTokens.Accounts.register_user(%{email: "bob@example.com", password: "foobarfoobar", password_confirmation: "foobarfoobar"})

Then we can create a token with a generated_by value set to the id of the above user.

Terminal
RegTokens.Accounts.generate_registration_token(generated_by: 1, scoped_to_email: "jill@example.com")

Great, everything seems to be working and if we look at our data with something like TablePlus we’ll see the 3 tokens we’ve created.

iex is a super convenient way to test drive functionality while building things out but we’ll definitely want to write some tests… let’s tackle that next.

Token generation tests

Just like the implementation, the tests will be pretty straight forward.

First thing, we’ll need a new alias in accounts_test.exs.

/test/reg_tokens/accounts_test.exs
alias RegTokens.Accounts.{RegistrationToken, User, UserToken}

Now we’ll create a new describe block for the token generation tests.

/test/reg_tokens/accounts_test.exs
describe "generate_registration_token/1" do
  test "generates a valid non email scoped registration token" do
    assert {:ok, %RegistrationToken{} = token} = Accounts.generate_registration_token()
    refute token.scoped_to_email
    refute token.used_by_user_id
    refute token.generated_by_user_id
    assert token.token
    assert token.token_string
  end

  test "generates a valid email scoped registration token" do
    assert {:ok, %RegistrationToken{} = token} =
             Accounts.generate_registration_token(scoped_to_email: "sally@example.com")

    assert token.scoped_to_email == "sally@example.com"
    refute token.used_by_user_id
    refute token.generated_by_user_id
    assert token.token
    assert token.token_string
  end

  test "can assign a valid user to the generated_by field of a token" do
    %{id: id} = user_fixture()

    assert {:ok, %RegistrationToken{} = token} =
             Accounts.generate_registration_token(generated_by: id)

    refute token.scoped_to_email
    refute token.used_by_user_id
    assert token.generated_by_user_id == id
    assert token.token
    assert token.token_string
  end

  test "validates generated_by field when supplied" do
    assert {:error, changeset} = Accounts.generate_registration_token(generated_by: 123_456)

    assert %{generated_by_user_id: ["does not exist"]} == errors_on(changeset)
  end
end

Pretty self-explanatory, we’re testing the various token options and ensuring the token is created with the expected values… and we have a test to make sure the generated_by foreign key constraint is acting as expected.

Let’s make sure everything is passing.

Terminal
mix test

Nice!

Onto the registration function.

User registration

We have the token generation part of our registration workflow done and dusted, now we need to register a user with a token. We’ll create a register_user_with_token/2 function in accounts.ex. We’ll build this function up bit by bit, starting with the top level function and filling in the supporting pieces as we proceed thru the explanation.

/lib/reg_tokens/accounts.ex
def register_user_with_token(token_string, attrs) do
  token_string
  |> maybe_get_registration_token()
  |> maybe_register_user_with_token(attrs)
end

The function above maps out the basic workflow of the registration functionality. We retrieve the registration token and pipe the result of the retrieval into maybe_register_user_with_token; which is the function that performs the actual registration.

Let’s implement the token retrieval:

/lib/reg_tokens/accounts.ex
@hash_algorithm :sha256
defp maybe_get_registration_token(token) when is_binary(token) do
  Base.url_decode64(token, padding: false)
  |> case do
    {:ok, decoded_token} ->
      hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
      RegistrationToken.get_registration_token_query(hashed_token)
      |> Repo.one()
   :error -> nil
 end
end

defp maybe_get_registration_token(_), do: nil

We’re decoding and hashing the token which we then use when building the retrieval query (get_registration_token_query). In the case where the decode fails or the token isn’t a string (i.e. the when is_binary(token) clause fails) we return nil.

The query function in registration_token.ex looks like :

/lib/reg_tokens/accounts/registration_token.ex
@token_validity_in_days 30

def get_registration_token_query(token) do
  from(rt in __MODULE__,
    where: rt.token == ^token,
    where: rt.inserted_at > ago(@token_validity_in_days, "day"),
    where: is_nil(rt.used_by_user_id)
  )
end

This is pretty simple, we are querying for a token that matches the hashed token passed to the function. Then we check the token has not expired and hasn’t been used. I’ve settled on an expiry of 30 days but depending on your use case you might want to tighten up the expiry period.

We also need to add a Ecto.Query import at the top of the file.

/lib/reg_tokens/accounts/registration_token.ex
import Ecto.Query

So that takes care of token retrieval, the only thing left is maybe_register_user_with_token/2. We’ll use some pattern matching to handle the various scenarios that can arise when piping the retrieved token into maybe_register_user_with_token. Our function signatures will look like:

# pattern match on a nil token value
# i.e. a valid token has not been retrieved
maybe_register_user_with_token(nil, attrs)

# pattern match on a token with a nil scoped_to_email value
# i.e. we retrieved a non email scoped token
maybe_register_user_with_token(%RegistrationToken{scoped_to_email: nil} = token, attrs)

# the fall thru case, if neither of the above match we know we retrieved an email scoped token
maybe_register_user_with_token(token, attrs)

Let’s get to the implementation, we’ll tackle the above cases one by one.

/lib/reg_tokens/accounts.ex
# token was not found
defp maybe_register_user_with_token(nil, attrs) do
  invalid_token_response(attrs)
end

The first case is simple. If we don’t retrieve a token from get_registration_token we know either the token doesn’t exist, is expired, or has already been used. We pattern match on nil as the token’s value and send back an error changeset via a new function we’ll create, invalid_token_response.

/lib/reg_tokens/accounts.ex
defp invalid_token_response(attrs) do
  %User{}
  |> User.registration_changeset(attrs)
  |> Ecto.Changeset.add_error(:registration_token, "Invalid registration token!")
  |> Ecto.Changeset.apply_action(:update)
end

I’ve chosen to return a generic invalid token message as part of the changeset, but depending on your front end requirements you might want to return specific messages around specific failures or even return specific error tuples. For instance instead of returning a changeset, you could return something like {:error, :expired_token}. This would be useful if you want to provide more detailed information around what went wrong to the caller of the registration function.

The next case is a non-email scoped token.

/lib/reg_tokens/accounts.ex
# non-email scoped token
defp maybe_register_user_with_token(%RegistrationToken{scoped_to_email: nil} = token, attrs) do
  Repo.transaction(fn ->
    with {:ok, user} <- register_user(attrs),
         {:ok, _registration_token} <- consume_registration_token(token, user) do
      user
    else
      {:error, error} ->
        Repo.rollback(error)
    end
  end)
end

Again we use pattern matching to determine that we have retrieved a token… but it is a non-email scoped token (i.e. via %RegistrationToken{scoped_to_email: nil}). We then use a transaction to register the user and consume the token. If for some reason either of these operations fail we perform a rollback. register_user/1 already exists, we’re just re-using the existing registration function from Phx.Gen.Auth, we need to write consume_registration_token however.

/lib/reg_tokens/accounts.ex
defp consume_registration_token(token, user) do
  RegistrationToken.consume_token_changeset(token, user.id)
  |> Repo.update()
end

Simple! Just a repo update with some help from a new changeset function we’ll create in registration_token.ex.

/lib/reg_tokens/accounts/registration_token.ex
def consume_token_changeset(token, user_id) do
  token
  |> cast(%{used_by_user_id: user_id}, [:used_by_user_id])
  |> unique_constraint(:used_by_user_id)
end

Nothing complicated, we set the used_by_user_id value to the id of the created user and perform a unique constraint check.

That’s it for non email scoped tokens, the final case to handle is tokens scoped to an email.

/lib/reg_tokens/accounts.ex
# email scoped token
defp maybe_register_user_with_token(token, attrs) do
  if token.scoped_to_email == (attrs["email"] || attrs[:email]) do
    Repo.transaction(fn ->
      with {:ok, user} <- register_user(attrs),
           {:ok, _registration_token} <- consume_registration_token(token, user) do
        user
      else
        {:error, error} ->
          Repo.rollback(error)
      end
    end)

  # wrong email scope
  else
    invalid_token_response(attrs)
  end
end

Very similar to the non-email scoped version of the function. The only difference being we have a surronding if statement that ensures the registering user’s email is the same as the email in the token. The Repo.transaction blocks are the same in both places, I thought about creating another sub function to remove the duplication but felt it read better having the transaction blocks inline.

Trying it out

Let’s give our code a spin in iex.

Terminal
iex -S mix phx.server

First we’ll try an invalid token string.

Terminal
RegTokens.Accounts.register_user_with_token("1234", %{email: "bob7456@example.com", password: "foobarfoobar", password_confirmation: "foobarfoobar"})

The output looks as expected.

Next let’s try a valid token. First we’ll create a token.

Terminal
RegTokens.Accounts.generate_registration_token(scoped_to_email: "bob7456@example.com")

And then register.

Terminal
RegTokens.Accounts.register_user_with_token("XVwL6GXXif2T59Ni-iqhRM34Ns75VigGxL1tl7sfDAE", %{email: "bob7456@example.com", password: "foobarfoobar", password_confirmation: "foobarfoobar"})

Sweet! Looks like things are working, feel free to run thru some other scenarios with iex such as mis-matching email addresses… all of which we’ll be tackling in our tests.

Adding some tests

Test time! The first thing we’re going to do is update the existing register_user/1 tests to run thru register_user_with_token/2. When we get around to the front-end changes we’ll make register_user a private function. In anticipation of that and to ensure we’re covering off the existing functionality around registration, we want to update the existing tests to run thru our new registration function.

To accomplish this, we need to:

  • Add a setup block.
  • Update the test signatures.
  • Replace the register_user function calls with register_user_with_token.
  • Update the title of the describe block, replacing describe register_user/1 with describe register_user_with_token/2.

Finally we won’t bother testing the happy path scenario as we’ll do so in subsequent tests.

To achieve the above, replace the current register_user/1 describe block with the following:

/test/reg_tokens/accounts_test.exs
describe "register_user_with_token/2" do
  setup do
    [
      token: registration_token_fixture().token_string
    ]
  end

  test "requires email and password to be set", %{token: token} do
    {:error, changeset} = Accounts.register_user_with_token(token, %{})

    assert %{
             password: ["can't be blank"],
             email: ["can't be blank"]
           } = errors_on(changeset)
  end

  test "validates email and password when given", %{token: token} do
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: "not valid", password: "not valid"})

    assert %{
             email: ["must have the @ sign and no spaces"],
             password: ["should be at least 12 character(s)"]
           } = errors_on(changeset)
  end

  test "validates maximum values for email and password for security", %{token: token} do
    too_long = String.duplicate("db", 100)
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: too_long, password: too_long})
    assert "should be at most 160 character(s)" in errors_on(changeset).email
    assert "should be at most 72 character(s)" in errors_on(changeset).password
  end

  test "validates email uniqueness", %{token: token} do
    %{email: email} = user_fixture()
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: email})
    assert "has already been taken" in errors_on(changeset).email

    # Now try with the upper cased email too, to check that email case is ignored.
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: String.upcase(email)})
    assert "has already been taken" in errors_on(changeset).email
  end

  # we'll test the happy path scenario in subsequent tests so we can remove this
  # test "registers users with a hashed password" do
  #   email = unique_user_email()
  #   {:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
  #   assert user.email == email
  #   assert is_binary(user.hashed_password)
  #   assert is_nil(user.confirmed_at)
  #   assert is_nil(user.password)
  # end
end

We’re making use of a new fixture function during the test setup (registration_token_fixture), so we need to add this to accounts_fixtures.ex.

/test/support/fixtures/accounts_fixtures.ex
def registration_token_fixture(email \\ nil) do
  {:ok, token} = RegTokens.Accounts.generate_registration_token(scoped_to_email: email)
  token
end

Let’s make sure the account tests are still passing.

Terminal
mix test test/reg_tokens/accounts_test.exs

Nice! Next we’ll add some tests for non-email scoped tokens.

/test/reg_tokens/accounts_test.exs
describe "register_user_with_token/2 - non email scoped token" do
  setup do
    [
      token: registration_token_fixture()
    ]
  end

  test "when valid attributes and token, registers user with hashed password and consumes token",
       %{token: token} do
    email = unique_user_email()

    {:ok, user} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: email))

    assert user.email == email
    assert is_binary(user.hashed_password)
    assert is_nil(user.confirmed_at)
    assert is_nil(user.password)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == user.id
  end

  test "with badly formatted token returns error", %{token: token} do
    email = unique_user_email()

    {:error, changeset} =
      Accounts.register_user_with_token(
        1234,
        valid_user_attributes(email: email)
      )

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has expired returns error", %{token: token} do
    {1, nil} = Repo.update_all(RegistrationToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
    email = unique_user_email()

    {:error, changeset} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has already been used returns error", %{token: token} do
    %{id: id} = user_fixture()
    {1, nil} = Repo.update_all(RegistrationToken, set: [used_by_user_id: id])

    email = unique_user_email()

    {:error, changeset} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == id
  end
end

I think these are all pretty self-explanatory. The first test validates the happy path, the remaining tests check the failure scenarios: a token which is not a string; a token which has expired; and a token which has already been used.

Let’s try ‘em out.

Terminal
mix test test/reg_tokens/accounts_test.exs

Fantastic! Now let’s create a similar describe block for email scoped tokens.

/test/reg_tokens/accounts_test.exs
describe "register_user_with_token/2 - email scoped token" do
  setup do
    email = unique_user_email()

    [
      token: registration_token_fixture(email),
      email: email
    ]
  end

  test "when valid attributes and token registers user with hashed password and consumes token",
       %{token: token, email: email} do
    {:ok, user} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: email))

    assert user.email == email
    assert is_binary(user.hashed_password)
    assert is_nil(user.confirmed_at)
    assert is_nil(user.password)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == user.id
  end

  test "with badly formatted token returns error", %{token: token, email: email} do
    {:error, changeset} =
      Accounts.register_user_with_token(
        1234,
        valid_user_attributes(email: email)
      )

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has expired returns error", %{token: token, email: email} do
    {1, nil} = Repo.update_all(RegistrationToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])

    {:error, changeset} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has already been used returns error", %{token: token, email: email} do
    %{id: id} = user_fixture()
    {1, nil} = Repo.update_all(RegistrationToken, set: [used_by_user_id: id])

    {:error, changeset} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == id
  end

  test "when given an incorrect email returns error", %{token: token, email: email} do
    {:error, changeset} =
      Accounts.register_user_with_token(token.token_string, valid_user_attributes(email: "#{email}a"))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end
end

This is largely the same as the previous describe block, this time we’ve just added a test for the scenario where the registering email address does not match the email address the token is scoped to.

Let’s run thru the full test suite.

Terminal
mix test

Awesome, all good! This closes out the backend changes required for the new registration workflow.

Summary

I was hoping to get everything done in a single go but this post is starting to get long… so we’ll handle the front-end changes in an upcoming post.

Todays code is available on GitHub, you can grab it via:

Terminal
mkdir <some new directory>
cd <the new directory>
Terminal
git init
git remote add origin git@github.com:riebeekn/phx_gen_auth_with_registration_tokens.git
git fetch origin 244b2d9f624aed6d5634fbaf66658ca12a918a8c
git reset --hard FETCH_HEAD

You should now see the following git history.

Terminal
git log --pretty=oneline

Thanks for reading and I hope you enjoyed today’s post!



Comment on this post!