forgot password flow
This commit is contained in:
parent
021c86a130
commit
2fab08f5f8
19 changed files with 631 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
7
lib/towerops_web/controllers/user_reset_password_html.ex
Normal file
7
lib/towerops_web/controllers/user_reset_password_html.ex
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
defmodule ToweropsWeb.UserResetPasswordHTML do
|
||||
@moduledoc false
|
||||
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_reset_password_html/*"
|
||||
end
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -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"},
|
||||
|
|
|
|||
11
mix.lock
11
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue