feat(user): use gen auth for users

This commit is contained in:
Anhgelus Morhtuuzh 2025-08-14 20:49:11 +02:00
parent fbb65e77c0
commit c1385df0f9
Signed by: anhgelus
GPG key ID: 617773CACE89052C
36 changed files with 3256 additions and 129 deletions

View file

@ -0,0 +1,297 @@
defmodule LearningPhoenix.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias LearningPhoenix.Repo
alias LearningPhoenix.Accounts.{User, UserToken, UserNotifier}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
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)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `LearningPhoenix.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
def update_user_email(user, token) do
context = "change:#{user.email}"
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `LearningPhoenix.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
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.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the user with the given magic link token.
"""
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
{user, _token} <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Logs the user in by magic link.
There are three cases to consider:
1. The user has already confirmed their email. They are logged in
and the magic link is expired.
2. The user has not confirmed their email and no password is set.
In this case, the user gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.
3. The user has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password registration" section of
`mix help phx.gen.auth`.
"""
def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!
This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
"""
{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
{user, token} ->
Repo.delete!(token)
{:ok, {user, []}}
nil ->
{:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given user.
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
@doc """
Delivers the magic link login instructions to the given user.
"""
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
end

View file

@ -0,0 +1,33 @@
defmodule LearningPhoenix.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `LearningPhoenix.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use as authorization, or to
ensure specific code paths can only be access for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias LearningPhoenix.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end

View file

@ -0,0 +1,132 @@
defmodule LearningPhoenix.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, LearningPhoenix.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A user changeset for changing the password.
It is important to validate the length of the password, as long passwords may
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`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(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
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# 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, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%LearningPhoenix.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View file

@ -0,0 +1,84 @@
defmodule LearningPhoenix.Accounts.UserNotifier do
import Swoosh.Email
alias LearningPhoenix.Mailer
alias LearningPhoenix.Accounts.User
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"LearningPhoenix", "contact@example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """
==============================
Hi #{user.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(user, url) do
case user do
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
_ -> deliver_magic_link_instructions(user, url)
end
end
defp deliver_magic_link_instructions(user, url) do
deliver(user.email, "Log in instructions", """
==============================
Hi #{user.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end

View file

@ -0,0 +1,156 @@
defmodule LearningPhoenix.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias LearningPhoenix.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :user, LearningPhoenix.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@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)
dt = user.authenticated_at || DateTime.utc_now(:second)
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
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, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{user, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks if the token is being used within
15 minutes. The context of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user_token found by the token, if any.
This is used to validate requests to change the user
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end

View file

@ -1,24 +0,0 @@
defmodule LearningPhoenix.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :password, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user, attrs) do
IO.puts("called")
user
|> cast(attrs, [:name, :email, :password])
|> validate_required([:name, :email, :password])
|> validate_length(:name, min: 2)
|> validate_length(:name, max: 30)
|> validate_format(:email, ~r/.+@.+\.[a-z]+/)
|> unique_constraint(:email)
end
end

View file

@ -31,6 +31,26 @@
</script>
</head>
<body>
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_scope do %>
<li>
{@current_scope.user.email}
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
{@inner_content}
</body>
</html>

View file

@ -1,36 +0,0 @@
defmodule LearningPhoenixWeb.UserController do
use LearningPhoenixWeb, :controller
alias LearningPhoenix.{Repo, User}
def index(conn, _params) do
conn
|> assign(:test, Repo.all(User))
|> render(:index)
end
def edit(conn, _params) do
render(conn, :edit)
end
def new(conn, _params) do
render(conn, :new)
end
def show(conn, _params) do
render(conn, :show)
end
def create(conn, _params) do
#redirect(conn, url(~p"/users/#{id}"))
redirect(conn, url(~p"/users"))
end
def update(conn, _params) do
#redirect(conn, url(~p"/users/#{id}"))
redirect(conn, url(~p"/users"))
end
def delete(conn, _params) do
redirect(conn, url(~p"/users"))
end
end

View file

@ -1,10 +0,0 @@
defmodule LearningPhoenixWeb.UserHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use LearningPhoenixWeb, :html
embed_templates "user_html/*"
end

View file

@ -1,9 +0,0 @@
<Layouts.flash_group flash={@flash} />
<div class="m-32 text-center">
<h1 class="text-4xl font-bold mb-8">
Page d'edit des utilisateurs
</h1>
<p class="text-xl">
Cette page permet de modifier un utilisateur
</p>
</div>

View file

@ -1,18 +0,0 @@
<Layouts.flash_group flash={@flash} />
<div class="m-32 text-center">
<h1 class="text-4xl font-bold mb-8">
Liste de tous les utilisateurs
</h1>
<p class="text-xl">
Cette page contient la liste de tous les utilisateurs
</p>
<div class="flex flex-col gap-4 justify-center justify-items-center">
<%= for user <- @test do %>
<div class="flex flex-col gap-2">
<p>Hello {user.name}!</p>
<p>Your email is {user.email}</p>
<p>And your hashed password is {user.password}.</p>
</div>
<% end %>
</div>
</div>

View file

@ -1,9 +0,0 @@
<Layouts.flash_group flash={@flash} />
<div class="m-32 text-center">
<h1 class="text-4xl font-bold mb-8">
Création d'utilisateur
</h1>
<p class="text-xl">
Cette page permet de créer un utilisateur.
</p>
</div>

View file

@ -1,9 +0,0 @@
<Layouts.flash_group flash={@flash} />
<div class="m-32 text-center">
<h1 class="text-4xl font-bold mb-8">
Info sur un utilisateur en particulier
</h1>
<p class="text-xl">
Cette page donne les info sur un utilisateur en particulier
</p>
</div>

View file

@ -0,0 +1,67 @@
defmodule LearningPhoenixWeb.UserSessionController do
use LearningPhoenixWeb, :controller
alias LearningPhoenix.Accounts
alias LearningPhoenixWeb.UserAuth
def create(conn, %{"_action" => "confirmed"} = params) do
create(conn, params, "User confirmed successfully.")
end
def create(conn, params) do
create(conn, params, "Welcome back!")
end
# magic link login
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
case Accounts.login_user_by_magic_link(token) do
{:ok, {user, tokens_to_disconnect}} ->
UserAuth.disconnect_sessions(tokens_to_disconnect)
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
_ ->
conn
|> put_flash(:error, "The link is invalid or it has expired.")
|> redirect(to: ~p"/users/log-in")
end
end
# email + password login
defp create(conn, %{"user" => user_params}, info) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, 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 email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/users/log-in")
end
end
def update_password(conn, %{"user" => user_params} = params) do
user = conn.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
{:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params)
# disconnect all existing LiveViews with old sessions
UserAuth.disconnect_sessions(expired_tokens)
conn
|> put_session(:user_return_to, ~p"/users/settings")
|> create(params, "Password updated successfully!")
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -0,0 +1,94 @@
defmodule LearningPhoenixWeb.UserLive.Confirmation do
use LearningPhoenixWeb, :live_view
alias LearningPhoenix.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>Welcome {@user.email}</.header>
</div>
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
phx-mounted={JS.focus_first()}
phx-submit="submit"
action={~p"/users/log-in?_action=confirmed"}
phx-trigger-action={@trigger_submit}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="btn btn-primary w-full"
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
Confirm and log in only this time
</.button>
</.form>
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
phx-submit="submit"
phx-mounted={JS.focus_first()}
action={~p"/users/log-in"}
phx-trigger-action={@trigger_submit}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button phx-disable-with="Logging in..." class="btn btn-primary w-full">
Log in
</.button>
<% else %>
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="btn btn-primary w-full"
>
Keep me logged in on this device
</.button>
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
Log me in only this time
</.button>
<% end %>
</.form>
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
Tip: If you prefer passwords, you can enable them in the user settings.
</p>
</div>
</Layouts.app>
"""
end
@impl true
def mount(%{"token" => token}, _session, socket) do
if user = Accounts.get_user_by_magic_link_token(token) do
form = to_form(%{"token" => token}, as: "user")
{:ok, assign(socket, user: user, form: form, trigger_submit: false),
temporary_assigns: [form: nil]}
else
{:ok,
socket
|> put_flash(:error, "Magic link is invalid or it has expired.")
|> push_navigate(to: ~p"/users/log-in")}
end
end
@impl true
def handle_event("submit", %{"user" => params}, socket) do
{:noreply, assign(socket, form: to_form(params, as: "user"), trigger_submit: true)}
end
end

View file

@ -0,0 +1,131 @@
defmodule LearningPhoenixWeb.UserLive.Login do
use LearningPhoenixWeb, :live_view
alias LearningPhoenix.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<% end %>
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<.form
:let={f}
for={@form}
id="login_form_magic"
action={~p"/users/log-in"}
phx-submit="submit_magic"
>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true"></span>
</.button>
</.form>
<div class="divider">or</div>
<.form
:let={f}
for={@form}
id="login_form_password"
action={~p"/users/log-in"}
phx-submit="submit_password"
phx-trigger-action={@trigger_submit}
>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
required
/>
<.input
field={@form[:password]}
type="password"
label="Password"
autocomplete="current-password"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true"></span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
email =
Phoenix.Flash.get(socket.assigns.flash, :email) ||
get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form, trigger_submit: false)}
end
@impl true
def handle_event("submit_password", _params, socket) do
{:noreply, assign(socket, :trigger_submit, true)}
end
def handle_event("submit_magic", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions for logging in shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> push_navigate(to: ~p"/users/log-in")}
end
defp local_mail_adapter? do
Application.get_env(:learning_phoenix, LearningPhoenix.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View file

@ -0,0 +1,88 @@
defmodule LearningPhoenixWeb.UserLive.Registration do
use LearningPhoenixWeb, :live_view
alias LearningPhoenix.Accounts
alias LearningPhoenix.Accounts.User
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
</div>
<.form for={@form} id="registration_form" phx-submit="save" phx-change="validate">
<.input
field={@form[:email]}
type="email"
label="Email"
autocomplete="username"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, %{assigns: %{current_scope: %{user: user}}} = socket)
when not is_nil(user) do
{:ok, redirect(socket, to: LearningPhoenixWeb.UserAuth.signed_in_path(socket))}
end
def mount(_params, _session, socket) do
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
{:noreply,
socket
|> put_flash(
:info,
"An email was sent to #{user.email}, please access it to confirm your account."
)
|> push_navigate(to: ~p"/users/log-in")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_email(%User{}, user_params, validate_unique: false)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
assign(socket, form: form)
end
end

View file

@ -0,0 +1,157 @@
defmodule LearningPhoenixWeb.UserLive.Settings do
use LearningPhoenixWeb, :live_view
on_mount {LearningPhoenixWeb.UserAuth, :require_sudo_mode}
alias LearningPhoenix.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email">
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
/>
<.button variant="primary" phx-disable-with="Saving...">
Save Password
</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_scope.user, token) do
{:ok, _user} ->
put_flash(socket, :info, "Email changed successfully.")
{:error, _} ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
socket =
socket
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
@impl true
def handle_event("validate_email", params, socket) do
%{"user" => user_params} = params
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
def handle_event("validate_password", params, socket) do
%{"user" => user_params} = params
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
end

