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