mix phx.gen.auth

This commit is contained in:
sloane 2025-03-24 05:30:37 -04:00
parent e0de9ae8d9
commit 9779520d0c
Signed by: sloanelybutsurely
SSH key fingerprint: SHA256:8SBnwhl+RY3oEyQxy1a9wByPzxWM0x+/Ejc+sIlY5qQ
26 changed files with 1392 additions and 12 deletions

View file

@ -1,5 +1,5 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix, :typed_struct],
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Styler, Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]

View file

@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :argon2_elixir, t_cost: 1, m_cost: 8
config :logger, level: :warning
config :phoenix, :plug_init_mode, :runtime

View file

@ -1,4 +1,4 @@
defmodule Core do
@moduledoc false
use Boundary, deps: [Schema], exports: []
use Boundary, deps: [Schema], exports: [Accounts]
end

199
lib/core/accounts.ex Normal file
View file

@ -0,0 +1,199 @@
defmodule Core.Accounts do
@moduledoc """
The Accounts context.
"""
alias Core.Accounts.User
alias Core.Accounts.UserToken
alias Core.Repo
## Database getters
@doc """
Gets a user by username and password.
## Examples
iex> get_user_by_username_and_password("foo@example.com", "correct_password")
%Schema.User{}
iex> get_user_by_username_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_username_and_password(username, password) when is_binary(username) and is_binary(password) do
user = Repo.get_by(Schema.User, username: username)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%Schema.User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(Schema.User, id)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %Schema.User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%Schema.User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %Schema.User{}}
"""
def change_user_registration(%Schema.User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_username: false)
end
## Settings
@doc """
Returns an `%Ecto.Changeset{}` for changing the user username.
## Examples
iex> change_user_username(user)
%Ecto.Changeset{data: %Schema.User{}}
"""
def change_user_username(user, attrs \\ %{}) do
User.username_changeset(user, attrs, validate_username: false)
end
@doc """
Updates the user username.
If the token matches, the user username is updated and the token is deleted.
The confirmed_at date is also updated to the current time.
"""
def update_user_username(user, password, attrs) do
user
|> User.username_changeset(attrs)
|> User.validate_current_password(password)
|> Repo.update()
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %Schema.User{}}
"""
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %Schema.User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, password, attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(password)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
"""
def get_user_by_session_token(token) do
token
|> UserToken.Query.valid_session_token()
|> Repo.one()
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(UserToken.Query.where_token_and_context(token, "session"))
:ok
end
## Reset password
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %Schema.User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
end

140
lib/core/accounts/user.ex Normal file
View file

@ -0,0 +1,140 @@
defmodule Core.Accounts.User do
@moduledoc false
import Ecto.Changeset
@doc """
A user changeset for registration.
It is important to validate the length of both username and password.
Otherwise databases may truncate the username without warnings, which
could lead to unpredictable or insecure behaviour. Long passwords may
also be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
* `:validate_username` - Validates the uniqueness of the username, in case
you don't want to validate the uniqueness of the username (like when
using this changeset for validations on a LiveView form before
submitting the form), this option can be set to `false`.
Defaults to `true`.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:username, :password])
|> validate_username(opts)
|> validate_password(opts)
end
defp validate_username(changeset, opts) do
changeset
|> validate_required([:username])
|> validate_length(:username, max: 160)
|> maybe_validate_unique_username(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
defp maybe_validate_unique_username(changeset, opts) do
if Keyword.get(opts, :validate_username, true) do
changeset
|> unsafe_validate_unique(:username, Core.Repo)
|> unique_constraint(:username)
else
changeset
end
end
@doc """
A user changeset for changing the username.
It requires the username to change otherwise an error is added.
"""
def username_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:username])
|> validate_username(opts)
|> case do
%{changes: %{username: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :username, "did not change")
end
end
@doc """
A user changeset for changing the password.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Argon2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Schema.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Argon2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Argon2.no_user_verify()
false
end
@doc """
Validates the current password otherwise adds an error to the changeset.
"""
def validate_current_password(changeset, password) do
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password) do
changeset
else
add_error(changeset, :current_password, "is not valid")
end
end
end

View file

