mirror of
https://github.com/plausible/analytics.git
synced 2025-03-14 10:06:38 +00:00
Settings password reset (#4649)
* Enable exceptions when revoking all user sessions * Add `User` changeset for changing password * Make button in `2fa_input` component optional * Implement password change from User Settings * Add tests * Fix 2FA modal cancel button formatting * Update changelog * Don't pass redundant params to `render_settings` and clean up code a bit * Render one error per field in password reset form * Refactor inline form 2FA validation --------- Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
|
||||
### Added
|
||||
|
||||
- Add ability to review and revoke particular logged in user sessions
|
||||
- Add ability to change password from user settings screen
|
||||
|
||||
### Removed
|
||||
|
||||
|
@ -23,6 +23,11 @@ defmodule Plausible.Auth do
|
||||
prefix: "email-change:user",
|
||||
limit: 2,
|
||||
interval: :timer.hours(1)
|
||||
},
|
||||
password_change_user: %{
|
||||
prefix: "password-change:user",
|
||||
limit: 5,
|
||||
interval: :timer.minutes(20)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,7 @@ defmodule Plausible.Auth.User do
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password_hash
|
||||
field :old_password, :string, virtual: true
|
||||
field :password, :string, virtual: true
|
||||
field :password_confirmation, :string, virtual: true
|
||||
field :name, :string
|
||||
@ -65,8 +66,7 @@ defmodule Plausible.Auth.User do
|
||||
%Plausible.Auth.User{}
|
||||
|> cast(attrs, @required)
|
||||
|> validate_required(@required)
|
||||
|> validate_length(:password, min: 12, message: "has to be at least 12 characters")
|
||||
|> validate_length(:password, max: 128, message: "cannot be longer than 128 characters")
|
||||
|> validate_password_length()
|
||||
|> validate_confirmation(:password, required: true)
|
||||
|> validate_password_strength()
|
||||
|> hash_password()
|
||||
@ -142,12 +142,23 @@ defmodule Plausible.Auth.User do
|
||||
user
|
||||
|> cast(%{password: password}, [:password])
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, message: "has to be at least 12 characters")
|
||||
|> validate_length(:password, max: 128, message: "cannot be longer than 128 characters")
|
||||
|> validate_password_length()
|
||||
|> validate_password_strength()
|
||||
|> hash_password()
|
||||
end
|
||||
|
||||
def password_changeset(user, params \\ %{}) do
|
||||
user
|
||||
|> cast(params, [:old_password, :password])
|
||||
|> check_password(:old_password)
|
||||
|> validate_required([:old_password, :password])
|
||||
|> validate_password_length()
|
||||
|> validate_confirmation(:password, required: true)
|
||||
|> validate_password_strength()
|
||||
|> validate_password_changed()
|
||||
|> hash_password()
|
||||
end
|
||||
|
||||
def hash_password(%{errors: [], changes: changes} = changeset) do
|
||||
hash = Plausible.Auth.Password.hash(changes[:password])
|
||||
change(changeset, password_hash: hash)
|
||||
@ -226,18 +237,35 @@ defmodule Plausible.Auth.User do
|
||||
end
|
||||
end
|
||||
|
||||
defp check_password(changeset) do
|
||||
if password = get_change(changeset, :password) do
|
||||
defp validate_password_changed(changeset) do
|
||||
old_password = get_change(changeset, :old_password)
|
||||
new_password = get_change(changeset, :password)
|
||||
|
||||
if old_password == new_password do
|
||||
add_error(changeset, :password, "is too weak", validation: :different_password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp check_password(changeset, field \\ :password) do
|
||||
if password = get_change(changeset, field) do
|
||||
if Plausible.Auth.Password.match?(password, changeset.data.password_hash) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :password, "is invalid", validation: :check_password)
|
||||
add_error(changeset, field, "is invalid", validation: :check_password)
|
||||
end
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_password_length(changeset) do
|
||||
changeset
|
||||
|> validate_length(:password, min: 12, message: "has to be at least 12 characters")
|
||||
|> validate_length(:password, max: 128, message: "cannot be longer than 128 characters")
|
||||
end
|
||||
|
||||
defp validate_password_strength(changeset) do
|
||||
if get_change(changeset, :password) != nil and password_strength(changeset).score <= 2 do
|
||||
add_error(changeset, :password, "is too weak", validation: :strength)
|
||||
|
@ -2,7 +2,8 @@ defmodule PlausibleWeb.Components.TwoFactor do
|
||||
@moduledoc """
|
||||
Reusable components specific to 2FA
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||
import PlausibleWeb.Components.Generic
|
||||
|
||||
attr :text, :string, required: true
|
||||
attr :scale, :integer, default: 4
|
||||
@ -24,14 +25,26 @@ defmodule PlausibleWeb.Components.TwoFactor do
|
||||
attr :form, :any, required: true
|
||||
attr :field, :any, required: true
|
||||
attr :class, :string, default: ""
|
||||
attr :show_button?, :boolean, default: true
|
||||
|
||||
def verify_2fa_input(assigns) do
|
||||
input_class =
|
||||
"font-mono tracking-[0.5em] w-36 pl-5 font-medium shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-500 dark:text-gray-200 dark:bg-gray-900 rounded-l-md"
|
||||
|
||||
input_class =
|
||||
if assigns.show_button? do
|
||||
input_class
|
||||
else
|
||||
[input_class, "rounded-r-md"]
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :input_class, input_class)
|
||||
|
||||
~H"""
|
||||
<div class={[@class, "flex items-center"]}>
|
||||
<%= Phoenix.HTML.Form.text_input(@form, @field,
|
||||
autocomplete: "off",
|
||||
class:
|
||||
"font-mono tracking-[0.5em] w-36 pl-5 font-medium shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-500 dark:text-gray-200 dark:bg-gray-900 rounded-l-md",
|
||||
class: @input_class,
|
||||
oninput:
|
||||
"this.value=this.value.replace(/[^0-9]/g, ''); if (this.value.length >= 6) document.getElementById('verify-button').focus()",
|
||||
onclick: "this.select();",
|
||||
@ -42,6 +55,7 @@ defmodule PlausibleWeb.Components.TwoFactor do
|
||||
required: "required"
|
||||
) %>
|
||||
<PlausibleWeb.Components.Generic.button
|
||||
:if={@show_button?}
|
||||
type="submit"
|
||||
id={@id}
|
||||
mt?={false}
|
||||
@ -139,13 +153,14 @@ defmodule PlausibleWeb.Components.TwoFactor do
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-850 px-4 py-3 sm:px-9 sm:flex sm:flex-row-reverse">
|
||||
<%= render_slot(@buttons) %>
|
||||
<button
|
||||
<.button
|
||||
type="button"
|
||||
class="sm:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
x-on:click={"#{@state_param} = false"}
|
||||
class="mr-2"
|
||||
theme="bright"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</.button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
@ -29,6 +29,7 @@ defmodule PlausibleWeb.AuthController do
|
||||
:user_settings,
|
||||
:save_settings,
|
||||
:update_email,
|
||||
:update_password,
|
||||
:cancel_update_email,
|
||||
:new_api_key,
|
||||
:create_api_key,
|
||||
@ -261,14 +262,7 @@ defmodule PlausibleWeb.AuthController do
|
||||
end
|
||||
|
||||
def user_settings(conn, _params) do
|
||||
user = conn.assigns.current_user
|
||||
settings_changeset = Auth.User.settings_changeset(user)
|
||||
email_changeset = Auth.User.settings_changeset(user)
|
||||
|
||||
render_settings(conn,
|
||||
settings_changeset: settings_changeset,
|
||||
email_changeset: email_changeset
|
||||
)
|
||||
render_settings(conn, [])
|
||||
end
|
||||
|
||||
def initiate_2fa_setup(conn, _params) do
|
||||
@ -453,12 +447,7 @@ defmodule PlausibleWeb.AuthController do
|
||||
|> redirect(to: Routes.auth_path(conn, :user_settings))
|
||||
|
||||
{:error, changeset} ->
|
||||
email_changeset = Auth.User.settings_changeset(user)
|
||||
|
||||
render_settings(conn,
|
||||
settings_changeset: changeset,
|
||||
email_changeset: email_changeset
|
||||
)
|
||||
render_settings(conn, settings_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
@ -476,26 +465,42 @@ defmodule PlausibleWeb.AuthController do
|
||||
end
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
settings_changeset = Auth.User.settings_changeset(user)
|
||||
|
||||
render_settings(conn,
|
||||
settings_changeset: settings_changeset,
|
||||
email_changeset: changeset
|
||||
)
|
||||
render_settings(conn, email_changeset: changeset)
|
||||
|
||||
{:error, {:rate_limit, _}} ->
|
||||
settings_changeset = Auth.User.settings_changeset(user)
|
||||
|
||||
{:error, changeset} =
|
||||
changeset =
|
||||
user
|
||||
|> Auth.User.email_changeset(user_params)
|
||||
|> Ecto.Changeset.add_error(:email, "too many requests, try again in an hour")
|
||||
|> Ecto.Changeset.apply_action(:validate)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
render_settings(conn,
|
||||
settings_changeset: settings_changeset,
|
||||
email_changeset: changeset
|
||||
)
|
||||
render_settings(conn, email_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"user" => params}) do
|
||||
user = conn.assigns.current_user
|
||||
user_session = conn.assigns.current_user_session
|
||||
|
||||
with :ok <- Auth.rate_limit(:password_change_user, user),
|
||||
{:ok, user} <- do_update_password(user, params) do
|
||||
UserAuth.revoke_all_user_sessions(user, except: user_session)
|
||||
|
||||
conn
|
||||
|> put_flash(:success, "Your password is now changed")
|
||||
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#change-password")
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render_settings(conn, password_changeset: changeset)
|
||||
|
||||
{:error, {:rate_limit, _}} ->
|
||||
changeset =
|
||||
user
|
||||
|> Auth.User.password_changeset(params)
|
||||
|> Ecto.Changeset.add_error(:password, "too many attempts, try again in 20 minutes")
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
render_settings(conn, password_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
@ -524,18 +529,57 @@ defmodule PlausibleWeb.AuthController do
|
||||
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#change-email-address")
|
||||
end
|
||||
|
||||
defp do_update_password(user, params) do
|
||||
changes = Auth.User.password_changeset(user, params)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
with {:ok, user} <- Repo.update(changes),
|
||||
{:ok, user} <- validate_2fa_code(user, params["two_factor_code"]) do
|
||||
user
|
||||
else
|
||||
{:error, :invalid_2fa} ->
|
||||
changes
|
||||
|> Ecto.Changeset.add_error(:password, "invalid 2FA code")
|
||||
|> Map.put(:action, :validate)
|
||||
|> Repo.rollback()
|
||||
|
||||
{:error, changeset} ->
|
||||
Repo.rollback(changeset)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_2fa_code(user, code) do
|
||||
if Auth.TOTP.enabled?(user) do
|
||||
case Auth.TOTP.validate_code(user, code) do
|
||||
{:ok, user} -> {:ok, user}
|
||||
{:error, :not_enabled} -> {:ok, user}
|
||||
{:error, _} -> {:error, :invalid_2fa}
|
||||
end
|
||||
else
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
defp render_settings(conn, opts) do
|
||||
current_user = conn.assigns.current_user
|
||||
settings_changeset = Keyword.fetch!(opts, :settings_changeset)
|
||||
email_changeset = Keyword.fetch!(opts, :email_changeset)
|
||||
api_keys = Repo.preload(current_user, :api_keys).api_keys
|
||||
user_sessions = Auth.UserSessions.list_for_user(current_user)
|
||||
|
||||
settings_changeset =
|
||||
Keyword.get(opts, :settings_changeset, Auth.User.settings_changeset(current_user))
|
||||
|
||||
email_changeset = Keyword.get(opts, :email_changeset, Auth.User.email_changeset(current_user))
|
||||
|
||||
password_changeset =
|
||||
Keyword.get(opts, :password_changeset, Auth.User.password_changeset(current_user))
|
||||
|
||||
render(conn, "user_settings.html",
|
||||
api_keys: api_keys,
|
||||
user_sessions: user_sessions,
|
||||
settings_changeset: settings_changeset,
|
||||
email_changeset: email_changeset,
|
||||
password_changeset: password_changeset,
|
||||
subscription: current_user.subscription,
|
||||
invoices: Plausible.Billing.paddle_api().get_invoices(current_user.subscription),
|
||||
theme: current_user.theme || "system",
|
||||
|
@ -329,6 +329,7 @@ defmodule PlausibleWeb.Router do
|
||||
get "/settings", AuthController, :user_settings
|
||||
put "/settings", AuthController, :save_settings
|
||||
put "/settings/email", AuthController, :update_email
|
||||
put "/settings/password", AuthController, :update_password
|
||||
post "/settings/email/cancel", AuthController, :cancel_update_email
|
||||
delete "/me", AuthController, :delete_me
|
||||
get "/settings/api-keys/new", AuthController, :new_api_key
|
||||
|
@ -203,6 +203,73 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
|
||||
<h2 id="change-password" class="text-xl font-black dark:text-gray-100">
|
||||
Change password
|
||||
</h2>
|
||||
|
||||
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
||||
|
||||
<%= form_for @password_changeset, "/settings/password#change-password", [class: "max-w-sm"], fn f -> %>
|
||||
<div class="my-4">
|
||||
<%= label(f, :old_password, "Current password",
|
||||
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
) %>
|
||||
<div class="mt-1">
|
||||
<%= password_input(f, :old_password,
|
||||
class:
|
||||
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
|
||||
) %>
|
||||
<%= error_tag(f, :old_password, only_first?: true) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4">
|
||||
<%= label(f, :password, "New password",
|
||||
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
) %>
|
||||
<div class="mt-1">
|
||||
<%= password_input(f, :password,
|
||||
autocomplete: "new-password",
|
||||
class:
|
||||
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
|
||||
) %>
|
||||
<%= error_tag(f, :password, only_first?: true) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4">
|
||||
<%= label(f, :password_confirmation, "Confirm new password",
|
||||
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
) %>
|
||||
<div class="mt-1">
|
||||
<%= password_input(f, :password_confirmation,
|
||||
autocomplete: "new-password",
|
||||
class:
|
||||
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
|
||||
) %>
|
||||
<%= error_tag(f, :password_confirmation, only_first?: true) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={Plausible.Auth.TOTP.enabled?(@current_user)} class="my-4">
|
||||
<%= label(f, :two_factor_code, "Verify with 2FA",
|
||||
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
) %>
|
||||
<div class="mt-1">
|
||||
<PlausibleWeb.Components.TwoFactor.verify_2fa_input
|
||||
form={f}
|
||||
show_button?={false}
|
||||
field={:two_factor_code}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= submit("Change my password",
|
||||
class:
|
||||
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-600 dark:text-red-500 bg-white dark:bg-gray-800 hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-green-500 rounded rounded-t-none shadow-md dark:bg-gray-800">
|
||||
<h2 id="setup-2fa" class="text-xl font-black dark:text-gray-100">
|
||||
Two-Factor Authentication (2FA)
|
||||
|
@ -3,7 +3,7 @@ defmodule PlausibleWeb.UserAuth do
|
||||
Functions for user session management.
|
||||
"""
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
import Ecto.Query
|
||||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Repo
|
||||
@ -85,12 +85,20 @@ defmodule PlausibleWeb.UserAuth do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec revoke_all_user_sessions(Auth.User.t()) :: :ok
|
||||
def revoke_all_user_sessions(user) do
|
||||
{_count, tokens} =
|
||||
Repo.delete_all(
|
||||
from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token
|
||||
)
|
||||
@spec revoke_all_user_sessions(Auth.User.t(), Keyword.t()) :: :ok
|
||||
def revoke_all_user_sessions(user, opts \\ []) do
|
||||
except = Keyword.get(opts, :except)
|
||||
|
||||
delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token
|
||||
|
||||
delete_query =
|
||||
if except do
|
||||
where(delete_query, [us], us.id != ^except.id)
|
||||
else
|
||||
delete_query
|
||||
end
|
||||
|
||||
{_count, tokens} = Repo.delete_all(delete_query)
|
||||
|
||||
Enum.each(tokens, fn token ->
|
||||
PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{})
|
||||
|
@ -1,13 +1,24 @@
|
||||
defmodule PlausibleWeb.ErrorHelpers do
|
||||
use Phoenix.HTML
|
||||
|
||||
def error_tag(%{errors: errors}, field) do
|
||||
Enum.map(Keyword.get_values(errors, field), fn error ->
|
||||
def error_tag(map_or_form, field, opts \\ [])
|
||||
|
||||
def error_tag(%{errors: errors}, field, opts) do
|
||||
error_messages = Keyword.get_values(errors, field)
|
||||
|
||||
error_messages =
|
||||
if Keyword.get(opts, :only_first?) do
|
||||
Enum.take(error_messages, 1)
|
||||
else
|
||||
error_messages
|
||||
end
|
||||
|
||||
Enum.map(error_messages, fn error ->
|
||||
content_tag(:div, translate_error(error), class: "mt-2 text-sm text-red-500")
|
||||
end)
|
||||
end
|
||||
|
||||
def error_tag(assigns, field) when is_map(assigns) do
|
||||
def error_tag(assigns, field, _opts) when is_map(assigns) do
|
||||
error = assigns[field]
|
||||
|
||||
if error do
|
||||
|
@ -1158,6 +1158,173 @@ defmodule PlausibleWeb.AuthControllerTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /settings/password" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
test "updates the password and kills other sessions", %{conn: conn, user: user} do
|
||||
password = "very-long-very-secret-123"
|
||||
new_password = "super-long-super-secret-999"
|
||||
|
||||
another_session =
|
||||
user
|
||||
|> Auth.UserSession.new_session("Some Device")
|
||||
|> Repo.insert!()
|
||||
|
||||
original =
|
||||
user
|
||||
|> User.set_password(password)
|
||||
|> Repo.update!()
|
||||
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn, 302) ==
|
||||
Routes.auth_path(conn, :user_settings) <> "#change-password"
|
||||
|
||||
current_hash = Repo.reload!(user).password_hash
|
||||
assert current_hash != original.password_hash
|
||||
assert Plausible.Auth.Password.match?(new_password, current_hash)
|
||||
|
||||
assert [remaining_session] = Repo.preload(user, :sessions).sessions
|
||||
assert remaining_session.id != another_session.id
|
||||
end
|
||||
|
||||
test "fails to update weak password", %{conn: conn} do
|
||||
password = "very-long-very-secret-123"
|
||||
new_password = "weak"
|
||||
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert html =~ "is too weak"
|
||||
end
|
||||
|
||||
test "fails to update confirmation mismatch", %{conn: conn} do
|
||||
password = "very-long-very-secret-123"
|
||||
new_password = "super-long-super-secret-999"
|
||||
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password <> "mismatch"
|
||||
}
|
||||
})
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert html =~ "does not match confirmation"
|
||||
end
|
||||
|
||||
test "updates the password when 2FA is enabled", %{conn: conn, user: user} do
|
||||
password = "very-long-very-secret-123"
|
||||
new_password = "super-long-super-secret-999"
|
||||
|
||||
original =
|
||||
user
|
||||
|> User.set_password(password)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, user, _} = Auth.TOTP.initiate(user)
|
||||
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
|
||||
|
||||
code = NimbleTOTP.verification_code(user.totp_secret)
|
||||
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password,
|
||||
"two_factor_code" => code
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn, 302) ==
|
||||
Routes.auth_path(conn, :user_settings) <> "#change-password"
|
||||
|
||||
current_hash = Repo.reload!(user).password_hash
|
||||
assert current_hash != original.password_hash
|
||||
assert Plausible.Auth.Password.match?(new_password, current_hash)
|
||||
end
|
||||
|
||||
test "fails to update with wrong 2fa code", %{conn: conn, user: user} do
|
||||
password = "very-long-very-secret-123"
|
||||
|
||||
user =
|
||||
user
|
||||
|> User.set_password(password)
|
||||
|> Repo.update!()
|
||||
|
||||
new_password = "super-long-super-secret-999"
|
||||
|
||||
{:ok, user, _} = Auth.TOTP.initiate(user)
|
||||
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
|
||||
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password,
|
||||
"two_factor_code" => "111111"
|
||||
}
|
||||
})
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert html =~ "invalid 2FA code"
|
||||
end
|
||||
|
||||
test "fails to update with missing 2fa code", %{conn: conn, user: user} do
|
||||
password = "very-long-very-secret-123"
|
||||
|
||||
user =
|
||||
user
|
||||
|> User.set_password(password)
|
||||
|> Repo.update!()
|
||||
|
||||
new_password = "super-long-super-secret-999"
|
||||
|
||||
{:ok, user, _} = Auth.TOTP.initiate(user)
|
||||
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
|
||||
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert html =~ "invalid 2FA code"
|
||||
end
|
||||
|
||||
test "fails to update with no input", %{conn: conn} do
|
||||
conn =
|
||||
put(conn, "/settings/password", %{
|
||||
"user" => %{}
|
||||
})
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert text(html) =~ "can't be blank"
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /settings/email" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
|
Reference in New Issue
Block a user