Phoenix auth in 2024: password auth

marcin 3rd April 2024 at 7:01pm

Some time ago Elixir Phoenix framework was upgraded with a built-in auth system, which can replace an external authentication library. However, such external libraries like pow or guardian work with other libraries to provide OAuth style authentication using external authentication providers (namely pow_assent and überauth).

What to do if we want to use phx.gen.auth, but also be able to sign in using Github? Turns out, we assent library is decoupled from pow_assent, which means it can be used in a bare phoenix app. In this guide, I'll show three scenarios:

  1. phx.gen.auth with email and password login,
  2. using assent for Github only login (see part 2: Phoenix auth in 2024: provider auth),
  3. using both password and Github authentication.

New project

I am going to use a most basic Phoenix app as an example (you can find the code here). For this part, start with checking out the example_1 tag. However, I encourage you to follow the guide by trying to build this by yourself from scratch.

Start with a standard opening:

mix phx.new auth2024 
cd auth2024
mix ecto.setup

What elements do we have in authentication?

Authentication involves passing information about who you are between different components. It can be passed around and stored using different channels and it can have many forms. It can be signed, so it cannot be tampered with, or encrypted and readable only by components holding the decryption key.

When interacting with a Phoenix app, we are talking about these components:

  • a mind of the user (memorizing a password)
  • phoenix controller (running to handle a HTTP request)
  • phoenix live view (running to handle web socket)
  • browser (frontend JS process)
  • authentication providers (Github, Google, etc)

The identity information can be stored in these forms:

  • in app memory
  • in a DB table
  • in an in-memory DB like redis or memcache
  • in a cookie
  • in an url
  • in a HTTP request header

The identity information can be made up from various pieces of data, but they should let the system component know, or know how to lookup, who the user is.

Authentication scaffolding with phx.gen.auth

Phoenix by default provides mix phx.gen.auth command, which gives us some tools to:

  • store full user information (the users table)
  • store a reference to the user information between HTTP requests (session mechanism)
  • functions to transfer user information between HTTP requests and LiveView running on a WebSocket
  • views for login, profile (settings) page

By default, it implements a password based authentication, as well as some utility things like sending a confirmation e-mail, to check if you are it's owner.

Let's run with the basic, but respond no to Do you want to create a LiveView based authentication system?. This is so we see how HTTP request authentication works.

mix phx.gen.auth Accounts User users 
mix ecto.migrate # create users table

You should be able to run the app with mix phx.server and sign up, login using a password.

The users table contain a basic information about you - your email. Beside that, it contains a secret (password) which you hold in your mind, and it's used to lookup the user information stored in DB (and prove you are the owner of the account). Note, that password is not stored verbatim, but it's cryptographic hash that is stored in hashed_password. This is a cryptographic trick that lets the app check if you provided the correct password, even though it's not stored as is in the DB (where it could be leaked in case of server compromise!)

Example 1: Logging in with email and password

What's going on here?

The flow of secrets that point to user information is as follows:

  1. Mr Murky enters an email and password into the form,
  2. LOGIN button sends a POST request to UserSessionController
  3. The controller does a user lookup in the users table
  4. If it can lookup the user, it creates at token and sends it back in a session cookie
  5. From now on, the session cookie is sent along each request, and UserAuth middleware will use it to lookup the user information from users table

the UserSessionController does the following on login:

  def create(conn, %{"user" => user_params}) do
    # read email, password inputs
    %{"email" => email, "password" => password} = user_params

    # lookup user using these inputs
    if user = Accounts.get_user_by_email_and_password(email, password) do
      conn
      |> put_flash(:info, "Welcome back!")
      |> UserAuth.log_in_user(user, user_params) # create session token
    else
      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      render(conn, :new, error_message: "Invalid email or password")
    end
  end

The UserAuth.log_in_user looks as follows:

  def log_in_user(conn, user, params \\ %{}) do
    # Create a session token in users_tokens table
    token = Accounts.generate_user_session_token(user)
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> renew_session()
    |> put_token_in_session(token)  # store session token
    |> maybe_write_remember_me_cookie(token, params)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

What is a user token?

You might have noticed that phx.auth.gen created users table, but also users_tokens, which holds tokens belonging to each user.

