diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index a29b63fb..27bc106e 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -293,6 +293,47 @@ defmodule Microwaveprop.Accounts do UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) end + ## Password reset + + @doc ~S""" + Delivers self-service password-reset instructions to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset-password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by a password-reset token, or nil if the token is invalid or expired. + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_password_reset_token_query(token), + {%User{} = user, _token} <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password and revokes all tokens for that user. + + Returns `{:ok, {user, expired_tokens}}` on success. + """ + def reset_user_password(%User{} = user, attrs) do + user + |> User.password_changeset(attrs) + |> update_user_and_delete_all_tokens() + end + @doc """ Deletes the signed token with the given context. """ diff --git a/lib/microwaveprop/accounts/user_notifier.ex b/lib/microwaveprop/accounts/user_notifier.ex index d819bd68..b875121c 100644 --- a/lib/microwaveprop/accounts/user_notifier.ex +++ b/lib/microwaveprop/accounts/user_notifier.ex @@ -40,6 +40,28 @@ defmodule Microwaveprop.Accounts.UserNotifier do """) end + @doc """ + Deliver instructions to reset a forgotten password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset your NTMS Propagation password", """ + + ============================== + + Hi #{user.callsign}, + + Someone requested a password reset for your NTMS Propagation + account. To pick a new password, visit the URL below: + + #{url} + + This link expires in 24 hours. If you didn't request this, + please ignore this message — your password won't change. + + ============================== + """) + end + @doc """ Deliver instructions to update a user email. """ diff --git a/lib/microwaveprop/accounts/user_token.ex b/lib/microwaveprop/accounts/user_token.ex index b7c1de93..fbf76f31 100644 --- a/lib/microwaveprop/accounts/user_token.ex +++ b/lib/microwaveprop/accounts/user_token.ex @@ -14,6 +14,7 @@ defmodule Microwaveprop.Accounts.UserToken do @magic_link_validity_in_minutes 15 @change_email_validity_in_days 7 @confirm_validity_in_days 1 + @reset_password_validity_in_days 1 @session_validity_in_days 14 @primary_key {:id, :binary_id, autogenerate: true} @@ -157,6 +158,31 @@ defmodule Microwaveprop.Accounts.UserToken do @doc """ Checks if the token is valid and returns its underlying lookup query. + Used to authorize a self-service password reset. The context is always + "reset_password" and the token is valid for @reset_password_validity_in_days. + """ + def verify_password_reset_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, "reset_password"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^@reset_password_validity_in_days, "day"), + 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 diff --git a/lib/microwaveprop_web/controllers/user_reset_password_controller.ex b/lib/microwaveprop_web/controllers/user_reset_password_controller.ex new file mode 100644 index 00000000..8cd4bc90 --- /dev/null +++ b/lib/microwaveprop_web/controllers/user_reset_password_controller.ex @@ -0,0 +1,63 @@ +defmodule MicrowavepropWeb.UserResetPasswordController do + use MicrowavepropWeb, :controller + + alias Microwaveprop.Accounts + + plug :get_user_by_reset_password_token when action in [:edit, :update] + + def new(conn, _params) do + render(conn, :new) + end + + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + {:ok, _email} = + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset-password/#{&1}") + ) + end + + # Always respond the same way whether or not the email exists — this is + # a deliberate anti-enumeration measure. If we redirected differently on + # a miss, an attacker could iterate emails to discover registered accounts. + conn + |> put_flash( + :info, + "If your email is in our system, you will receive instructions to reset your password shortly." + ) + |> redirect(to: ~p"/users/log-in") + end + + def edit(conn, _params) do + render(conn, :edit, + changeset: Accounts.change_user_password(conn.assigns.user), + token: conn.assigns.token + ) + end + + def update(conn, %{"user" => user_params}) do + case Accounts.reset_user_password(conn.assigns.user, user_params) do + {:ok, _} -> + conn + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log-in") + + {:error, changeset} -> + render(conn, :edit, changeset: changeset, token: conn.assigns.token) + end + end + + defp get_user_by_reset_password_token(conn, _opts) do + %{"token" => token} = conn.params + + if user = Accounts.get_user_by_reset_password_token(token) do + conn |> assign(:user, user) |> assign(:token, token) + else + conn + |> put_flash(:error, "Reset link is invalid or it has expired.") + |> redirect(to: ~p"/users/reset-password") + |> halt() + end + end +end diff --git a/lib/microwaveprop_web/controllers/user_reset_password_html.ex b/lib/microwaveprop_web/controllers/user_reset_password_html.ex new file mode 100644 index 00000000..a8bd2b39 --- /dev/null +++ b/lib/microwaveprop_web/controllers/user_reset_password_html.ex @@ -0,0 +1,5 @@ +defmodule MicrowavepropWeb.UserResetPasswordHTML do + use MicrowavepropWeb, :html + + embed_templates "user_reset_password_html/*" +end diff --git a/lib/microwaveprop_web/controllers/user_reset_password_html/edit.html.heex b/lib/microwaveprop_web/controllers/user_reset_password_html/edit.html.heex new file mode 100644 index 00000000..b447c1f3 --- /dev/null +++ b/lib/microwaveprop_web/controllers/user_reset_password_html/edit.html.heex @@ -0,0 +1,40 @@ + +
+
+ <.header> + Reset your password + <:subtitle>Choose a new password for your account. + +
+ + <.form + :let={f} + for={@changeset} + as={:user} + action={~p"/users/reset-password/#{@token}"} + method="put" + > + <.input + field={f[:password]} + type="password" + label="New password" + autocomplete="new-password" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + spellcheck="false" + required + /> + + <.button phx-disable-with="Saving..." class="btn btn-primary w-full"> + Save new password + + +
+
diff --git a/lib/microwaveprop_web/controllers/user_reset_password_html/new.html.heex b/lib/microwaveprop_web/controllers/user_reset_password_html/new.html.heex new file mode 100644 index 00000000..289b2758 --- /dev/null +++ b/lib/microwaveprop_web/controllers/user_reset_password_html/new.html.heex @@ -0,0 +1,35 @@ + +
+
+ <.header> + Reset your password + <:subtitle> + Enter the email on your account and we'll send you a link to pick a new password. + + +
+ + <.form :let={f} for={%{}} as={:user} action={~p"/users/reset-password"}> + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + + <.button class="btn btn-primary w-full"> + Send reset link + + + +

