This post belongs to the Elixir and Phoenix basic passwordless and databaseless authentication series.
- Project setup and the initial functionality for storing and verifying authentication tokens
- Sending authentication link emails and the user socket connection
- Setting up webpack as our asset bundler and the Elm single-page application
In the previous part of the series, we set up the umbrella application for our new project and created the necessary modules for storing and generating authentication tokens. Having this done, the next step is sending emails to valid users, containing the sign-in link that will authenticate them into the system once clicked.
Sending emails
To send emails in an Elixir application I usually rely on Bamboo from the awesome team at thoughtbot, which is not only simple and powerful, but very customizable as well. Let's go ahead and add the dependency to the project, under the PasswordlessAuthWeb
application:
# apps/passwordless_auth_web/mix.exs
defmodule PasswordlessAuthWeb.Mixfile do
use Mix.Project
# ...
defp deps do
[
{:phoenix, "~> 1.3.2"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_html, "~> 2.10"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:passwordless_auth, in_umbrella: true},
{:cowboy, "~> 1.0"},
{:bamboo, "~> 0.8"}
]
end
# ...
end
Don't forget to run the necessary deps.get
mix task in order to install it. Next step is configuring Bamboo:
# apps/passwordless_auth_web/config/config.exs
use Mix.Config
# ...
# Bamboo mailer configuration
config :passwordless_auth_web,
PasswordlessAuthWeb.Service.Mailer,
adapter: Bamboo.LocalAdapter
# ...
In the configuration we are specifying two things:
- The name of the module we are going to use as an interface with
Bamboo
's functionality. - The adapter we want to use to send emails.
For our particular case and while developing the project, we are going to take advantage of the LocalAdapter which stores sent emails in memory and offers us a small inbox application where we can view them. In order to have access to this inbox application, we need to mount a new route it in the router, which will be only accessible in the :dev
environment:
# apps/passwordless_auth_web/lib/passwordless_auth_web/router.ex
defmodule PasswordlessAuthWeb.Router do
use PasswordlessAuthWeb, :router
if Mix.env() == :dev, do: forward("/sent_emails", Bamboo.EmailPreviewPlug)
# ...
end
The only part we are missing is creating the Mailer
module, so let's go ahead and add it:
# apps/passwordless_auth_web/lib/passwordless_auth_web/service/mailer.ex
defmodule PasswordlessAuthWeb.Service.Mailer do
use Bamboo.Mailer, otp_app: :passwordless_auth_web
end
If we start the Phoenix server at this point and visit http://localhost:4000/sent_emails, we should see the following message:
This is completely fine, as we haven't sent any emails yet, so let's go ahead and create the necessary functionality to build up an email containing the authentication link using an email address and a token:
# apps/passwordless_auth_web/lib/passwordless_auth_web/emails/auth_email.
defmodule PasswordlessAuthWeb.Emails.AuthEmail do
import Bamboo.Email, only: [new_email: 1]
import PasswordlessAuthWeb.Router.Helpers, only: [page_url: 4]
@from "support@passwordlessauth.com"
def build(email, token) do
url = page_url(PasswordlessAuthWeb.Endpoint, :index, [], token: token)
new_email(
to: email,
from: @from,
subject: "Your authentication link",
html_body: """
<p>Here is your authentication link:</p>
<a href="#{url}">#{url}</a>
<p>It is valid for 5 minutes.</p>
""",
text_body: """
Here is your authentication link: \n
#{url}\n
It is valid for 5 minutes.
"""
)
end
end
With the token parameter and thanks to Phoenix's route helpers, we build the new Bamboo email which has the authentication link in its body, and which addressee is the email parameter. For the time being, let's use the default route that comes with Phoenix out of the box, and which points to /
.
Let's test this out by starting the Phoenix server again and sending a new email:
$ iex -S mix phx.server
Erlang/OTP 21 [erts-10.0] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]
[info] Running PasswordlessAuthWeb.Endpoint with Cowboy using http://0.0.0.0:4000
Interactive Elixir (1.6.6) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> email = PasswordlessAuthWeb.Emails.AuthEmail.build("foo@email.com", "token")
%Bamboo.Email{
assigns: %{},
bcc: nil,
cc: nil,
from: "support@passwordlessauth.com",
headers: %{},
html_body: "<p>Here is your authentication link:</p>\n<a href=\"http://localhost:4000/?token=token\">http://localhost:4000/?token=token</a>\n<p>It is valid for 5 minutes.</p>\n",
private: %{},
subject: "Your authentication link",
text_body: "Here is your authentication link: \n\nhttp://localhost:4000/?token=token\n\nIt is valid for 5 minutes.\n",
to: "foo@email.com"
}
iex(2)> PasswordlessAuthWeb.Service.Mailer.deliver_later email
[debug] Sending email with Bamboo.LocalAdapter:
%Bamboo.Email{assigns: %{}, bcc: [], cc: [], from: {nil, "support@passwordlessauth.com"}, headers: %{}, html_body: "<p>Here is your authentication link:</p>\n<a href=\"http://localhost:4000/?token=token\">http://localhost:4000/?token=token</a>\n<p>It is valid for 5 minutes.</p>\n", private: %{}, subject: "Your authentication link", text_body: "Here is your authentication link: \n\nhttp://localhost:4000/?token=token\n\nIt is valid for 5 minutes.\n", to: [nil: "foo@email.com"]}
iex(3)>
If we revisit http://localhost:4000/sent_emails, we can see the email that we just sent:
But, how are the users going to request the authentication email?
The authentication controller
Despite the admin site being an Elm single page application, that relies on a socket connection, we still need to provide a mechanism so users can request the authentication email. Let's use a controller for this:
# apps/passwordless_auth_web/lib/passwordless_auth_web/controllers/authentication_controller.ex
defmodule PasswordlessAuthWeb.AuthenticationController do
use PasswordlessAuthWeb, :controller
alias PasswordlessAuthWeb.{Emails.AuthEmail, Service.Mailer}
@email_regex ~r/^[A-Za-z0-9._%+-+']+@[A-Za-z0-9.-]+\.[A-Za-z]+$/
def create(conn, params) do
with %{"email" => email} <- params,
true <- valid_email?(email),
{:ok, token} <- PasswordlessAuth.provide_token_for(email) do
build_and_deliver_email(email, token)
end
json(conn, %{message: gettext("auth.message")})
end
def valid_email?(email), do: Regex.match?(@email_regex, email)
defp build_and_deliver_email(email, token) do
email
|> AuthEmail.build(token)
|> Mailer.deliver_later()
end
end
If the received parameters contain the email
with a valid format, it provides a token, builds and delivers the authentication email. On the contrary, for security reasons we don't want to give any clues to the user if the email provided has a wrong format or it does not exist, so it just returns the same success message. Let's add a new route for this controller and action:
# apps/passwordless_auth_web/lib/passwordless_auth_web/router.ex
defmodule PasswordlessAuthWeb.Router do
use PasswordlessAuthWeb, :router
if Mix.env() == :dev, do: forward("/sent_emails", Bamboo.EmailPreviewPlug)
# ...
pipeline :api do
plug(:accepts, ["json"])
end
# ...
scope "/api", PasswordlessAuthWeb do
pipe_through(:api)
post("/auth", AuthenticationController, :create)
end
Finally, to check that everything works as we expect, let's add a new test module for the controller:
# apps/passwordless_auth_web/test/passwordless_auth_web/controllers/authentication_controller_test.exs
defmodule PasswordlessAuthWeb.AuthenticationControllerTest do
use PasswordlessAuthWeb.ConnCase
use Bamboo.Test
import PasswordlessAuthWeb.Gettext
alias PasswordlessAuth.Repo
alias PasswordlessAuthWeb.Emails.AuthEmail
describe "POST /api/auth" do
test "always returns success message no matter what parameters receives", %{conn: conn} do
conn = post(conn, authentication_path(conn, :create), email: "foo@test.com")
assert %{"message" => _} = json_response(conn, 200)
conn = post(conn, authentication_path(conn, :create), %{})
assert assert %{"message" => _} = json_response(conn, 200)
end
test "delivers the email only when valid email", %{conn: conn} do
email = "#{ __MODULE__ }@email.com"
Repo.add_email(email)
post(conn, authentication_path(conn, :create), email: email)
{:ok, token} = Repo.fetch(email)
assert_delivered_email(AuthEmail.build(email, token))
end
test "does not deliver the email only when invalid email format", %{conn: conn} do
email = "#{ __MODULE__ }emailcom"
Repo.add_email(email)
post(conn, authentication_path(conn, :create), email: email)
{:ok, token} = Repo.fetch(email)
refute_delivered_email(AuthEmail.build(email, token))
end
end
end
If we run it, we can check that it is actually working fine:
$ mix test test/passwordless_auth_web/controllers/authentication_controller_test.exs
==> passwordless_auth
Test patterns did not match any file: test/passwordless_auth_web/controllers/authentication_controller_test.exs
==> passwordless_auth_web
...
Finished in 0.1 seconds
3 tests, 0 failures
Randomized with seed 547795
Yay! Now that we have the email generation and delivery sorted out, let's move on the next important part of our application, the user socket.
Authenticating the user socket connection
Phoenix creates a default UserSocket
module while bootstrapping a new project, so let's edit it to add the authentication logic:
# apps/passwordless_auth_web/lib/passwordless_auth_web/channels/user_socket.ex
defmodule PasswordlessAuthWeb.UserSocket do
use Phoenix.Socket
alias PasswordlessAuth
## Transports
transport(:websocket, Phoenix.Transports.WebSocket)
def connect(%{"token" => token}, socket) do
case PasswordlessAuth.verify_token(token) do
{:ok, email} ->
{:ok, assign(socket, :user, %{email: email})}
_ ->
:error
end
end
def connect(_, _socket), do: :error
def id(socket), do: "user_socket:#{socket.assigns.user.email}"
end
The connect/2
callback function receives a token
parameter, and using PasswordlessAuth.verify_token/1
checks whether this token is valid or not, assigning to the socket the corresponding email on success. On the other hand, if no token
parameter is received or the verification goes wrong, it returns :error
rejecting the connection. Let's add some unit tests to ensure that it works as we expect:
# apps/passwordless_auth_web/test/passwordless_auth_web/channels/user_socket_test.exs
defmodule PasswordlessAuthWeb.UserSocketTest do
use PasswordlessAuthWeb.ChannelCase, async: true
alias Phoenix.Socket
alias PasswordlessAuth.Repo
alias PasswordlessAuthWeb.UserSocket
describe "connect/2" do
test "errors when passing invalid params or token" do
assert :error = connect(UserSocket, %{})
assert :error = connect(UserSocket, %{"token" => "invalid-token"})
end
test "joins when passing valid token" do
email = "foo@#{ __MODULE__ }.com"
:ok = Repo.add_email(email)
{:ok, token} = PasswordlessAuth.provide_token_for(email)
assert {:ok, %Socket{assigns: %{user: %{email: ^email}}}} =
connect(UserSocket, %{"token" => token})
end
end
end
And run them to check the result:
$ mix test test/passwordless_auth_web/channels/user_socket_test.exs
==> passwordless_auth
Test patterns did not match any file: test/passwordless_auth_web/channels/user_socket_test.exs
==> passwordless_auth_web
..
Finished in 0.06 seconds
2 tests, 0 failures
Randomized with seed 589379
Cool, it works! I think this is all for today. In the next part, we will work on the front end side, configuring webpack as our asset build tool of choice, adding Elm support to start building the admin single page application, using everything we have done until now to authenticate users. In the meantime, don't forget to check out the source code with the final result:
Happy coding!
Top comments (1)
Just came across this passwordless-auth tutorials of yours and learned a lot on passwordless auth in Elixir. So far the repo relies on OTP as passwordless alternative. Have you ever tried passwordless WebAuthn / passkeys to add to Elxiir as well?