Last active
June 14, 2024 07:54
-
-
Save mitkins/a0acef8e9fa459ef68df483cf85de9a8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <%= 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 %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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