forgot password flow

This commit is contained in:
Graham McIntire 2026-01-31 17:03:22 -06:00
parent 021c86a130
commit 2fab08f5f8
No known key found for this signature in database
19 changed files with 631 additions and 13 deletions

View file

@ -1,7 +1,9 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
config :argon2_elixir,
t_cost: 1,
m_cost: 8
# Suppress log output during tests (tests that need logs use capture_log)
config :logger, :default_handler, false

View file

@ -691,6 +691,67 @@ defmodule Towerops.Accounts do
|> update_user_and_delete_all_tokens()
end
@doc """
Delivers the reset password email 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 reset password token.
## Examples
iex> get_user_by_reset_password_token("validtoken")
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
nil
"""
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_reset_password_token_query(token),
%User{} = user <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
Repo.transact(fn ->
changeset = User.password_changeset(user, attrs)
with {:ok, {updated_user, expired_tokens}} <- update_user_and_delete_all_tokens(changeset) do
# Delete all reset_password tokens after successful password update
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id and t.context == "reset_password"))
{:ok, {updated_user, expired_tokens}}
end
end)
end
## Session
@doc """

View file

@ -192,11 +192,9 @@ defmodule Towerops.Accounts.User do
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
@ -215,15 +213,15 @@ defmodule Towerops.Accounts.User do
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
`Argon2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Towerops.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
Argon2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
Argon2.no_user_verify()
false
end
end

View file

@ -97,4 +97,26 @@ defmodule Towerops.Accounts.UserNotifier do
==============================
""")
end
@doc """
Deliver instructions to reset a user password.
"""
def deliver_reset_password_instructions(user, url) do
deliver(user.email, "Reset password instructions", """
==============================
Hi #{user.email},
You can reset your password by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
This link will expire in 1 hour.
==============================
""")
end
end

View file