@ -0,0 +1,68 @@
defmodule Core.Accounts.UserToken do
@moduledoc false
@rand_size 32
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix' default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %Schema.UserToken{token: token, context: "session", user_id: user.id}}
end
defmodule Query do
@moduledoc false
import Ecto.Query
@session_validity_in_days 60
def base do
from Schema.UserToken, as: :user_tokens
end
def join_users(query \\ base()) do
join(query, :inner, [user_tokens: ut], u in assoc(ut, :user), as: :users)
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def valid_session_token(query \\ base(), token) do
query
|> where_token_and_context(token, "session")
|> join_users()
|> where([user_tokens: ut], ut.inserted_at > ago(@session_validity_in_days, "day"))
|> select([users: user], user)
end
def where_token_and_context(query \\ base(), token, context) do
where(query, [user_tokens: ut], ut.token == ^token and ut.context == ^context)
end
def for_user(query \\ base(), user) do
where(query, [user_tokens: t], t.user_id == ^user.id)
end
end
end

View file

@ -1,4 +1,4 @@
defmodule Schema do
@moduledoc false
use Boundary, deps: [], exports: []
use Boundary, deps: [], exports: [User, UserToken]
end

15
lib/schema/user.ex Normal file
View file

@ -0,0 +1,15 @@
defmodule Schema.User do
@moduledoc false
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :username, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :current_password, :string, virtual: true, redact: true
timestamps(type: :utc_datetime_usec)
end
end

15
lib/schema/user_token.ex Normal file
View file

@ -0,0 +1,15 @@
defmodule Schema.UserToken do
@moduledoc false
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Schema.User
timestamps(type: :utc_datetime_usec, updated_at: false)
end
end

View file

@ -0,0 +1,7 @@
defmodule Web.PageController do
use Web, :controller
def home(conn, _params) do
render(conn, :home)
end
end

View file

@ -0,0 +1,5 @@
defmodule Web.PageHTML do
use Web, :html
embed_templates "page_html/*"
end

View file

@ -0,0 +1 @@
<h1>Home</h1>

View file

@ -0,0 +1,41 @@
defmodule Web.UserSessionController do
use Web, :controller
alias Web.UserAuth
def create(conn, %{"_action" => "registered"} = params) do
create(conn, params, "Account created successfully!")
end
def create(conn, %{"_action" => "password_updated"} = params) do
conn
|> put_session(:user_return_to, ~p"/admin/users/settings")
|> create(params, "Password updated successfully!")
end
def create(conn, params) do
create(conn, params, "Welcome back!")
end
defp create(conn, %{"user" => user_params}, info) do
%{"username" => username, "password" => password} = user_params
if user = Core.Accounts.get_user_by_username_and_password(username, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the username is registered.
conn
|> put_flash(:error, "Invalid username or password")
|> put_flash(:username, String.slice(username, 0, 160))
|> redirect(to: ~p"/admin/users/log_in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -0,0 +1,47 @@
defmodule Web.UserLoginLive do
@moduledoc false
use Web, :live_view
def render(assigns) do
# ~H"""
# <div class="mx-auto max-w-sm">
# <.header class="text-center">
# Log in to account
# <:subtitle>
# Don't have an account?
# <.link navigate={~p/admin/users/register"} class="font-semibold text-brand hover:underline">
# Sign up
# </.link>
# for an account now.
# </:subtitle>
# </.header>
# <.simple_form for={@form} id="login_form" action={~p/admin/users/log_in"} phx-update="ignore">
# <.input field={@form[:email]} type="email" label="Email" required />
# <.input field={@form[:password]} type="password" label="Password" required />
# <:actions>
# <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
# <.link href={~p/admin/users/reset_password"} class="text-sm font-semibold">
# Forgot your password?
# </.link>
# </:actions>
# <:actions>
# <.button phx-disable-with="Logging in..." class="w-full">
# Log in <span aria-hidden="true">→</span>
# </.button>
# </:actions>
# </.simple_form>
# </div>
# """
~H"""
<pre>UserLoginLive</pre>
"""
end
def mount(_params, _session, socket) do
email = Phoenix.Flash.get(socket.assigns.flash, :email)
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: form]}
end
end

View file

