Sep 10, 2021

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

In our last post we implemented the backend changes required for our new locked down registration workflow. Today we’ll hook in the front end changes.

If you’ve followed along with part 1 you can continue with the code from there. Otherwise you can grab today’s starting off point by:

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 1c8ac756ada2c41758d621291ff22ca2854cd87c
git reset --hard FETCH_HEAD
git branch -m master main

You should now see the following git history.

Terminal
git log --pretty=oneline

And now you can install the existing dependencies, and setup the database.

Terminal
mix deps.get
Terminal
mix do ecto.drop, ecto.create, ecto.migrate

Updating the front end

This should go pretty quick, there isn’t too much we need to change to get the front end working.

The first thing we’ll do is update the router, specifically the registration routes.

/lib/reg_tokens_web/router.ex
scope "/", RegTokensWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  get "/users/register/:token", UserRegistrationController, :new
  post "/users/register/:token", UserRegistrationController, :create

All we’ve done is add the :token parameter to the end of the route, this is going to break a bunch of templates that are currently using the registration route. With our new registration workflow it doesn’t make much sense to have a direct link to the registration page so we’ll remove all the registration links from the heex templates.

/lib/reg_tokens_web/templates/layout/_user_menu.html.heex
# remove the below
<li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
/lib/reg_tokens_web/templates/user_confirmation/new.html.heex
# remove the below
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
/lib/reg_tokens_web/templates/user_confirmation/edit.html.heex
# remove the below
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
/lib/reg_tokens_web/templates/user_reset_password/edit.html.heex
# remove the below
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
/lib/reg_tokens_web/templates/user_reset_password/new.html.heex
# remove the below
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
/lib/reg_tokens_web/templates/user_session/new.html.heex
# remove the below
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |

Now that we’ve gotten rid of the old registration links we can update the actual registration form.

/lib/reg_tokens_web/templates/user_registration/new.html.heex
<h1>Register</h1>

<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create, @token)}>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
    <%= error_tag f, :registration_token %>
  <% end %>

  <%= label f, :email %>
  <%= email_input f, :email, required: true %>
  <%= error_tag f, :email %>

  <%= label f, :password %>
  <%= password_input f, :password, required: true %>
  <%= error_tag f, :password %>

  <div>
    <%= submit "Register" %>
  </div>
</.form>

<p>
  <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> |
  <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p>

The only changes we’ve made is to update the route in the form_for tag (just adding in a @token assign). We also added a new error tag (<%= error_tag f, :registration_token %>) so any token errors will be displayed.

The final piece of the puzzle is to update the controller.

/lib/reg_tokens_web/controllers/user_registration_controller.ex
defmodule RegTokensWeb.UserRegistrationController do
  use RegTokensWeb, :controller

  alias RegTokens.Accounts
  alias RegTokens.Accounts.User
  alias RegTokensWeb.UserAuth

  def new(conn, params) do
    changeset = Accounts.change_user_registration(%User{})
    render(conn, "new.html", changeset: changeset, token: params["token"])
  end

  def create(conn, %{"token" => token, "user" => user_params}) do
    case Accounts.register_user_with_token(token, user_params) do
      {:ok, user} ->
        {:ok, _} =
          Accounts.deliver_user_confirmation_instructions(
            user,
            &Routes.user_confirmation_url(conn, :edit, &1)
          )

        conn
        |> put_flash(:info, "User created successfully.")
        |> UserAuth.log_in_user(user)

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

Both the new and the create functions have changed. The new function pulls the registration token out of the params map and passes this along to the form via the token: params["token"] assignment. The create function calls into the new backend registration function (register_user_with_token) instead of register_user.

And that’s it for the front end changes!

Before giving it a go, we can update Accounts.register_user and make it a private function as it is no longer called outside of the Accounts context module.

/lib/reg_tokens/accounts.ex
defp register_user(attrs) do
...

Trying it out

Let’s give the full registration workflow a go. We’ll fire up the server.

Terminal
iex -S mix phx.server

And then create a registration token.

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

Note: you’d likely want to build some sort of admin interface for generating and managing tokens, but that’s outside the scope of this article so we’ll just stick to generating the tokens in iex.

Now with a valid token in hand we can navigate to the appropriate registration URL, in this case http://localhost:4000/users/register/a7f0d5ea-f005-49ba-83aa-a4e00f31c4c7.

Since we generated an email scoped token, signing up with a different email will fail.

But with the correct email, we’re all good.

Nice! We just have a few tests to deal with.

Updating some tests

We need to make a small change to the user_session_controller test. It has an assertion for the registration link and since we’ve removed that link we also need to remove the assertion from the test.

/test/auth_w_invite_web/controllers/user_session_controller_test.exs
describe "GET /users/log_in" do
  test "renders log in page", %{conn: conn} do
    conn = get(conn, Routes.user_session_path(conn, :new))
    response = html_response(conn, 200)
    assert response =~ "<h1>Log in</h1>"
    assert response =~ "Log in</a>"
    # remove the below
    # assert response =~ "Register</a>"
  end

The only other test to update is user_registration_controller_test.exs.