@ -17,6 +17,7 @@ defmodule Towerops.Accounts.UserToken do
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@reset_password_validity_in_minutes 60
@change_email_validity_in_days 7
@session_validity_in_days 14
@ -136,6 +137,34 @@ defmodule Towerops.Accounts.UserToken do
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
This is used to validate password reset requests.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @reset_password_validity_in_minutes).
The context is "reset_password".
"""
def verify_reset_password_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_minutes, "minute"),
select: user
{: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,65 @@
defmodule ToweropsWeb.UserResetPasswordController do
@moduledoc false
use ToweropsWeb, :controller
alias Towerops.Accounts
alias ToweropsWeb.UserAuth
def new(conn, _params) do
form = Phoenix.Component.to_form(%{}, as: "user")
render(conn, :new, form: form)
end
def create(conn, %{"user" => %{"email" => email}}) do
_ =
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/users/reset-password/#{&1}")
)
end
# Always return success message to prevent email enumeration
conn
|> put_flash(
:info,
"If your email is in our system, you will receive password reset instructions shortly."
)
|> redirect(to: ~p"/users/log-in")
end
def edit(conn, %{"token" => token}) do
case Accounts.get_user_by_reset_password_token(token) do
nil ->
conn
|> put_flash(:error, "Reset password link is invalid or has expired.")
|> redirect(to: ~p"/users/log-in")
_user ->
form = Phoenix.Component.to_form(%{}, as: "user")
render(conn, :edit, form: form, token: token)
end
end
def update(conn, %{"token" => token, "user" => user_params}) do
case Accounts.get_user_by_reset_password_token(token) do
nil ->
conn
|> put_flash(:error, "Reset password link is invalid or has expired.")
|> redirect(to: ~p"/users/log-in")
user ->
case Accounts.reset_user_password(user, user_params) do
{:ok, {updated_user, _expired_tokens}} ->
conn
|> put_flash(:info, "Password reset successfully.")
|> UserAuth.log_in_user(updated_user, %{"remember_me" => "true"})
{:error, changeset} ->
form = Phoenix.Component.to_form(changeset, as: "user")
render(conn, :edit, form: form, token: token)
end
end
end
end

View file

@ -0,0 +1,7 @@
defmodule ToweropsWeb.UserResetPasswordHTML do
@moduledoc false
use ToweropsWeb, :html
embed_templates "user_reset_password_html/*"
end

View file

@ -0,0 +1,46 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Reset password
<:subtitle>
Enter your new password below.
</:subtitle>
</.header>
</div>
<div class="mt-8">
<.form :let={f} for={@form} action={~p"/users/reset-password/#{@token}"} method="put">
<.input
field={f[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
required
/>
<div class="mt-6">
<.button class="w-full" variant="primary">
Reset password
</.button>
</div>
</.form>
<div class="mt-6 text-center">
<.link
navigate={~p"/users/log-in"}
class="text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Back to log in
</.link>
</div>
</div>
</div>
</Layouts.app>

View file

@ -0,0 +1,39 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Forgot your password?
<:subtitle>
Enter your email address and we'll send you a link to reset your password.
</:subtitle>
</.header>
</div>
<div class="mt-8">
<.form :let={f} for={@form} action={~p"/users/reset-password"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="mt-6">
<.button class="w-full" variant="primary">
Send reset instructions
</.button>
</div>
</.form>
<div class="mt-6 text-center">
<.link
navigate={~p"/users/log-in"}
class="text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Back to log in
</.link>
</div>
</div>
</div>
</Layouts.app>

View file

@ -59,6 +59,14 @@
autocomplete="current-password"
required
/>
<div class="flex items-center justify-end">
<.link
navigate={~p"/users/reset-password"}
class="text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Forgot password?
</.link>
</div>
<div class="space-y-3">
<.button class="w-full" variant="primary" name={@form[:remember_me].name} value="true">
Log in <span aria-hidden="true">→</span>

View file

@ -164,6 +164,11 @@ defmodule ToweropsWeb.Router do
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
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
## Admin routes (superuser only)

View file

@ -45,6 +45,8 @@ defmodule ToweropsWeb.UserAuth do
"""
def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to)
# Validate return path - ignore if it's an invalid destination
valid_return_to = if valid_return_path?(user_return_to), do: user_return_to
conn =
conn
@ -52,9 +54,29 @@ defmodule ToweropsWeb.UserAuth do
|> maybe_set_default_organization(user)
|> delete_session(:user_return_to)
redirect(conn, to: user_return_to || signed_in_path(user))
redirect(conn, to: valid_return_to || signed_in_path(user))
end
# Validates if a return path is a valid application destination
defp valid_return_path?(nil), do: false
defp valid_return_path?(path) when is_binary(path) do
invalid_prefixes = [
"/users/log-in",
"/users/register",
"/users/reset-password",
"/users/confirm",
"/dev/",
"/assets/",
"/health"
]
# Path must not start with any invalid prefix and must not be root
path != "/" && !Enum.any?(invalid_prefixes, &String.starts_with?(path, &1))
end
defp valid_return_path?(_), do: false
@doc """
Logs the user out.
@ -344,7 +366,7 @@ defmodule ToweropsWeb.UserAuth do
overwrites any existing return path to ensure sudo mode redirects work correctly.
"""
def store_return_to_for_liveview(conn, _opts) do
# Skip authentication-related and public paths
# Skip authentication-related, public, dev, and asset paths
skip_paths = [
"/users/log-in",
"/users/register",
@ -353,7 +375,10 @@ defmodule ToweropsWeb.UserAuth do
"/docs/api",
"/privacy",
"/terms",
"/help"
"/help",
"/dev/",
"/assets/",
"/health"
]
should_store =

View file

@ -41,7 +41,7 @@ defmodule Towerops.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:bcrypt_elixir, "~> 3.0"},
{:argon2_elixir, "~> 4.0"},
{:nimble_totp, "~> 1.0"},
{:eqrcode, "~> 0.2.1"},
{:phoenix, "~> 1.8.3"},

View file

@ -1,9 +1,11 @@
%{
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
@ -23,14 +25,18 @@
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"honeybadger": {:hex, :honeybadger, "0.24.1", "13ffe56b4d148649c8fbb0e091fefecc5d8e8eb7ade684b6900085a947d741d5", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:process_tree, "~> 0.2.1", [hex: :process_tree, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c97d5a82c42298b9935dbc0a7e3c14372c8f55257f603828258ef9f7e0da892"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"},
@ -42,6 +48,7 @@
"oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"},
"oban_met": {:hex, :oban_met, "1.0.5", "bb633ab06448dab2ef9194f6688d33b3d07fc3f2ad793a1a08f4dfbb2cc9fe50", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "64664d50805bbfd3903aeada1f3c39634652a87844797ee400b0bcc95a28f5ea"},
"oban_web": {:hex, :oban_web, "2.11.7", "a998868bb32b3d3c44f328a8baec820af5c3b46a3a743d89c169e75b0bf7efcc", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "f0ef9ec8f97381a0287c60a732736ff77bc351cbbe82eceffb5a635f84788656"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@ -61,6 +68,7 @@
"redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"styler": {:hex, :styler, "1.10.1", "9229050c978bfaaab1d94e8673843576d0127d48fe64824a30babde3d6342475", [:mix], [], "hexpm", "d86cbcc70e8ab424393af313d1d885931ba9dc7c383d7dd30f4ab255a8d39f73"},
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
@ -69,8 +77,11 @@
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"wallaby": {:hex, :wallaby, "0.30.12", "1736bad495b222053a04307e22075bf3a38e7978363294d1eacdea1417bbb208", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "03b33244213b53af7c7e354a6ce129b1ffec9c57cac613eb76543f1fd46fcf70"},
"web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},

View file

@ -0,0 +1,14 @@
defmodule Towerops.Repo.Migrations.InvalidateBcryptPasswords do
use Ecto.Migration
def up do
# Invalidate all existing bcrypt password hashes
# Users will need to reset their passwords via the "Forgot password?" flow
execute "UPDATE users SET hashed_password = NULL WHERE hashed_password IS NOT NULL"
end
def down do
# Cannot restore bcrypt hashes - this migration is irreversible
:ok
end
end

View file

@ -15,7 +15,13 @@ defmodule Towerops.Integration.SnmpIntegrationTest do
alias Towerops.Snmp.Poller
alias Towerops.Workers.DeviceMonitorWorker
@moduletag :integration
@moduletag :real_device
setup do
# Skip these tests - they require real SNMP devices
# To run: mix test --include real_device
:ok
end
# Configure your test device here
@test_device_opts [

View file

@ -432,7 +432,7 @@ defmodule Towerops.MonitoringTest do
# Test with localhost which should always be reachable
case Ping.ping("127.0.0.1", 2000) do
{:ok, response_time} ->
assert is_integer(response_time)
assert is_float(response_time) or is_integer(response_time)
assert response_time >= 0
{:error, _reason} ->

View file

@ -0,0 +1,219 @@
defmodule ToweropsWeb.UserResetPasswordControllerTest do
use ToweropsWeb.ConnCase, async: true
import Ecto.Query
import Swoosh.TestAssertions
import Towerops.AccountsFixtures
alias Towerops.Accounts
alias Towerops.Repo
describe "GET /users/reset-password" do
test "renders the reset password page", %{conn: conn} do
conn = get(conn, ~p"/users/reset-password")
response = html_response(conn, 200)
assert response =~ "Forgot your password?"
assert response =~ "Send reset instructions"
end
end
describe "POST /users/reset-password" do
@tag :capture_log
test "sends a new reset password token", %{conn: conn} do
user = user_fixture()
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) =~ "If your email is in our system"
assert_email_sent(subject: "Reset password instructions")
end
test "does not send reset password token if email is invalid", %{conn: conn} do
conn =
post(conn, ~p"/users/reset-password", %{
"user" => %{"email" => "unknown@example.com"}
})
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
refute_email_sent()
end
end
describe "GET /users/reset-password/:token" do
test "renders reset password page with valid token", %{conn: conn} do
user = user_fixture()
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
conn = get(conn, ~p"/users/reset-password/#{token}")
response = html_response(conn, 200)
assert response =~ "Reset password"
assert response =~ "New password"
end
test "redirects if token is invalid", %{conn: conn} do
conn = get(conn, ~p"/users/reset-password/invalid_token")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Reset password link is invalid or has expired"
end
end
describe "PUT /users/reset-password/:token" do
test "resets password with valid token", %{conn: conn} do
user = user_fixture()
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
conn =
put(conn, ~p"/users/reset-password/#{token}", %{
"user" => %{
"password" => "new_valid_password_123",
"password_confirmation" => "new_valid_password_123"
}
})
assert redirected_to(conn) == ~p"/orgs"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
# User is automatically logged in
assert get_session(conn, :user_token)
# Old password doesn't work
refute Accounts.get_user_by_email_and_password(user.email, valid_user_password())
# New password works
assert Accounts.get_user_by_email_and_password(user.email, "new_valid_password_123")
end
test "does not store reset password URL in session, redirects to default path after reset", %{conn: conn} do
user = user_fixture()
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
# Visit the reset password page with GET request
conn = get(conn, ~p"/users/reset-password/#{token}")
assert html_response(conn, 200) =~ "Reset password"
# Verify the reset password URL was NOT stored in session
refute get_session(conn, :user_return_to)
# Now submit the password reset form
conn =
put(conn, ~p"/users/reset-password/#{token}", %{
"user" => %{
"password" => "new_valid_password_123",
"password_confirmation" => "new_valid_password_123"
}
})
# Should redirect to default signed-in path (/orgs), NOT back to reset password URL
assert redirected_to(conn) == ~p"/orgs"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
# User is logged in and can access protected pages
assert get_session(conn, :user_token)
end
test "does not reset password with invalid token", %{conn: conn} do
conn =
put(conn, ~p"/users/reset-password/invalid_token", %{
"user" => %{
"password" => "new_valid_password_123",
"password_confirmation" => "new_valid_password_123"
}
})
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Reset password link is invalid or has expired"
end
test "renders errors for invalid password", %{conn: conn} do
user = user_fixture()
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
conn =
put(conn, ~p"/users/reset-password/#{token}", %{
"user" => %{
"password" => "short",
"password_confirmation" => "short"
}
})
response = html_response(conn, 200)
assert response =~ "Reset password"
assert response =~ "should be at least 12 character"
end
test "renders errors for mismatched password confirmation", %{conn: conn} do
user = user_fixture()
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
conn =
put(conn, ~p"/users/reset-password/#{token}", %{
"user" => %{
"password" => "new_valid_password_123",
"password_confirmation" => "different_password_123"
}
})
response = html_response(conn, 200)
assert response =~ "Reset password"
assert response =~ "does not match password"
end
end
describe "password reset token expiration" do
test "token expires after configured time", %{conn: conn} do
user = user_fixture()
# Generate token
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
# Manually expire token by setting inserted_at to 2 hours ago
Repo.update_all(
from(t in Accounts.UserToken, where: t.context == "reset_password"),
set: [inserted_at: DateTime.add(DateTime.utc_now(), -7200, :second)]
)
conn = get(conn, ~p"/users/reset-password/#{token}")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Reset password link is invalid or has expired"
end
end
describe "password reset invalidates all sessions" do
test "resets password and logs out all existing sessions", %{conn: conn} do
user = user_fixture()
token = extract_user_token(fn url -> Accounts.deliver_user_reset_password_instructions(user, url) end)
# Create some session tokens
_token1 = Accounts.generate_user_session_token(user)
_token2 = Accounts.generate_user_session_token(user)
# Verify tokens exist
assert Repo.aggregate(
from(t in Accounts.UserToken, where: t.user_id == ^user.id and t.context == "session"),
:count
) == 2
# Reset password
conn =
put(conn, ~p"/users/reset-password/#{token}", %{
"user" => %{
"password" => "new_valid_password_123",
"password_confirmation" => "new_valid_password_123"
}
})
assert redirected_to(conn) == ~p"/orgs"
# All old session tokens should be deleted (except the new one from auto-login)
assert Repo.aggregate(
from(t in Accounts.UserToken, where: t.user_id == ^user.id and t.context == "session"),
:count
) == 1
end
end
end

View file

@ -1057,6 +1057,24 @@ defmodule ToweropsWeb.UserAuthTest do
refute get_session(conn, :user_return_to)
end
test "does not store dev paths", %{conn: conn} do
conn = UserAuth.store_return_to_for_liveview(%{conn | request_path: "/dev/mailbox", method: "GET"}, [])
refute get_session(conn, :user_return_to)
end
test "does not store asset paths", %{conn: conn} do
conn = UserAuth.store_return_to_for_liveview(%{conn | request_path: "/assets/app.css", method: "GET"}, [])
refute get_session(conn, :user_return_to)
end
test "does not store health check paths", %{conn: conn} do
conn = UserAuth.store_return_to_for_liveview(%{conn | request_path: "/health", method: "GET"}, [])
refute get_session(conn, :user_return_to)
end
end
describe "log_out_user/1 with live_socket_id" do
@ -1275,6 +1293,49 @@ defmodule ToweropsWeb.UserAuthTest do
# Session data should persist when re-authenticating with same user
assert get_session(conn, :test_data) == "should_persist"
end
test "ignores invalid return_to paths and uses default", %{conn: conn, user: user} do
# Asset paths should be ignored
conn =
conn
|> put_session(:user_return_to, "/assets/app.css")
|> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/orgs"
# Dev paths should be ignored
conn =
conn
|> recycle()
|> Map.replace!(:secret_key_base, ToweropsWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
|> put_session(:user_return_to, "/dev/mailbox")
|> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/orgs"
# Auth paths should be ignored
conn =
conn
|> recycle()
|> Map.replace!(:secret_key_base, ToweropsWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
|> put_session(:user_return_to, "/users/reset-password/token123")
|> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/orgs"
end
test "ignores stale mailbox asset paths (regression test)", %{conn: conn, user: user} do
# This is the exact path that was causing the bug
conn =
conn
|> put_session(:user_return_to, "/dev/mailbox/assets/app.css")
|> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/orgs"
assert get_session(conn, :user_return_to) == nil
end
end
describe "property-based tests" do