Skip to content

Commit 959c127

Browse files
authored
docs: password change UI tutorial (#656)
* docs: password change UI tutorial Add tutorial for implementing a password-change UI. * docs(fix): Put field policy bypass in a separate section
1 parent fa724a8 commit 959c127

File tree

3 files changed

+284
-1
lines changed

3 files changed

+284
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Welcome! Here you will find everything you need to know to get started with Ash
3131
- [Get Started](documentation/tutorials/get-started.md)
3232
- [Using with LiveView](documentation/tutorials/liveview.md)
3333
- [Overriding UI](documentation/tutorials/ui-overrides.md)
34+
- [Password-Change UI](documentation/tutorials/password-change.md)
3435

3536
## Related packages
3637

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
# Building a password-change UI
2+
AshAuthentication'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](https://hexdocs.pm/ash_phoenix/). 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.
3+
4+
This is the `:change_password` action that we are starting with, generated by Igniter.
5+
```elixir
6+
# lib/my_app/accounts/user.ex
7+
# ...
8+
update :change_password do
9+
require_atomic? false
10+
accept []
11+
argument :current_password, :string, sensitive?: true, allow_nil?: false
12+
13+
argument :password, :string,
14+
sensitive?: true,
15+
allow_nil?: false,
16+
constraints: [min_length: 8]
17+
18+
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
19+
20+
validate confirm(:password, :password_confirmation)
21+
22+
validate {AshAuthentication.Strategy.Password.PasswordValidation,
23+
strategy_name: :password, password_argument: :current_password}
24+
25+
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
26+
end
27+
# ...
28+
```
29+
30+
## LiveComponent
31+
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](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html) to contain our password-change UI, which we can then mount in a LiveView with the rest of the user settings.
32+
33+
Start by defining the module, and defining the template in the `render/1` function.
34+
```elixir
35+
# lib/my_app_web/components/change_password_component.ex
36+
defmodule MyAppWeb.ChangePasswordComponent do
37+
@moduledoc """
38+
LiveComponent for changing the current user's password.
39+
"""
40+
use MyAppWeb, :live_component
41+
alias MyApp.Accounts.User
42+
43+
@impl true
44+
def render(assigns) do
45+
~H"""
46+
<div>
47+
<.simple_form
48+
for={@form}
49+
id="user-password-change-form"
50+
phx-target={@myself}
51+
phx-submit="save"
52+
>
53+
<.input field={@form[:current_password]} type="password" label="Current Password" />
54+
<.input field={@form[:password]} type="password" label="New Password" />
55+
<.input field={@form[:password_confirmation]} type="password" label="Confirm New Password" />
56+
<:actions>
57+
<.button phx-disable-with="Saving...">Save</.button>
58+
</:actions>
59+
</.simple_form>
60+
</div>
61+
"""
62+
end
63+
end
64+
```
65+
66+
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.
67+
68+
```elixir
69+
@impl true
70+
def update(assigns, socket) do
71+
{:ok,
72+
socket
73+
|> assign(assigns)
74+
|> assign_form()}
75+
end
76+
77+
defp assign_form(%{assigns: %{current_user: user}} = socket) do
78+
form = AshPhoenix.Form.for_update(user, :change_password, as: "user", actor: user)
79+
assign(socket, form: to_form(form))
80+
end
81+
```
82+
`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.
83+
84+
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.
85+
86+
```elixir
87+
@impl true
88+
def handle_event("save", %{"user" => user_params}, %{assigns: assigns} = socket) do
89+
case AshPhoenix.Form.submit(assigns.form, params: user_params) do
90+
{:ok, user} ->
91+
assigns.on_saved.()
92+
{:noreply, socket}
93+
94+
{:error, form} ->
95+
{:noreply, assign(socket, form: form)}
96+
end
97+
end
98+
```
99+
100+
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.
101+
102+
## Policies
103+
Since the password-change workflow is done entirely in our application code, the [AshAuthentication policy bypass](https://hexdocs.pm/ash_authentication/policies-on-authentication-resources.html) will not pass. We need to add a policy that allows a user to run the `:change_password` action on themselves.
104+
```elixir
105+
# lib/my_app/accounts/user.ex
106+
# ...
107+
policies do
108+
# ...
109+
policy action(:change_password) do
110+
description "Users can change their own password"
111+
authorize_if expr(id == ^actor(:id))
112+
end
113+
end
114+
# ...
115+
```
116+
117+
## Using the LiveComponent
118+
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.
119+
120+
```elixir
121+
defmodule MyAppWeb.LiveUserSettings do
122+
@moduledoc """
123+
LiveView for the current user's account settings.
124+
"""
125+
use MyAppWeb, :live_view
126+
alias MyAppWeb.ChangePasswordComponent
127+
128+
@impl true
129+
def render(assigns) do
130+
~H"""
131+
<.header>
132+
Settings
133+
</.header>
134+
<% # ... %>
135+
<.live_component
136+
module={ChangePasswordComponent}
137+
id="change-password-component"
138+
current_user={@current_user}
139+
on_saved={fn -> send(self(), {:saved, :password}) end}
140+
/>
141+
<% # ... %>
142+
"""
143+
end
144+
145+
@impl true
146+
def handle_info({:saved, :password}, socket) do
147+
{:noreply,
148+
socket
149+
|> put_flash(:info, "Password changed successfully")}
150+
end
151+
end
152+
```
153+
154+
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`.
155+
156+
## Security Email Notification
157+
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.
158+
159+
The simplest way to do this is with a [notifier](https://hexdocs.pm/ash/notifiers.html).
160+
```elixir
161+
# ...
162+
update :change_password do
163+
notifiers [MyApp.Notifiers.EmailNotifier]
164+
# ...
165+
```
166+
```elixir
167+
# lib/my_app/notifiers/email_notifier.ex
168+
defmodule MyApp.Notifiers.EmailNotifier do
169+
use Ash.Notifier
170+
alias MyApp.Accounts.User
171+
172+
@impl true
173+
def notify(%Ash.Notifier.Notification{action: %{name: :change_password}, resource: user}) do
174+
User.Email.deliver_password_change_notification(user)
175+
end
176+
end
177+
```
178+
179+
```elixir
180+
defmodule MyApp.Accounts.User.Email do
181+
@moduledoc """
182+
Email notifications for `User` records.
183+
"""
184+
185+
def deliver_password_change_notification(user) do
186+
{"""
187+
<html>
188+
<p>
189+
Hi #{user.display_name},
190+
</p>
191+
<p>
192+
Someone just changed your password. If this was not you,
193+
please <a href="mailto:[email protected]">contact support</a>
194+
<em>immediately</em>, because it means someone else has taken over
195+
your account.
196+
</p>
197+
</html>
198+
""",
199+
"""
200+
Hi #{user.display_name},
201+
202+
Someone just changed your password. If this was not you, please contact
203+
support <[email protected]> <em>immediately</em>, because it means
204+
someone else has taken over your account.
205+
"""}
206+
|> MyApp.Mailer.send_mail_to_user("Your password has been changed", user)
207+
end
208+
end
209+
```
210+
211+
`MyApp.Mailer.send_mail_to_user/3` would be your application's internal interface to whichever mailer you are using, such as [Swoosh](https://hexdocs.pm/swoosh) or [Bamboo](https://hexdocs.pm/bamboo), that takes the HTML and text email bodies in a two-element tuple, the subject line, and the recipient user.
212+
213+
# Field Policies
214+
If you are not using field policies, or you are using field policies with `private_fields :show` (the default), you can skip this section.
215+
216+
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`.
217+
218+
### Load `hashed_password` in the form
219+
Change the function `assign_form/1` in `ChangePasswordComponent` as follows.
220+
221+
```elixir
222+
defp assign_form(%{assigns: %{current_user: user}} = socket) do
223+
form =
224+
AshPhoenix.Form.for_update(user, :change_password,
225+
as: "user",
226+
# Add this argument
227+
prepare_source: fn changeset ->
228+
%{
229+
changeset
230+
| data:
231+
Ash.load!(changeset.data, :hashed_password,
232+
context: %{private: %{password_change?: true}}
233+
)
234+
}
235+
end,
236+
actor: user
237+
)
238+
239+
assign(socket, form: to_form(form))
240+
end
241+
```
242+
243+
There are a couple of things going on here:
244+
1. We are calling `Ash.load!/3` to load the attribute `hashed_password` on the record in the changeset.
245+
2. 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.
246+
247+
### Field Policy Bypass
248+
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.
249+
```elixir
250+
# lib/my_app/accounts/user.ex
251+
# ...
252+
field_policies do
253+
private_fields :include
254+
255+
# ...
256+
257+
field_policy_bypass :* do
258+
description "Users can access all fields for password change"
259+
authorize_if MyApp.Checks.PasswordChangeInteraction
260+
end
261+
end
262+
# ...
263+
```
264+
265+
```elixir
266+
# lib/my_app/checks/password_change_interaction.ex
267+
defmodule MyApp.Checks.PasswordChangeInteraction do
268+
use Ash.Policy.SimpleCheck
269+
270+
@impl Ash.Policy.Check
271+
def describe(_) do
272+
"MyApp is performing a password change for this interaction"
273+
end
274+
275+
@impl Ash.Policy.SimpleCheck
276+
def match?(_, %{subject: %{context: %{private: %{password_change?: true}}}}, _), do: true
277+
def match?(_, _, _), do: false
278+
end
279+
```
280+
281+
This is actually how `AshAuthentication.Checks.AshAuthenticationInteraction` is implemented, only matching a slightly different pattern.

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ defmodule AshAuthentication.Phoenix.MixProject do
2828
"README.md",
2929
{"documentation/tutorials/get-started.md", title: "Get Started"},
3030
{"documentation/tutorials/liveview.md", title: "LiveView Routes"},
31-
{"documentation/tutorials/ui-overrides.md", title: "UI Overrides"}
31+
{"documentation/tutorials/ui-overrides.md", title: "UI Overrides"},
32+
{"documentation/tutorials/password-change.md", title: "Password-Change UI"}
3233
],
3334
redirects: %{
3435
"getting-started-with-ash-authentication-phoenix" => "get-started"

0 commit comments

Comments
 (0)