|
| 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. |
0 commit comments