View file

@ -1,6 +1,8 @@
defmodule LearningPhoenixWeb.Router do
use LearningPhoenixWeb, :router
import LearningPhoenixWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@ -8,6 +10,7 @@ defmodule LearningPhoenixWeb.Router do
plug :put_root_layout, html: {LearningPhoenixWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
@ -18,7 +21,6 @@ defmodule LearningPhoenixWeb.Router do
pipe_through :browser
get "/", PageController, :home
resources "/users", UserController
end
# Other scopes may use custom stacks.
@ -42,4 +44,32 @@ defmodule LearningPhoenixWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", LearningPhoenixWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{LearningPhoenixWeb.UserAuth, :require_authenticated}] do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
end
post "/users/update-password", UserSessionController, :update_password
end
scope "/", LearningPhoenixWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{LearningPhoenixWeb.UserAuth, :mount_current_scope}] do
live "/users/register", UserLive.Registration, :new
live "/users/log-in", UserLive.Login, :new
live "/users/log-in/:token", UserLive.Confirmation, :new
end
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end

View file

@ -0,0 +1,287 @@
defmodule LearningPhoenixWeb.UserAuth do
use LearningPhoenixWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias LearningPhoenix.Accounts
alias LearningPhoenix.Accounts.Scope
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken.
@max_cookie_age_in_days 14
@remember_me_cookie "_learning_phoenix_web_user_remember_me"
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
]
# How old the session token should be before a new one is issued. When a request is made
# with a session token older than this value, then a new session token will be created
# and the session and remember-me cookies (if set) will be updated with the new token.
# Lowering this value will result in more tokens being created by active users. Increasing
# it will result in less time before a session token expires for a user to get issued a new
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
# the reissuing of tokens completely.
@session_reissue_age_in_days 7
@doc """
Logs the user in.
Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`.
"""
def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to)
conn
|> create_or_extend_session(user, params)
|> redirect(to: user_return_to || signed_in_path(conn))
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
LearningPhoenixWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
"""
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
end
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, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
else
nil
end
end
end
# Reissue the session token if it is older than the configured reissue age.
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
if token_age >= @session_reissue_age_in_days do
create_or_extend_session(conn, user, %{})
else
conn
end
end
# This function is the one responsible for creating session tokens
# and storing them safely in the session and cookies. It may be called
# either when logging in, during sudo mode, or to renew a session which
# will soon expire.
#
# When the session is created, rather than extended, the renew_session
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, user, params) do
token = Accounts.generate_user_session_token(user)
remember_me = get_session(conn, :user_remember_me)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
# Do not renew session if the user is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id 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, _user) do
# delete_csrf_token()
# 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, _user) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, token, _params, true),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
defp write_remember_me_cookie(conn, token) do
conn
|> put_session(:user_remember_me, true)
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, user_session_topic(token))
end
@doc """
Disconnects existing sockets for the given tokens.
"""
def disconnect_sessions(tokens) do
Enum.each(tokens, fn %{token: token} ->
LearningPhoenixWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{})
end)
end
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
@doc """
Handles mounting and authenticating the current_scope in LiveViews.
## `on_mount` arguments
* `:mount_current_scope` - Assigns current_scope
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:require_authenticated` - Authenticates the user from the session,
and assigns the current_scope to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the `current_scope`:
defmodule LearningPhoenixWeb.PageLive do
use LearningPhoenixWeb, :live_view
on_mount {LearningPhoenixWeb.UserAuth, :mount_current_scope}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{LearningPhoenixWeb.UserAuth, :require_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_scope, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
def on_mount(:require_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.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"/users/log-in")
{:halt, socket}
end
end
def on_mount(:require_sudo_mode, _params, session, socket) do
socket = mount_current_scope(socket, session)
if Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
{user, _} =
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end || {nil, nil}
Scope.for_user(user)
end)
end
@doc "Returns the path to redirect to after log in."
# the user was already logged in, redirect to settings
def signed_in_path(%Plug.Conn{assigns: %{current_scope: %Scope{user: %Accounts.User{}}}}) do
~p"/users/settings"
end
def signed_in_path(_), do: ~p"/"
@doc """
Plug for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
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
end