Building a password-change UI
View SourceAshAuthentication's Igniter task adds a :change_password
action when you specify the password strategy, but AshAuthenticationPhoenix does not provide a component for this action, so you will need to either write your own, or use one provided by a component library that supports AshPhoenix. The main reason for this is that the password-change UI is usually not as separate from the rest of an application as sign-in, registration, and password-reset actions.
This is the :change_password
action that we are starting with, generated by Igniter.
# lib/my_app/accounts/user.ex
# ...
update :change_password do
require_atomic? false
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false
argument :password, :string,
sensitive?: true,
allow_nil?: false,
constraints: [min_length: 8]
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
validate confirm(:password, :password_confirmation)
validate {AshAuthentication.Strategy.Password.PasswordValidation,
strategy_name: :password, password_argument: :current_password}
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
end
# ...
LiveComponent
Most web applications that you have used likely had the UI for changing your password somewhere under your personal settings. We are going to do the same, and create a LiveComponent to contain our password-change UI, which we can then mount in a LiveView with the rest of the user settings.
Start by defining the module, and defining the template in the render/1
function.
# lib/my_app_web/components/change_password_component.ex
defmodule MyAppWeb.ChangePasswordComponent do
@moduledoc """
LiveComponent for changing the current user's password.
"""
use MyAppWeb, :live_component
alias MyApp.Accounts.User
@impl true
def render(assigns) do
~H"""
<div>
<.simple_form
for={@form}
id="user-password-change-form"
phx-target={@myself}
phx-submit="save"
>
<.input field={@form[:current_password]} type="password" label="Current Password" />
<.input field={@form[:password]} type="password" label="New Password" />
<.input field={@form[:password_confirmation]} type="password" label="Confirm New Password" />
<:actions>
<.button phx-disable-with="Saving...">Save</.button>
</:actions>
</.simple_form>
</div>
"""
end
end
This will produce a form with the usual three fields (current password, new password, and confirmation of new password, to guard against typographical errors), and a submit button. Now let's populate the @form
assign.
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
defp assign_form(%{assigns: %{current_user: user}} = socket) do
form = AshPhoenix.Form.for_update(user, :change_password, as: "user", actor: user)
assign(socket, form: to_form(form))
end
update/2
is covered in the Phoenix.LiveComponent
life-cycle documentation, so we won't go into it here. The private function assign_form/1
should look familiar if you have any forms for Ash resources in your application, but with a significant addition: the prepare_source
option.
The attribute phx-target={@myself}
on the form in our template ensures the submit event is received by the component, so the handle_event/3
function in this module is called, rather than the LiveView that mounts this component receiving the event.
@impl true
def handle_event("save", %{"user" => user_params}, %{assigns: assigns} = socket) do
case AshPhoenix.Form.submit(assigns.form, params: user_params) do
{:ok, user} ->
assigns.on_saved.()
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
Again, this should look familiar if you have any forms on your other application resources, but handle the success case a little differently here, calling the function passed by the parent LiveView via the on_saved
attribute. This will make more sense when we use this component in a LiveView.
Policies
Since the password-change workflow is done entirely in our application code, the AshAuthentication policy bypass will not pass. We need to add a policy that allows a user to run the :change_password
action on themselves.
# lib/my_app/accounts/user.ex
# ...
policies do
# ...
policy action(:change_password) do
description "Users can change their own password"
authorize_if expr(id == ^actor(:id))
end
end
# ...
Using the LiveComponent
Finally, let's use this component in our UI somewhere. Exactly where this belongs will depend on the wider UX of your application, so for the sake of example, let's assume that you already have a LiveView called LiveUserSettings
in your application, where you want to add the password-change form.
defmodule MyAppWeb.LiveUserSettings do
@moduledoc """
LiveView for the current user's account settings.
"""
use MyAppWeb, :live_view
alias MyAppWeb.ChangePasswordComponent
@impl true
def render(assigns) do
~H"""
<.header>
Settings
</.header>
<% # ... %>
<.live_component
module={ChangePasswordComponent}
id="change-password-component"
current_user={@current_user}
on_saved={fn -> send(self(), {:saved, :password}) end}
/>
<% # ... %>
"""
end
@impl true
def handle_info({:saved, :password}, socket) do
{:noreply,
socket
|> put_flash(:info, "Password changed successfully")}
end
end
For the on_saved
callback attribute mentioned earlier, we pass a function that sends a message to the process for this LiveView, and then write a handle_info/2
clause that matches this message and which puts up an info flash informing the user that the password change succeeded. This interface decouples ChangePasswordComponent
from where it is used. It manages only the password-change form, and leaves user feedback up to the parent. You could put the form in a modal that closes when the form submits successfully without having to change any code in the component, only LiveUserSettings
.
Security Email Notification
This gets you a working password-change UI, but you should also send an email notification to the user upon a password change. The reason for this is so that in the case of an account compromise, the attacker cannot change the password and lock out the rightful owner without alerting them.
The simplest way to do this is with a notifier.
# ...
update :change_password do
notifiers [MyApp.Notifiers.EmailNotifier]
# ...
# lib/my_app/notifiers/email_notifier.ex
defmodule MyApp.Notifiers.EmailNotifier do
use Ash.Notifier
alias MyApp.Accounts.User
@impl true
def notify(%Ash.Notifier.Notification{action: %{name: :change_password}, resource: user}) do
User.Email.deliver_password_change_notification(user)
end
end
defmodule MyApp.Accounts.User.Email do
@moduledoc """
Email notifications for `User` records.
"""
def deliver_password_change_notification(user) do
{"""
<html>
<p>
Hi #{user.display_name},
</p>
<p>
Someone just changed your password. If this was not you,
please <a href="mailto:support@example.com">contact support</a>
<em>immediately</em>, because it means someone else has taken over
your account.
</p>
</html>
""",
"""
Hi #{user.display_name},
Someone just changed your password. If this was not you, please contact
support <support@example.net> <em>immediately</em>, because it means
someone else has taken over your account.
"""}
|> MyApp.Mailer.send_mail_to_user("Your password has been changed", user)
end
end
MyApp.Mailer.send_mail_to_user/3
would be your application's internal interface to whichever mailer you are using, such as Swoosh or Bamboo, that takes the HTML and text email bodies in a two-element tuple, the subject line, and the recipient user.
Field Policies
If you are not using field policies, or you are using field policies with private_fields :show
(the default), you can skip this section.
When using field policies, the @current_user
assign set by AshAuthentication may not contain the value of the hashed_password
attribute, because it is a private field (if you are using private_fields :hide
or private_fields :include
). This is what you normally want in your application, but the :change_password
action needs this to validate the :current_password
argument, you will need to explicitly load this attribute when creating the form in ChangePasswordComponent
.
Load hashed_password
in the form
Change the function assign_form/1
in ChangePasswordComponent
as follows.
defp assign_form(%{assigns: %{current_user: user}} = socket) do
form =
AshPhoenix.Form.for_update(user, :change_password,
as: "user",
# Add this argument
prepare_source: fn changeset ->
%{
changeset
| data:
Ash.load!(changeset.data, :hashed_password,
context: %{private: %{password_change?: true}}
)
}
end,
actor: user
)
assign(socket, form: to_form(form))
end
There are a couple of things going on here:
- We are calling
Ash.load!/3
to load the attributehashed_password
on the record in the changeset. - Setting a private context field for this
Ash.load!/3
call to be used in a field policy bypass that we need to write in order for this load to succeed.
Field Policy Bypass
The actual work will be done in a separate policy check module, so our bypass will look very simple. If you are using private_fields :hide
, you will need to change it to private_fields :include
, otherwise the hashed_password
field will always be hidden, regardless of any field policy bypass.
# lib/my_app/accounts/user.ex
# ...
field_policies do
private_fields :include
# ...
field_policy_bypass :* do
description "Users can access all fields for password change"
authorize_if MyApp.Checks.PasswordChangeInteraction
end
end
# ...
# lib/my_app/checks/password_change_interaction.ex
defmodule MyApp.Checks.PasswordChangeInteraction do
use Ash.Policy.SimpleCheck
@impl Ash.Policy.Check
def describe(_) do
"MyApp is performing a password change for this interaction"
end
@impl Ash.Policy.SimpleCheck
def match?(_, %{subject: %{context: %{private: %{password_change?: true}}}}, _), do: true
def match?(_, _, _), do: false
end
This is actually how AshAuthentication.Checks.AshAuthenticationInteraction
is implemented, only matching a slightly different pattern.