Skip to content

Instantly share code, notes, and snippets.

@mitkins
Last active June 14, 2024 07:54
Show Gist options
  • Select an option

  • Save mitkins/a0acef8e9fa459ef68df483cf85de9a8 to your computer and use it in GitHub Desktop.

Select an option

Save mitkins/a0acef8e9fa459ef68df483cf85de9a8 to your computer and use it in GitHub Desktop.
defmodule PetalProWeb.PasswordlessAuthLive do
@moduledoc """
This module is used to handle the passwordless auth flow.
A user enters their email and submits. If no user exists for the user, then one is created with a random password.
A user will fill in their name at the onboarding screen.
Process:
1: User submits email.
2: Find or create a user, set it as assigns.auth_user
3: Push patch to /passwordless/sign-in-code/:hashed_user_id
4: User enters code that was sent to their email.
5: A form is submited that POSTs a token to UserSessionController.create_from_token/2
6: User is logged in
"""
use PetalProWeb, :live_view
alias PetalPro.Accounts
alias PetalPro.Accounts.User
alias PetalPro.Accounts.UserNotifier
alias PetalPro.Accounts.UserPin
require Logger
@allowed_attempts 3
@hash_salt "passwordless"
def mount(params, _session, socket) do
socket =
assign(socket,
page_title: gettext("Sign in"),
form: to_form(Accounts.change_user_passwordless_registration(%User{}, %{})),
user_return_to: Map.get(params, "user_return_to", nil),
error_message: nil,
auth_user: nil,
enable_resend?: nil,
sign_in_token: nil,
token_form: to_form(build_token_changeset(%{}), as: :auth),
loading: false,
trigger_submit: false
)
{:ok, socket}
end
def handle_params(params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :sign_in, _params), do: assign(socket, error_message: nil)
defp apply_action(socket, :sign_in_code, %{"hashed_user_id" => hashed_user_id}) do
user_id = decode_user_id(hashed_user_id)
if socket.assigns.auth_user do
socket
else
# Re-assign page variables if this is remounted (eg. socket disconnected)
# This can happen on mobile devices when user switches to mail app to copy/paste pin
auth_user = Accounts.get_user!(user_id)
if UserPin.valid_pin_exists?(auth_user) do
assign(socket, auth_user: auth_user)
else
push_patch(socket,
to: ~p"/auth/sign-in/passwordless"
)
end
end
end
def handle_event("submit_email", %{"user" => %{"email" => email}}, socket) do
send_pin(socket, email)
end
# When pin is less than 6 digits, ignore it
def handle_event("validate_pin", %{"auth" => %{"pin" => pin}}, socket) when byte_size(pin) < 6 do
{:noreply, assign(socket, error_message: nil)}
end
# Handle nil user
def handle_event("validate_pin", %{"auth" => %{"pin" => _pin}}, socket) when socket.assigns.auth_user == nil do
{:noreply, push_patch(socket, to: ~p"/auth/sign-in/passwordless")}
end
def handle_event("validate_pin", %{"auth" => %{"pin" => submitted_pin}}, socket) when byte_size(submitted_pin) >= 6 do
submitted_pin = String.trim(submitted_pin)
pin_result = UserPin.validate_pin(socket.assigns.auth_user, submitted_pin, @allowed_attempts)
{:noreply, handle_validation(socket, pin_result)}
end
# Fallback function if the pin wasn't yet 6 digits.
def handle_event("validate_pin", _, socket), do: {:noreply, socket}
def handle_event("resend", _, socket) do
user = socket.assigns[:auth_user]
if user do
pin = UserPin.create_pin(user)
UserNotifier.deliver_passwordless_pin(user, pin)
Accounts.user_lifecycle_action("after_passwordless_pin_sent", user, %{pin: pin})
{:noreply,
assign(socket, %{
enable_resend?: false,
error_message: nil
})}
else
{:noreply, socket}
end
end
defp send_pin(socket, email) do
email = Util.trim(email)
with {:ok, user} <- Accounts.get_or_create_user(%{email: email}, "passwordless"),
pin when not is_nil(pin) <- UserPin.create_pin(user) do
{:ok, email} = UserNotifier.deliver_passwordless_pin(user, pin)
Accounts.user_lifecycle_action("after_passwordless_pin_sent", user, %{pin: pin})
{:noreply,
socket
|> assign(%{
email: email,
error_message: nil,
auth_user: user,
attempts: 0
})
|> push_patch(to: ~p"/auth/sign-in/passwordless/enter-pin/#{encode_user_id(user.id)}")}
else
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
_ ->
{:noreply, assign(socket, error_message: gettext("Unknown error."))}
end
end
defp decode_user_id(hashed_user_id), do: Util.HashId.decode(hashed_user_id, salt_addition: @hash_salt)
defp encode_user_id(user_id), do: Util.HashId.encode(user_id, salt_addition: @hash_salt)
defp build_token_changeset(params) do
types = %{
pin: :number,
sign_in_token: :string,
user_return_to: :string
}
Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
end
defp handle_validation(socket, {:ok, _user_pin}) do
Accounts.UserPin.purge_pins(socket.assigns.auth_user)
sign_in_token =
socket.assigns.auth_user
|> Accounts.generate_user_session_token()
|> Base.encode64()
token_changeset =
build_token_changeset(%{
sign_in_token: sign_in_token,
user_return_to: socket.assigns.user_return_to
})
Process.send_after(self(), :trigger_submit, 500)
assign(socket, token_form: to_form(token_changeset, as: :auth), loading: true)
end
defp handle_validation(socket, {:error, :too_many_incorrect_attempts}) do
Accounts.UserPin.purge_pins(socket.assigns.auth_user)
PetalPro.Logs.log_async("passwordless_pin_too_many_incorrect_attempts", %{
user: socket.assigns.auth_user
})
socket
|> push_patch(to: ~p"/auth/sign-in/passwordless")
|> put_flash(:error, gettext("Too many incorrect attempts."))
end
defp handle_validation(socket, {:error, :expired}) do
Accounts.UserPin.purge_pins(socket.assigns.auth_user)
PetalPro.Logs.log_async("passwordless_pin_expired", %{
user: socket.assigns.auth_user
})
socket
|> push_patch(to: ~p"/auth/sign-in/passwordless")
|> put_flash(:error, gettext("Not a valid pin. Sure you typed it correctly?"))
end
defp handle_validation(socket, {:error, :incorrect_pin}) do
# Increase attempt count
Accounts.UserPin.failed_attempt(socket.assigns.auth_user)
socket
|> assign(:error_message, gettext("Not a valid pin. Sure you typed it correctly?"))
|> assign(:enable_resend?, true)
end
defp handle_validation(socket, {:error, :not_found}) do
socket
|> push_patch(to: ~p"/auth/sign-in/passwordless")
|> put_flash(:error, gettext("Not a valid pin. Sure you typed it correctly?"))
end
def handle_info(:trigger_submit, socket) do
{:noreply, assign(socket, :trigger_submit, true)}
end
# Leave this in for testing purposes. Swoosh sends the email back to the live view and allows us to get the pin in the test
def handle_info({:email, _email}, socket) do
{:noreply, socket}
end
end
<%= if @live_action == :sign_in do %>
<.auth_layout title={gettext("Continue with passwordless")}>
<:logo>
<.logo_icon class="w-20 h-20" />
</:logo>
<.form for={@form} phx-submit="submit_email">
<.field
type="email"
field={@form[:email]}
placeholder={gettext("eg. sarah@gmail.com")}
{alpine_autofocus()}
/>
<.p class="text-sm">
<%= gettext(
"Enter the email with to register or sign in with and we'll email you a pin code."
) %>
</.p>
<.alert color="warning" label={@error_message} class="mt-5" />
<div class="flex justify-between mt-6">
<.button to={~p"/auth/sign-in"} link_type="live_redirect" type="button" color="white">
<.icon solid name={:arrow_small_left} class="w-4 h-4 mr-1" />
<%= gettext("Cancel") %>
</.button>
<.button phx-disable-with={gettext("Sending...")} label={gettext("Get pin code")} />
</div>
</.form>
</.auth_layout>
<% end %>
<%= if @live_action == :sign_in_code do %>
<.auth_layout title={gettext("Check your email")}>
<:logo>
<.logo_icon class="w-20 h-20" />
</:logo>
<:top_links>
<.p><%= gettext("We've sent a 6 digit sign in pin code to") %>:</.p>
<.p class="font-semibold"><%= @auth_user.email %></.p>
<.p><%= gettext("Can't find it? Check your spam folder.") %></.p>
</:top_links>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<%= if @loading do %>
<div class="flex items-center justify-center gap-3 h-[140px]">
<.spinner show={true} class="text-primary-600 dark:text-primary-400" size="md" />
<.h5 no_margin><%= gettext("Signing in...") %></.h5>
</div>
<% end %>
<.form
for={@token_form}
action={~p"/auth/sign-in/passwordless"}
phx-trigger-action={@trigger_submit}
phx-change="validate_pin"
class={if @loading, do: "hidden", else: ""}
>
<.form_label><%= gettext("Your sign in pin code") %></.form_label>
<input
type="number"
name={@token_form[:pin].name}
value={@token_form[:pin].value}
class="block w-full font-mono text-center border-gray-300 rounded-md shadow-sm md:text-2xl dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 focus:border-primary-500 dark:focus:border-primary-500 focus:outline-none focus:ring-primary-500"
min="0"
max="10000000"
inputmode="numeric"
pattern="[0-9]*"
onkeypress="{if(this.value.length==6) return false;}"
autofill="off"
autocomplete="off"
{alpine_autofocus()}
/>
<.input type="hidden" field={@token_form[:sign_in_token]} />
<.input type="hidden" field={@token_form[:user_return_to]} />
<.alert color="warning" class="mt-5" label={@error_message} />
<div class="flex justify-between mt-6">
<.button
to={~p"/auth/sign-in/passwordless"}
link_type="live_patch"
type="button"
color="white"
>
<.icon solid name={:arrow_small_left} class="w-4 h-4 mr-1" />
<%= gettext("Cancel") %>
</.button>
<%= if @enable_resend? do %>
<.button
color="white"
type="button"
phx-disable-with={gettext("Resending new pin code...")}
phx-click="resend"
>
<.icon solid name={:arrow_path} class="w-4 h-4 mr-1" />
<%= gettext("Resend pin code") %>
</.button>
<% end %>
</div>
</.form>
</div>
</.auth_layout>
<% end %>
defmodule PetalProWeb.PasswordlessAuthLiveTest do
use PetalProWeb.ConnCase
import PetalPro.AccountsFixtures
import Phoenix.LiveViewTest
alias PetalPro.Repo
describe "no existing user" do
test "it creates a new user and logs them in", %{conn: conn} do
inital_user_count = Repo.count(PetalPro.Accounts.User)
{:ok, view, _html} = live(conn, ~p"/auth/sign-in/passwordless")
email = unique_user_email()
# Enter in our email
html =
view
|> form("form", user: %{email: email})
|> render_submit()
# A new user should have been created
assert inital_user_count + 1 == Repo.count(PetalPro.Accounts.User)
new_user = Repo.last(PetalPro.Accounts.User)
assert new_user.email == email
# Check that the view now shows the pin entering screen
assert_patch(view)
assert html =~ email
assert html =~ "Check your email"
# Check that an email was sent to the email address
assert_received {:email, swoosh_email}
# Extract the pin out of the email
pin = ~r/\d{6}/ |> Regex.run(swoosh_email.text_body) |> Enum.at(0)
# Submit the pin
view
|> form("form")
|> render_change(%{auth: %{pin: pin}})
# Set phx-trigger-action
send(view.pid, :trigger_submit)
# Normally the presence of phx-trigger-action would cause live views javascript to submit our form for us.
# But since we're not in a browser environment we need to manually submit it
sign_in_token =
new_user
|> PetalPro.Accounts.generate_user_session_token()
|> Base.encode64()
form = form(view, "form", %{"auth" => %{"sign_in_token" => sign_in_token}})
conn = follow_trigger_action(form, conn)
assert redirected_to(conn) =~ PetalProWeb.Helpers.home_path(new_user)
# New user should be confirmed, as they obviously have access to their email
new_user = Repo.last(PetalPro.Accounts.User)
assert !!new_user.confirmed_at
end
end
describe "with existing user" do
test "it logs in the existing user", %{conn: conn} do
user = confirmed_user_fixture()
inital_user_count = Repo.count(PetalPro.Accounts.User)
{:ok, view, _html} = live(conn, ~p"/auth/sign-in/passwordless")
html =
view
|> form("form", user: %{email: user.email})
|> render_submit()
assert_patch(view)
assert html =~ user.email
assert html =~ "Check your email"
assert_received {:email, swoosh_email}
pin = ~r/\d{6}/ |> Regex.run(swoosh_email.text_body) |> Enum.at(0)
# Submit the pin
view
|> form("form")
|> render_change(%{auth: %{pin: pin}})
# Set phx-trigger-action
send(view.pid, :trigger_submit)
sign_in_token =
user
|> PetalPro.Accounts.generate_user_session_token()
|> Base.encode64()
form =
form(view, "form", %{
"auth" => %{"sign_in_token" => sign_in_token, "user_return_to" => ""}
})
conn = follow_trigger_action(form, conn)
assert redirected_to(conn) =~ PetalProWeb.Helpers.home_path(user)
assert inital_user_count == Repo.count(PetalPro.Accounts.User)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment