feat(auth): self-service password reset

Add a "Forgot your password?" flow off the login page. A 24-hour
reset_password token is emailed on request, the landing page lets the
user pick a new password, and all other tokens for the user are
revoked on success.

The request endpoint returns the same flash regardless of whether the
email matches a user so that attackers can't enumerate accounts.
This commit is contained in:
Graham McIntire 2026-04-18 08:42:16 -05:00
parent d01e420b9d
commit 00aad3cb98
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
12 changed files with 464 additions and 0 deletions

View file

@ -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.
"""

View file

@ -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.
"""

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
defmodule MicrowavepropWeb.UserResetPasswordHTML do
use MicrowavepropWeb, :html
embed_templates "user_reset_password_html/*"
end

View file

@ -0,0 +1,40 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
Reset your password
<:subtitle>Choose a new password for your account.</:subtitle>
</.header>
</div>
<.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
</.button>
</.form>
</div>
</Layouts.app>

View file

@ -0,0 +1,35 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
Reset your password
<:subtitle>
Enter the email on your account and we'll send you a link to pick a new password.
</:subtitle>
</.header>
</div>
<.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
</.button>
</.form>
<p class="text-center text-sm text-base-content/70">
Remembered it?
<.link navigate={~p"/users/log-in"} class="font-semibold text-primary hover:underline">
Log in
</.link>
</p>
</div>
</Layouts.app>

View file

@ -43,5 +43,14 @@
Log in only this time
</.button>
</.form>
<p class="text-center text-sm text-base-content/70">
<.link
navigate={~p"/users/reset-password"}
class="font-semibold text-primary hover:underline"
>
Forgot your password?
</.link>
</p>
</div>
</Layouts.app>

View file

@ -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

View file

@ -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{})

View file

@ -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

View file

@ -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