@ -0,0 +1,84 @@
defmodule Web.UserRegistrationLive do
@moduledoc false
use Web, :live_view
alias Core.Accounts
def render(assigns) do
# ~H"""
# <div class="mx-auto max-w-sm">
# <.header class="text-center">
# Register for an account
# <:subtitle>
# Already registered?
# <.link navigate={~p/admin/users/log_in"} class="font-semibold text-brand hover:underline">
# Log in
# </.link>
# to your account now.
# </:subtitle>
# </.header>
# <.simple_form
# for={@form}
# id="registration_form"
# phx-submit="save"
# phx-change="validate"
# phx-trigger-action={@trigger_submit}
# action={~p/admin/users/log_in?_action=registered"}
# method="post"
# >
# <.error :if={@check_errors}>
# Oops, something went wrong! Please check the errors below.
# </.error>
# <.input field={@form[:email]} type="email" label="Email" required />
# <.input field={@form[:password]} type="password" label="Password" required />
# <:actions>
# <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
# </:actions>
# </.simple_form>
# </div>
# """
~H"""
<pre>UserRegistrationLive</pre>
"""
end
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%Schema.User{})
socket =
socket
|> assign(trigger_submit: false, check_errors: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
changeset = Accounts.change_user_registration(user)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%Schema.User{}, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
if changeset.valid? do
assign(socket, form: form, check_errors: false)
else
assign(socket, form: form)
end
end
end

View file

@ -0,0 +1,156 @@
defmodule Web.UserSettingsLive do
@moduledoc false
use Web, :live_view
alias Core.Accounts
def render(assigns) do
# ~H"""
# <.header class="text-center">
# Account Settings
# <:subtitle>Manage your account username address and password settings</:subtitle>
# </.header>
# <div class="space-y-12 divide-y">
# <div>
# <.simple_form
# for={@username_form}
# id="username_form"
# phx-submit="update_username"
# phx-change="validate_username"
# >
# <.input field={@username_form[:username]} type="text" label="Email" required />
# <.input
# field={@username_form[:current_password]}
# name="current_password"
# id="current_password_for_username"
# type="password"
# label="Current password"
# value={@username_form_current_password}
# required
# />
# <:actions>
# <.button phx-disable-with="Changing...">Change Email</.button>
# </:actions>
# </.simple_form>
# </div>
# <div>
# <.simple_form
# for={@password_form}
# id="password_form"
# action={~p/admin/users/log_in?_action=password_updated"}
# method="post"
# phx-change="validate_password"
# phx-submit="update_password"
# phx-trigger-action={@trigger_submit}
# >
# <input
# name={@password_form[:username].name}
# type="hidden"
# id="hidden_user_username"
# value={@current_username}
# />
# <.input field={@password_form[:password]} type="password" label="New password" required />
# <.input
# field={@password_form[:password_confirmation]}
# type="password"
# label="Confirm new password"
# />
# <.input
# field={@password_form[:current_password]}
# name="current_password"
# type="password"
# label="Current password"
# id="current_password_for_password"
# value={@current_password}
# required
# />
# <:actions>
# <.button phx-disable-with="Changing...">Change Password</.button>
# </:actions>
# </.simple_form>
# </div>
# </div>
# """
~H"""
<pre>UserSettingsLive</pre>
"""
end
def mount(_params, _session, socket) do
user = socket.assigns.current_user
username_changeset = Accounts.change_user_username(user)
password_changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(:current_password, nil)
|> assign(:username_form_current_password, nil)
|> assign(:current_username, user.username)
|> assign(:username_form, to_form(username_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
def handle_event("validate_username", params, socket) do
%{"current_password" => password, "user" => user_params} = params
username_form =
socket.assigns.current_user
|> Accounts.change_user_username(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, username_form: username_form, username_form_current_password: password)}
end
def handle_event("update_username", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_username(user, password, user_params) do
{:ok, updated_user} ->
username_changeset = Accounts.change_user_username(updated_user)
{:noreply,
socket
|> put_flash(:info, "Email updated")
|> assign(username_form_current_password: nil, username_form: to_form(username_changeset))}
{:error, changeset} ->
{:noreply, assign(socket, :username_form, to_form(Map.put(changeset, :action, :insert)))}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.current_user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form, current_password: password)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

View file

@ -1,6 +1,8 @@
defmodule Web.Router do
use Web, :router
import Web.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@ -8,5 +10,37 @@ defmodule Web.Router do
plug :put_root_layout, html: {Web.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
scope "/", Web do
pipe_through [:browser, :redirect_if_user_is_authenticated]
live_session :redirect_if_user_is_authenticated,
on_mount: [{Web.UserAuth, :redirect_if_user_is_authenticated}] do
live "/admin/users/register", UserRegistrationLive, :new
live "/admin/users/log_in", UserLoginLive, :new
end
post "/admin/users/log_in", UserSessionController, :create
end
scope "/admin", Web do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
end
end
scope "/", Web do
pipe_through [:browser]
get "/", PageController, :home
delete "/users/log_out", UserSessionController, :delete
# live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
# end
end
end

230
lib/web/user_auth.ex Normal file
View file

@ -0,0 +1,230 @@
defmodule Web.UserAuth do
@moduledoc false
use Web, :verified_routes
import Phoenix.Controller
import Plug.Conn
alias Core.Accounts
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
# the token expiry itself in UserToken.
@max_age 60 * 60 * 24 * 60
@remember_me_cookie "_core_web_user_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
@doc """
Logs the user in.
It renews the session ID and clears the whole session
to avoid fixation attacks. See the renew_session
function to customize this behaviour.
It also sets a `:live_socket_id` key in the session,
so LiveView sessions are identified and automatically
disconnected on log out. The line can be safely removed
if you are not using LiveView.
"""
def log_in_user(conn, user, params \\ %{}) do
token = Accounts.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end
defp maybe_write_remember_me_cookie(conn, _token, _params) do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn) do
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
Web.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session
and remember me token.
"""
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
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule Web.PageLive do
use Web, :live_view
on_mount {Web.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/admin/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns[:current_user] do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
@doc """
Used for routes that require the user to be authenticated.
If you want to enforce the user email is confirmed before
they use the application at all, here would be a good place.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/admin/users/log_in")
|> halt()
end
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: ~p"/"
end

View file

@ -33,6 +33,7 @@ defmodule SlaonelyButSurely.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:argon2_elixir, "~> 3.0"},
# Default Phoenix dependencies
{:phoenix, "~> 1.7.19"},
{:phoenix_ecto, "~> 4.5"},

View file

@ -1,5 +1,5 @@
%{
"argon2_elixir": {:hex, :argon2_elixir, "4.1.2", "1160a3ccd59b951175525882240651f5ed3303b75c616204713f8b31c76b37bd", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9222341e1b0d9aa5ca7e26a1c77bd1bd92d2314c92b57ca3e2c7ed847223b51d"},
"argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"},
"bandit": {:hex, :bandit, "1.6.7", "42f30e37a1c89a2a12943c5dca76f731a2313e8a2e21c1a95dc8241893e922d1", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "551ba8ff5e4fc908cbeb8c9f0697775fb6813a96d9de5f7fe02e34e76fd7d184"},
"boundary": {:hex, :boundary, "0.10.4", "5fec5d2736c12f9bfe1720c3a2bd8c48c3547c24d6002ebf8e087570afd5bd2f", [:mix], [], "hexpm", "8baf6f23987afdb1483033ed0bde75c9c703613c22ed58d5f23bf948f203247c"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},

View file

@ -0,0 +1,27 @@
defmodule Core.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true
add :username, :text, null: false
add :hashed_password, :text, null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(:users, [:username])
create table(:users_tokens, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :text, null: false
timestamps(type: :utc_datetime_usec, updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View file

@ -36,4 +36,30 @@ defmodule Test.ConnCase do
Test.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
# @doc """
# Setup helper that registers and logs in users.
# setup :register_and_log_in_user
# It stores an updated connection and a registered user in the
# test context.
# """
# def register_and_log_in_user(%{conn: conn}) do
# user = Core.AccountsFixtures.user_fixture()
# %{conn: log_in_user(conn, user), user: user}
# end
# @doc """
# Logs the given `user` into the `conn`.
# It returns an updated `conn`.
# """
# def log_in_user(conn, user) do
# token = Core.Accounts.generate_user_session_token(user)
# conn
# |> Phoenix.ConnTest.init_test_session(%{})
# |> Plug.Conn.put_session(:user_token, token)
# end
end

16
test/support/test/fixtures/accounts.ex vendored Normal file
View file

@ -0,0 +1,16 @@
defmodule Test.Fixtures.Accounts do
@moduledoc """
This module defines test helpers for creating
entities via the `Core.Accounts` context.
"""
def unique_user_username, do: "user#{System.unique_integer()}"
def valid_user_password, do: "hello world!"
def user(attrs \\ %{}) do
Enum.into(attrs, %{
username: unique_user_username(),
password: valid_user_password()
})
end
end

View file

@ -1,8 +0,0 @@
defmodule Test.Web.PageControllerTest do
use Test.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "sloanelybutsurely.com"
end
end

View file

@ -0,0 +1,273 @@
defmodule Test.Web.UserAuthTest do
use Test.ConnCase, async: true
alias Core.Accounts
alias Phoenix.LiveView
alias Web.UserAuth
@remember_me_cookie "_core_web_user_remember_me"
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, Web.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
{:ok, user} = Accounts.register_user(Test.Fixtures.Accounts.user())
%{user: user, conn: conn}
end
describe "log_in_user/3" do
test "stores the user token in the session", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
assert redirected_to(conn) == ~p"/"
assert Accounts.get_user_by_session_token(token)
end
test "clears everything previously stored in the session", %{conn: conn, user: user} do
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "redirects to the configured path", %{conn: conn, user: user} do
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
assert redirected_to(conn) == "/hello"
end
test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == 5_184_000
end
end
describe "logout_user/1" do
test "erases session and cookies", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn
|> put_session(:user_token, user_token)
|> put_req_cookie(@remember_me_cookie, user_token)
|> fetch_cookies()
|> UserAuth.log_out_user()
refute get_session(conn, :user_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
refute Accounts.get_user_by_session_token(user_token)
end
test "broadcasts to the given live_socket_id", %{conn: conn} do
live_socket_id = "users_sessions:abcdef-token"
Web.Endpoint.subscribe(live_socket_id)
conn
|> put_session(:live_socket_id, live_socket_id)
|> UserAuth.log_out_user()
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
end
test "works even if user is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
end
end
describe "fetch_current_user/2" do
test "authenticates user from session", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
assert conn.assigns.current_user.id == user.id
end
test "authenticates user from cookies", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
user_token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
conn =
conn
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_user([])
assert conn.assigns.current_user.id == user.id
assert get_session(conn, :user_token) == user_token
assert get_session(conn, :live_socket_id) ==
"users_sessions:#{Base.url_encode64(user_token)}"
end
test "does not authenticate if data is missing", %{conn: conn, user: user} do
_ = Accounts.generate_user_session_token(user)
conn = UserAuth.fetch_current_user(conn, [])
refute get_session(conn, :user_token)
refute conn.assigns.current_user
end
end
describe "on_mount :mount_current_user" do
test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user.id == user.id
end
test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user == nil
end
test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do
session = get_session(conn)
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user == nil
end
end
describe "on_mount :ensure_authenticated" do
test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user.id == user.id
end
test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: Web.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_user == nil
end
test "redirects to login page if there isn't a user_token", %{conn: conn} do
session = get_session(conn)
socket = %LiveView.Socket{
endpoint: Web.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_user == nil
end
end
describe "on_mount :redirect_if_user_is_authenticated" do
test "redirects if there is an authenticated user ", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
assert {:halt, _updated_socket} =
UserAuth.on_mount(
:redirect_if_user_is_authenticated,
%{},
session,
%LiveView.Socket{}
)
end
test "doesn't redirect if there is no authenticated user", %{conn: conn} do
session = get_session(conn)
assert {:cont, _updated_socket} =
UserAuth.on_mount(
:redirect_if_user_is_authenticated,
%{},
session,
%LiveView.Socket{}
)
end
end
describe "redirect_if_user_is_authenticated/2" do
test "redirects if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
assert conn.halted
assert redirected_to(conn) == ~p"/"
end
test "does not redirect if user is not authenticated", %{conn: conn} do
conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
refute conn.halted
refute conn.status
end
end
describe "require_authenticated_user/2" do
test "redirects if user is not authenticated", %{conn: conn} do
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
assert conn.halted
assert redirected_to(conn) == ~p"/admin/users/log_in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must log in to access this page."
end
test "stores the path to redirect to on GET", %{conn: conn} do
halted_conn =
%{conn | path_info: ["foo"], query_string: ""}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
refute get_session(halted_conn, :user_return_to)
end
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
refute conn.halted
refute conn.status
end
end
end