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 244b2d9f624aed6d5634fbaf66658ca12a918a8c
git reset --hard FETCH_HEAD
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/4xY75rlRQK7FZ13HBJDZNrJvo0nRBM3eUL1HbuEao8E.
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 "12375rlRQK7FZ13HBJDZNrJvo0nRBM3eUL1HbuEao8E"
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_string
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.
We’ll update the fixture 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!
What if we don’t want to store the token string?
In part 1 we talked about the case where we are immediately using the registration token and thus don’t need to save the url encoded string token in the database. What would this look like? Pretty simple to be honest.
First off the migration would exclude the token_string
column, i.e.
/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
And the schema file would use a virtual field for the token_string:
/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, virtual: true
...
...
That’s it! If you re-create your database with the new migration (i.e. mix do ecto.drop, ecto.create, ecto.migrate
) and run the code everything will work as before but the token_string
will no longer be saved. All the tests will also be passing as is (you’ll need to re-create the test db, i.e. MIX_ENV=test mix do ecto.drop, ecto.create, ecto.migrate
).
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!