+ Remembered it? + <.link navigate={~p"/users/log-in"} class="font-semibold text-primary hover:underline"> + Log in + +

+
+
diff --git a/lib/microwaveprop_web/controllers/user_session_html/new.html.heex b/lib/microwaveprop_web/controllers/user_session_html/new.html.heex index d8017308..09b08383 100644 --- a/lib/microwaveprop_web/controllers/user_session_html/new.html.heex +++ b/lib/microwaveprop_web/controllers/user_session_html/new.html.heex @@ -43,5 +43,14 @@ Log in only this time + +

+ <.link + navigate={~p"/users/reset-password"} + class="font-semibold text-primary hover:underline" + > + Forgot your password? + +

diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index a5522b24..6a5e195d 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -230,6 +230,11 @@ defmodule MicrowavepropWeb.Router do get "/users/register", UserRegistrationController, :new post "/users/register", UserRegistrationController, :create + + get "/users/reset-password", UserResetPasswordController, :new + post "/users/reset-password", UserResetPasswordController, :create + get "/users/reset-password/:token", UserResetPasswordController, :edit + put "/users/reset-password/:token", UserResetPasswordController, :update end scope "/", MicrowavepropWeb do diff --git a/test/microwaveprop/accounts_test.exs b/test/microwaveprop/accounts_test.exs index 1ca65194..1b0ca10f 100644 --- a/test/microwaveprop/accounts_test.exs +++ b/test/microwaveprop/accounts_test.exs @@ -229,6 +229,85 @@ defmodule Microwaveprop.AccountsTest do end end + describe "deliver_user_reset_password_instructions/2" do + test "sends token through notification" do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + end + + test "does not return the user with invalid token" do + refute Accounts.get_user_by_reset_password_token("oops") + end + + test "does not return the user if token expired", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + end + + test "does not return the user with a confirm-context token" do + user = unconfirmed_user_fixture() + + confirm_token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + refute Accounts.get_user_by_reset_password_token(confirm_token) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password length", %{user: user} do + {:error, changeset} = Accounts.reset_user_password(user, %{password: "short"}) + assert %{password: ["should be at least 8 character(s)"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, {user, _tokens}} = + Accounts.reset_user_password(user, %{password: "new valid password"}) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + describe "change_user_registration/2" do test "returns a changeset without hashing the password" do changeset = Accounts.change_user_registration(%User{}) diff --git a/test/microwaveprop_web/controllers/user_reset_password_controller_test.exs b/test/microwaveprop_web/controllers/user_reset_password_controller_test.exs new file mode 100644 index 00000000..c8b76327 --- /dev/null +++ b/test/microwaveprop_web/controllers/user_reset_password_controller_test.exs @@ -0,0 +1,132 @@ +defmodule MicrowavepropWeb.UserResetPasswordControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.Accounts + alias Microwaveprop.Accounts.UserToken + alias Microwaveprop.Repo + + setup do + %{user: user_fixture()} + end + + describe "GET /users/reset-password" do + test "renders the request form", %{conn: conn} do + conn = get(conn, ~p"/users/reset-password") + response = html_response(conn, 200) + assert response =~ "Reset your password" + assert response =~ "Email" + end + end + + describe "POST /users/reset-password" do + @tag :capture_log + test "sends a new reset-password token for an existing email", %{conn: conn, user: user} do + conn = post(conn, ~p"/users/reset-password", %{"user" => %{"email" => user.email}}) + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + ~r/If your email is in our system/ + + assert Repo.get_by(UserToken, user_id: user.id, context: "reset_password") + end + + test "does not leak account existence for an unknown email", %{conn: conn} do + conn = + post(conn, ~p"/users/reset-password", %{ + "user" => %{"email" => "ghost@example.com"} + }) + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + ~r/If your email is in our system/ + + refute Repo.get_by(UserToken, context: "reset_password") + end + end + + describe "GET /users/reset-password/:token" do + setup %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token} + end + + test "renders the new-password form for a valid token", %{conn: conn, token: token} do + conn = get(conn, ~p"/users/reset-password/#{token}") + response = html_response(conn, 200) + assert response =~ "Reset your password" + assert response =~ "New password" + assert response =~ "Confirm new password" + end + + test "redirects with flash error when the token is invalid", %{conn: conn} do + conn = get(conn, ~p"/users/reset-password/oops") + assert redirected_to(conn) == ~p"/users/reset-password" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Reset link is invalid or it has expired" + end + end + + describe "PUT /users/reset-password/:token" do + setup %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token} + end + + test "resets the password for a valid token", %{conn: conn, user: user, token: token} do + conn = + put(conn, ~p"/users/reset-password/#{token}", %{ + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Password reset successfully" + + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "renders errors when the password is invalid", %{conn: conn, token: token} do + conn = + put(conn, ~p"/users/reset-password/#{token}", %{ + "user" => %{"password" => "short", "password_confirmation" => "short"} + }) + + response = html_response(conn, 200) + assert response =~ "Reset your password" + assert response =~ "should be at least 8" + end + + test "redirects with flash error when the token is invalid", %{conn: conn} do + conn = + put(conn, ~p"/users/reset-password/oops", %{ + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == ~p"/users/reset-password" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Reset link is invalid or it has expired" + end + end +end diff --git a/test/microwaveprop_web/controllers/user_session_controller_test.exs b/test/microwaveprop_web/controllers/user_session_controller_test.exs index 4528b8d7..063eab97 100644 --- a/test/microwaveprop_web/controllers/user_session_controller_test.exs +++ b/test/microwaveprop_web/controllers/user_session_controller_test.exs @@ -18,6 +18,13 @@ defmodule MicrowavepropWeb.UserSessionControllerTest do assert response =~ "Password" end + test "renders a link to reset-password", %{conn: conn} do + conn = get(conn, ~p"/users/log-in") + response = html_response(conn, 200) + assert response =~ ~p"/users/reset-password" + assert response =~ "Forgot" + end + test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do html = conn