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:
- phx.gen.auth with email and password login,
- using assent for Github only login (see part 2: Phoenix auth in 2024: provider auth),
- 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:
- Mr Murky enters an email and password into the form,
- LOGIN button sends a POST request to
UserSessionController
- The controller does a user lookup in the users table
- If it can lookup the user, it creates at token and sends it back in a session cookie
- 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:
- the migration created in
priv/repo/migrations
because we already have it. - 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