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:
parent
d01e420b9d
commit
00aad3cb98
12 changed files with 464 additions and 0 deletions
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
defmodule MicrowavepropWeb.UserResetPasswordHTML do
|
||||
use MicrowavepropWeb, :html
|
||||
|
||||
embed_templates "user_reset_password_html/*"
|
||||
end
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue