Victor Björklund

Phoenix Phx.Gen.Auth (almost) entirely in Liveview

Published: Aug 23 2022

These days my go-to solution for building web apps is Elixir with Phoenix Liveview. And since a lot of applications need some kind of user management and authentication I tend to use phx.gen.auth. I have looked at other solutions like Guardian but I like the built-in auth generator since it generates the code and there is no magic going on that I don’t see.

The problem

The problem is when we use Phoenix Liveview and phx.gen.auth together since phx.gen.auth will generate only routes without Liveview. There is nothing inherently wrong with routes without LiveView but in general, I find that the UX is slightly less good since you are missing out on things like form validations running whenever the fields change compared to just validations after submitting the form.

Why isn’t phx.gen.auth using Liveview

The reason phx.gen.auth isn’t using Liveview is that we are storing the authenticated user in the session cookie and we can’t write to the session cookie from Liveview since it’s only using Phoenix Channels for all the communication.

Solutions

So there are a couple of different solutions to this issue that I went through.

Accept no Liveview for auth

The first one is to just accept that auth related routes won’t have Liveview. After all, it’s a lot of sites and apps that just use server-side routes and full page reloads for forms and it hasn’t killed anyone yet.

Put auth token in local storage instead

This involves putting the auth token in the local storage instead since we can access that from Liveview using Javascript. I never tried this myself because what I don’t like with this solution is that it changes a lot of the code being generated by phx.gen.auth and local storage has different security considerations than using an HTTP-only cookie. And since I’m not a security expert I would be worried about making some mistake that would make the site vulnerable.

Validate first with Liveview and then submit normally

The solution I ended up with is so simple that it almost feels like cheating. First, we create a normal form :

<.form let={f} for={@conn} 
   action={Routes.user_session_path(@conn, :create)} as={:user}>
  <%= if @error_message do %>
    <div class="alert alert-danger">
      <p><%= @error_message %></p>
    </div>
  <% end %>

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

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


  <div>
    <%= submit "Log in" %>
  </div>
</.form>

Then we turn it into a Liveview and we make some changes.

<.form
  let={f}
  for={:changeset}
  action={Routes.user_session_path(@socket, :create)}
  phx-submit="login"
  phx-trigger-action={@trigger_submit}
  as={:user}
>
  <%= if @error_message do %>
    <div class="alert alert-danger">
      <p><%= @error_message %></p>
    </div>
  <% end %>
  
  <label for="email">Email address</label>
  
  <input
    type="email"
    value={@values["email"]}
    name="user[email]"
    require
    autocomplete="email"
  />
  
  <label for="password">Password</label>
  
  <input
    type="password"
    name="user[password]"
    value={@values["password"]}
    require
    autocomplete="current-password"
  />
  
  <button type="submit">Login</button>
</.form>

We then add a function that handles the login request.

@impl true
def handle_event("login", %{"user" => user_params}, socket) do
  %{"email" => email, "password" => password} = user_params

  values = %{"email" => email, "password" => password}

  if user = Accounts.get_user_by_email_and_password(email, password) do
    {:noreply,
      socket
      |> assign(:values, values)
      |> assign(:error_message, nil)
      |> assign(:trigger_submit, true)}
  else
    {:noreply,
      socket
      |> assign(:error_message, "Invalid email or password")
      |> assign(:values, values)}
  end
end

What is happening here is that it uses the existing code to check whether the provided email and password match or not. If it does not you set :error_message to “Invalid email or password” indicating that the combination is wrong (for security reasons you don’t wanna tell the user if it was the password or the email that didn’t match).

If on the other hand the provided password and email match the “magic” begins by setting :trigger_submit to true. That causes the form to do a traditional submission to your session controller generated by the phx.gen.auth (which we don’t need to change anything in) and you will be logged in (since we already know that the password/email matches and assume that it hasn’t changed in the milliseconds between the first check and the submission to the controller.

Conclusion

In the end, this is the “best” solution according to me since it enables 99% of everything to use Phoenix Liveview and at the same time, it requires minimal changes to any code dealing with security (where you don’t wanna make a mistake). The downside is that we are now “double-checking” the database for the login credentials on a successful login but I think that is a price worth paying.

Contact

Github Linkedin Twitter