Phoenix LiveView is great for things like forms with validations. For a new user form, you'll probably want to log the user in as soon as they create an account. This is a walk-through of how you can add user auth to a LiveView app using login tokens. The same strategy also works for logging in with "magic" forgot password links.
Add password hashing libraries
We'll start by updating our mix.exs
file by adding the following two libraries to our deps:
defp deps do
[
#...
{:comeonin, "~> 4.0"},
{:argon2_elixir, "~> 1.2"}
]
end
Add a login hash to the user
First create a migration:
defmodule Reactor.Repo.Migrations.AddLoginhashToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :login_token, :string
end
end
end
then update the user schema, then add a token changset and helpers:
defmodule Reactor.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias Comeonin.Argon2
schema "users" do
# ...
field :password_hash, :string
field :password, :string, virtual: true
field :password_confirmation, :string, virtual: true
field :login_token, :string
#...
def token_changeset(struct, attrs \\ %{}) do
struct
|> changeset(attrs)
|> cast(attrs, [:login_token])
|> hash_login_token()
end
def generate_login_token do
:crypto.strong_rand_bytes(40) |> Base.url_encode64()
end
defp hash_login_token(%{valid?: false} = changeset), do: changeset
defp hash_login_token(%{changes: %{login_token: nil}} = changeset) do
put_change(changeset, :login_token, nil)
end
defp hash_login_token(%{changes: %{login_token: token}} = changeset) do
put_change(changeset, :login_token, Argon2.hashpwsalt(token))
end
defp hash_login_token(%{valid?: true} = cset), do: put_change(cset, :login_token, nil)
Add auth functions to the Accounts context
# ...
import Comeonin.Argon2, only: [checkpw: 2, dummy_checkpw: 0]
def authenticate_by_email_password(email, given_pass) do
user = get_user_by_email(email)
cond do
email in Reactor.Accounts.Email.banned() ->
dummy_checkpw()
{:error, :unauthorized}
user && checkpw(given_pass, user.password_hash) ->
{:ok, user}
user ->
{:error, :unauthorized}
true ->
dummy_checkpw()
{:error, :not_found}
end
end
def authenticate_by_email_token(email, token) do
user = get_user_by_email(email)
cond do
email in Reactor.Accounts.Email.banned() ->
dummy_checkpw()
{:error, :unauthorized}
user && user.login_token && checkpw(token, user.login_token) ->
update_login_token(user, nil)
{:ok, user}
true ->
dummy_checkpw()
{:error, :unauthorized}
end
end
def get_user_by_email(email) do
Repo.get_by(User, email: email)
end
def update_login_token(%User{} = user, token) do
user
|> User.token_changeset(%{login_token: token})
|> Repo.update()
end
Add a session controller
This will be identical to that of a standard phoenix app, plus a function for token-based logins:
def create_from_token(conn, %{"email" => email, "token" => token}) do
case Accounts.authenticate_by_email_token(email, token) do
{:ok, user} ->
conn
|> put_flash(:info, "Welcome back!")
|> put_session(:user_id, user.id)
|> configure_session(renew: true)
|> redirect(to: Routes.live_path(conn, ReactorWeb.UserLive.Show, user))
_ ->
conn
|> put_flash(:error, "Invalid login")
|> redirect(to: Routes.session_path(conn, :new))
end
end
Create an auth plug
defmodule ReactorWeb.Auth do
import Plug.Conn
import Phoenix.Controller
alias ReactorWeb.Router.Helpers, as: Routes
alias Reactor.Accounts
@site_admins ~w[
# add email addresses of site admins here
alchemist.camp@gmail.com
]
def init(opts), do: opts
def call(conn, _opts) do
user_id = get_session(conn, :user_id)
user =
cond do
assigned = conn.assigns[:current_user] -> assigned
true -> Accounts.get_user_from_id(user_id)
end
put_current_user(conn, user)
end
def authenticate_admin(conn = %{assigns: %{admin_user: true}}, _), do: conn
def authenticate_admin(conn, _opts) do
conn
|> put_flash(:error, "You do not have access to that page")
|> halt()
|> redirect(to: Routes.page_path(conn, :index))
end
def is_admin?(%{email: email}), do: email in @site_admins
def is_admin(_), do: false
def login(conn, user) do
conn
|> put_current_user(user)
|> put_session(:user_id, user.id)
|> configure_session(renew: true)
end
def logout(conn) do
drop_current_user(conn)
end
def logged_in_user(conn = %{assigns: %{current_user: %{}}}, _), do: conn
def logged_in_user(conn, _opts) do
conn
|> put_flash(:error, "You must be logged in to access that page")
|> redirect(to: Routes.page_path(conn, :index))
|> halt()
end
def put_current_user(conn, user) do
token = user && ReactorWeb.Token.sign(%{id: user.id})
conn
|> assign(:current_user, user)
|> assign(
:admin_user,
(user && user.email) in @site_admins
)
|> assign(:user_token, token)
end
def drop_current_user(conn) do
conn
|> delete_req_header("authorization")
|> configure_session(drop: true)
end
end
Update the router
Add the following code to the router:
defmodule ReactorWeb.Router do
use ReactorWeb, :router
pipeline :browser do
# ...
plug :put_root_layout, {ReactorWeb.LayoutView, :root}
plug ReactorWeb.Auth
end
# ...
scope "/", ReactorWeb do
pipe_through :browser
# ...
get("/forgot", SessionController, :forgot)
get("/login", SessionController, :new)
get("/login/:token/email/:email", SessionController, :create_from_token)
get("/logout", SessionController, :delete)
post("/forgot", SessionController, :reset_pass)
resources(
"/sessions",
SessionController,
only: [:new, :create, :delete],
singleton: true
)
# ...
end
end
Update UserLive.new
Then the final step is to update the save version of UserLive.new.handle_event/3
function to use all the utilities we've just added to log in new users once they're created.
def handle_event("save", %{"user" => params}, socket) do
case Accounts.create_user(params) do
{:ok, user} ->
token = Accounts.User.generate_login_token()
{:ok, user} = Accounts.update_login_token(user, token)
{:noreply,
socket
|> put_flash(:info, "user created")
|> redirect(
to:
Routes.session_path(
socket,
:create_from_token,
token,
user.email
)
)}
{:error, %Ecto.Changeset{} = cset} ->
{:noreply, assign(socket, changeset: cset)}
end
end
(Source code available for premium members)
Next episode: Adding comments
No Comments