Skip to content

Commit

Permalink
Settings password reset (plausible#4649)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
aerosol and zoldar authored Oct 3, 2024
1 parent 35c8010 commit 6940281
Show file tree
Hide file tree
Showing 10 changed files with 400 additions and 53 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions lib/plausible/auth/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
42 changes: 35 additions & 7 deletions lib/plausible/auth/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -142,9 +142,20 @@ 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

Expand Down Expand Up @@ -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)
Expand Down
27 changes: 21 additions & 6 deletions lib/plausible_web/components/two_factor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();",
Expand All @@ -42,6 +55,7 @@ defmodule PlausibleWeb.Components.TwoFactor do
required: "required"
) %>
<PlausibleWeb.Components.Generic.button
:if={@show_button?}
type="submit"
id={@id}
mt?={false}
Expand Down Expand Up @@ -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>
Expand Down
104 changes: 74 additions & 30 deletions lib/plausible_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

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

Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/plausible_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 6940281

Please sign in to comment.