The log_in_user function creates a token and puts it into session. So far we had one secret that was used to lookup user info - now we have another one. A token is such random, temporary secret. It will be given back to you stored in a cookie (You can check that there is a _auth2024_key cookie, which contains an encoded map of values, one of which is "user_token"). Now on every request, this token will be used to lookup your user information (so you don't have to provide your password on every request!).

You will find a fetch_current_user plug (middleware) in the router, a function imported from Auth2024Web.UserAuth, which does exactly this. It takes the token and looks up the User record, storing it in conn.assigns.

  def fetch_current_user(conn, _opts) do
    {user_token, conn} = ensure_user_token(conn)
    user = user_token && Accounts.get_user_by_session_token(user_token)
    assign(conn, :current_user, user)
  end

Authentication in Live View

Phoenix does a lot to blend handling requests with controllers and live views. However, it's important to realize that web sockets are a different protocol then HTTP. Although we "upgrade" the HTTP connection into a web socket (a process where we first fetch the page using HTTP GET, and then establish a bidirectional web socket), they are really different protocols, and do not support same features. For example, web sockets do not offer cookies, which are used for session storing!

Controller handle requests each time starting anew, they have to each time read the query string, headers, cookies, and build their state (in conn.assigns) from scratch. Live views, on other hand, are started when the web socket is created, and they keep their state (socket.assigns) in app memory all the time.

So far we have seen how plugs lookup user by token and store it in conn.assigns.current_user.

In live view, we have to set socket.assigns.current_user upon its setup (mounting).

Lets create a simple greeting live view (in file lib/auth2024_web/live/hi_widget_live.ex):

defmodule Auth2024Web.HiWidgetLive do
  use Auth2024Web, :live_view

  def render(assigns) do
    if assigns[:current_user] do
      ~H"""
      <div>👋 Hi, <%= @current_user.email %></div>
      """
    else
      ~H"""
      <div>🤔 Do I know you?</div>
      """
    end
  end
end

Add it to the router (lib/auth2024_web/router.ex):

  scope "/", Auth2024Web do
    pipe_through :browser

    get "/", PageController, :home
    live "/hi", HiWidgetLive # <-- add it here
  end

Now open https://localhost:4000/hi and notice you are not recognized, even if you log in.

As mentioned before, current_user needs to be added to live view socket assigns. To do this, add this function to the HiWidgetLive. This function is called when a live view is set up, and it's an opportunity to have access to both session and (web) socket.

  def mount(_params, session, socket) do
    with token when is_bitstring(token) <- session["user_token"],
      user when not is_nil(user) <- Auth2024.Accounts.get_user_by_session_token(token) do
      {:ok, assign(socket, current_user: user)}
    else
      _ -> {:ok, socket}
    end
  end

What it does is simply read the "user_token" from the session and uses Accounts context function to fetch a user, and then add it to socket's assigns. Log out and in again, and notice now the live view recognizes you!

We have manually written mount function, but phx.gen.auth already provided us with a helper, and instead we can just add one line in the top of the file:

defmodule Auth2024Web.HiWidgetLive do
  use Auth2024Web, :live_view
  on_mount {Auth2024Web.UserAuth, :mount_current_user}
  ...

Which will basically do the same for us.

phx.gen.auth with live view enabled

To see the result of this part, check out the example_2 tag. Lets run mix phx.gen.auth Accounts User users again but this time, say Y to whether use live views for login/register/settings pages. Allow it to overwrite the files, and delete:

  1. the migration created in priv/repo/migrations because we already have it.
  2. the routes in router.ex below the second comment Authentication routes – we only want live view ones, which prependend (not replaced) the old ones.

When you are in router.ex file, take a look at the auth routes. Now register, log in and password reset pages are run as live views (eg. they will update input field validations without page reload). However, there is one POST route left, the post "/users/log_in" which uses the same method to login and create users session as before.

Technically, it would be possible to verify the password in UserLoginLive, create a user token there, and store it into session/cookie. However it is not straightforward, because cookies are a HTTP request/response concept, not a web socket one!

This could be done by pushing the token to the front-end, and setting the cookie using Javascript1. However, our use case does not satisfy this kind of trick.

In part 2 we will authenticate against a provider, Github: Phoenix auth in 2024: provider auth