/test/reg_tokens_web/controllers/user_registration_controller_test.exs
defmodule RegTokensWeb.UserRegistrationControllerTest do
  use RegTokensWeb.ConnCase, async: true

  import RegTokens.AccountsFixtures

  @default_registration_token "40083e23-a925-473a-b4ad-134dfa3ee99a"

  describe "GET /users/register" do
    test "renders registration page", %{conn: conn} do
      conn = get(conn, Routes.user_registration_path(conn, :new, @default_registration_token))
      response = html_response(conn, 200)
      assert response =~ "<h1>Register</h1>"
      assert response =~ "Log in</a>"
    end

    test "redirects if already logged in", %{conn: conn} do
      conn =
        conn
        |> log_in_user(user_fixture())
        |> get(Routes.user_registration_path(conn, :new, @default_registration_token))

      assert redirected_to(conn) == "/"
    end
  end

  describe "POST /users/register" do
    @tag :capture_log
    test "creates account and logs the user in", %{conn: conn} do
      email = unique_user_email()
      token = registration_token_fixture().token

      conn =
        post(conn, Routes.user_registration_path(conn, :create, token), %{
          "user" => valid_user_attributes(email: email)
        })

      assert get_session(conn, :user_token)
      assert redirected_to(conn) =~ "/"

      # Now do a logged in request and assert on the menu
      conn = get(conn, "/")
      response = html_response(conn, 200)
      assert response =~ email
      assert response =~ "Settings</a>"
      assert response =~ "Log out</a>"
    end

    test "render errors for invalid data", %{conn: conn} do
      conn =
        post(conn, Routes.user_registration_path(conn, :create, @default_registration_token), %{
          "user" => %{"email" => "with spaces", "password" => "too short"}
        })

      response = html_response(conn, 200)
      assert response =~ "<h1>Register</h1>"
      assert response =~ "must have the @ sign and no spaces"
      assert response =~ "should be at least 12 character"
      assert response =~ "Invalid registration token!"
    end
  end
end

Nothing tricky, all we’ve done is add a token as part of the registration path router calls. For everything other than the success path (i.e. everything other than creates account and logs the user in) we don’t actually need a valid token so we’re just passing in a hard-coded default token.

Let’s give our tests a rip.

Terminal
mix test

Whoops!

You’ll recall we updated the original register_user function to be a private function. One thing we missed is that the function is used in accounts_fixtures.ex. We can see user_fixture/1 relies on our now private function.

def user_fixture(attrs \\ %{}) do
  {:ok, user} =
    attrs
    |> valid_user_attributes()
    |> RegTokens.Accounts.register_user()

  user
end

We’ll update the function to run against the repo directly.

/test/support/fixtures/accounts_fixtures.ex
def user_fixture(attrs \\ %{}) do
  attrs = valid_user_attributes(attrs)

  {:ok, user} =
    %User{}
    |> User.registration_changeset(attrs)
    |> Repo.insert()

  user
end

This also requires a couple of new alias statements.

/test/support/fixtures/accounts_fixtures.ex
defmodule RegTokens.AccountsFixtures do
  alias RegTokens.Accounts.User
  alias RegTokens.Repo
  ...

Let’s give it another go.

Terminal
mix test

Everything is looking good!

A couple of useful queries

So that does it for our implementation but I thought I would throw up a few queries that make use of the generated_by field just for the heck of it.

I’ve created some test data that looks like the below.

What we have is 2 “root” users, Bob and Sally who registered with plain tokens, everyone else were “invited” and thus registered with tokens generated by an existing user. We can see Bob isn’t very social, he has only invited a single user, Jim. Sally on the other hand has invited Jill and Kelly, and then Jill invited Marley who in turn invited Paige.

If we want to trace the invite / referral path how would we do so? It’s easy to find the direct invites of a user, i.e.

no file
-- find all the users who joined with a token generated by Sally
select u.id as user_id, u.email, rt.id as reg_token_id, rt.used_by_user_id, rt.generated_by_user_id
from users u join registration_tokens rt on rt.used_by_user_id = u.id
where generated_by_user_id = 2

As expected this returns the 2 users directly invited by Sally.

If we want a full picture of the sign-ups directly and indirectly associated with Sally we can use a recursive query. This article provides a very nice introduction to PostgreSQL recursive queries.

In our case, we’d end up with a query like:

no file
-- find all users associated with Sally
with RECURSIVE refer_tree_query as(
	select id, used_by_user_id, generated_by_user_id, 0 as depth
	from registration_tokens
	where generated_by_user_id = 2
	union
		select rt.id, rt.used_by_user_id, rt.generated_by_user_id, regs.depth + 1
		from registration_tokens rt
		inner join refer_tree_query regs on regs.used_by_user_id = rt.generated_by_user_id

)
SELECT u.id as user_id, u.email, rgq.id as reg_token_id, rgq.used_by_user_id, rgq.generated_by_user_id, depth as user_id
FROM refer_tree_query rgq
inner join users u on u.id = rgq.used_by_user_id
order by depth

Nice! We’re seeing all users who are in some way associated with Sally. The depth column gives us insight into how closely each user is associated to Sally.

We can change the where generated_by_user_id clause to scope the results to a particular point in the tree, i.e.

no file
-- find all users associated with Marley
with RECURSIVE refer_tree_query as(
	select id, used_by_user_id, generated_by_user_id, 0 as depth
	from registration_tokens
	where generated_by_user_id = 4
	union
		select rt.id, rt.used_by_user_id, rt.generated_by_user_id, regs.depth + 1
		from registration_tokens rt
		inner join refer_tree_query regs on regs.used_by_user_id = rt.generated_by_user_id

)
SELECT u.id as user_id, u.email, rgq.id as reg_token_id, rgq.used_by_user_id, rgq.generated_by_user_id, depth as user_id
FROM refer_tree_query rgq
inner join users u on u.id = rgq.used_by_user_id
order by depth

Summary

That’s it for this series of posts. We’ve updated the workflow around registrations without too much bother. That’s one of the great things about Phx.Gen.Auth; it provides a solid, ready to go implementation out of the box… but is easy to customize to your particular needs. Being a mix task that injects code directly into your project makes for a very transparent and easy to customize authentication solution.

Today’s code is available on GitHub.

Thanks for reading and I hope you enjoyed the post!



Comment on this post!