Implement UI for 2FA setup and verification (#3541)

* Add 2FA actions to `AuthController`

* Hook up new `AuthController` actions to router

* Add `qr_code` to project dependencies

* Implement generic `qr_code` component rendering SVG QR code from text

* Implement enabled and disabled 2FA setting state in user settings view

* Implement view for initiating 2FA setup

* Implement view for verifying 2FA setup

* Implement view for rendering generated 2FA recovery codes

* Implement view for verifying 2FA code

* Implement view for verifying 2FA recovery code

* Improve `input_with_clipboard` component

* Improve view for initiating 2FA setup

* Improve verify 2FA setup view

* Implement `verify_2fa_input` component

* Improve view for verifying 2FA setup

* Improve view rendering generated 2FA recovery codes

* Use `verify_2fa_input` component in verify 2FA view

* Do not render PA contact on self-hosted instances

* Improve flash message phrasing on generated recovery codes

* Add byline with a warning to disable 2FA modal

* Extract modal to component and move 2FA components to dedicated module

* First pass on loading state for "generate new codes"

* Adjust modal button logic

* Fix button in verify_2fa_input component

* Use button component in activate view

* Implement wait states for recovery code related actions properly

* Apply rate limiting to 2FA verification

* Log failed 2FA code input attempts

* Add ability to trust device and skip 2FA for 30 days

* Improve styling in dark mode

* Fix waiting state under Chrome and Safari

* Delete trust cookie when disabling 2FA

* Put 2FA behind a feature flag

* Extract 2FA cookie deletion

* ff fixup

* Improve session management during 2FA login

* Extract part of 2FA controller logic to a separate module and clean up a bit

* Clear 2FA user session when rate limit hit

* Add id to form in verify 2FA setup view

* Add controller tests for 2FA actions and login action

* Update CHANGELOG.md

* Use `full_build?()` instead of `@is_selfhost` removed after rebase

* Update `Auth.TOTP` moduledoc

* Add TOTP token management and make `TOTP.enable` more test-friendly

* Use TOTP token for device trust feature

* Use zero-deps `eqrcode` instead of deps-heavy `qr_code`

* Improve flash messages copy

Co-authored-by: hq1 <hq@mtod.org>

* Make one more copy improvement

Co-authored-by: hq1 <hq@mtod.org>

* Fix copy in remaining spots

* Change redirect after login to accept URLs from #3560 (h/t @aerosol)

* Add tests checking handling login_dest on login and 2FA verification

* Fix regression in email activation form submit button behavior

* Rename `PlausibleWeb.TwoFactor` -> `PlausibleWeb.TwoFactor.Session`

* Move `qr_code` component under `Components.TwoFactor`

* Set domain and secure options for new cookies

---------

Co-authored-by: hq1 <hq@mtod.org>
This commit is contained in:
Adrian Gruntkowski 2023-12-06 12:01:19 +01:00 committed by GitHub
parent 4566e6b530
commit da0fa6c355
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2182 additions and 462 deletions

View File

@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file.
- Add last 24h plots to /sites view
- Add site pinning to /sites view
- Add support for JSON logger, via LOG_FORMAT=json environment variable
- Add support for 2FA authentication
### Removed
- Removed the nested custom event property breakdown UI when filtering by a goal in Goal Conversions

View File

@ -18,19 +18,17 @@ defmodule Plausible.Auth.TOTP do
After initiation, user is expected to confirm valid setup with `enable/2`,
providing TOTP code from their authenticator app. After code validation
passes successfully, the `User.totp_enabled` flag is set to `true`.
Finally, the user must be immediately presented with a list of recovery codes
generated with `generate_recovery_codes/1`. The codes should be presented
returned by the same call of `enable/2`. The codes should be presented
in copy/paste friendly form, ideally also with a print-friendly view option.
The function can be run more than once, giving the user ability to regenerate
codes from the final stage of setup if needed.
The `initiate/1` and `enable/1` functions can be safely called multiple
times, allowing user to abort and restart setup up to these stages.
## Management
State of TOTP for a particular user can be chcecked by calling `enabled?/1`.
The state of TOTP for a particular user can be chcecked by calling
`enabled?/1` or `initiated?/1`.
TOTP can be disabled with `disable/2`. User is expected to provide their
current password for safety. Once disabled, all TOTP user settings are
@ -38,9 +36,9 @@ defmodule Plausible.Auth.TOTP do
can be safely run more than once.
If the user needs to regenerate the recovery codes outside of setup procedure,
they must do it via `generate_recovery_codes_protected/2`, providing
their current password for safety. They must be warned that any existing
recovery codes will be invalidated.
they must do it via `generate_recovery_codes/2`, providing their current
password for safety. They must be warned that any existing recovery codes
will be invalidated.
## Validation
@ -65,6 +63,18 @@ defmodule Plausible.Auth.TOTP do
In case of recovery codes, each code is deleted immediately after use.
They are strictly one-time use only.
## TOTP Token
TOTP token is an alternate method of authenticating user session.
It's main use case is "trust this device" functionality, where user
can decide to skip 2FA verification for a particular browser session
for next N days. The token should then be stored in an encrypted,
signed cookie with a proper expiration timestamp.
The token should be reset each time it either fails to match
or when other credentials (like password) are reset. This should
effectively invalidate all trusted devices for a given user.
"""
import Ecto.Changeset, only: [change: 2]
@ -106,14 +116,15 @@ defmodule Plausible.Auth.TOTP do
user
|> change(
totp_enabled: false,
totp_secret: secret
totp_secret: secret,
totp_token: nil
)
|> Repo.update!()
{:ok, user, %{totp_uri: totp_uri(user), secret: readable_secret(user)}}
end
@spec enable(Auth.User.t(), String.t(), Keyword.t()) ::
@spec enable(Auth.User.t(), String.t() | :skip_verify, Keyword.t()) ::
{:ok, Auth.User.t(), %{recovery_codes: [String.t()]}}
| {:error, :invalid_code | :not_initiated}
def enable(user, code, opts \\ [])
@ -122,28 +133,39 @@ defmodule Plausible.Auth.TOTP do
{:error, :not_initiated}
end
def enable(user, :skip_verify, _opts) do
do_enable(user)
end
def enable(user, code, opts) do
with {:ok, user} <- do_validate_code(user, code, opts) do
{:ok, {user, recovery_codes}} =
Repo.transaction(fn ->
user =
user
|> change(totp_enabled: true)
|> Repo.update!()
{:ok, recovery_codes} = do_generate_recovery_codes(user)
{user, recovery_codes}
end)
user
|> Email.two_factor_enabled_email()
|> Plausible.Mailer.send()
{:ok, user, %{recovery_codes: recovery_codes}}
do_enable(user)
end
end
defp do_enable(user) do
{:ok, {user, recovery_codes}} =
Repo.transaction(fn ->
user =
user
|> change(
totp_enabled: true,
totp_token: generate_token()
)
|> Repo.update!()
{:ok, recovery_codes} = do_generate_recovery_codes(user)
{user, recovery_codes}
end)
user
|> Email.two_factor_enabled_email()
|> Plausible.Mailer.send()
{:ok, user, %{recovery_codes: recovery_codes}}
end
@spec disable(Auth.User.t(), String.t()) :: {:ok, Auth.User.t()} | {:error, :invalid_password}
def disable(user, password) do
if Auth.Password.match?(password, user.password_hash) do
@ -157,6 +179,7 @@ defmodule Plausible.Auth.TOTP do
user
|> change(
totp_enabled: false,
totp_token: nil,
totp_secret: nil,
totp_last_used_at: nil
)
@ -173,6 +196,18 @@ defmodule Plausible.Auth.TOTP do
end
end
@spec reset_token(Auth.User.t()) :: Auth.User.t()
def reset_token(user) do
new_token =
if user.totp_enabled do
generate_token()
end
user
|> change(totp_token: new_token)
|> Repo.update!()
end
@spec generate_recovery_codes(Auth.User.t(), String.t()) ::
{:ok, [String.t()]} | {:error, :invalid_password | :not_enabled}
def generate_recovery_codes(%{totp_enabled: false}) do
@ -303,4 +338,10 @@ defmodule Plausible.Auth.TOTP do
|> change(totp_last_used_at: now)
|> Repo.update!()
end
defp generate_token() do
20
|> :crypto.strong_rand_bytes()
|> Base.encode64(padding: false)
end
end

View File

@ -38,6 +38,7 @@ defmodule Plausible.Auth.User do
# Fields for TOTP authentication. See `Plausible.Auth.TOTP`.
field :totp_enabled, :boolean, default: false
field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary
field :totp_token, :string
field :totp_last_used_at, :naive_datetime
embeds_one :grace_period, Plausible.Auth.GracePeriod, on_replace: :update

View File

@ -32,7 +32,7 @@ defmodule PlausibleWeb.Components.Generic do
type={@type}
disabled={@disabled}
class={[
"inline-flex items-center justify-center gap-x-2 rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:bg-gray-400",
"inline-flex items-center justify-center gap-x-2 rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700",
@class
]}
{@rest}
@ -53,7 +53,7 @@ defmodule PlausibleWeb.Components.Generic do
<.link
href={@href}
class={[
"inline-flex items-center justify-center gap-x-2 rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:bg-gray-400",
"inline-flex items-center justify-center gap-x-2 rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:bg-gray-400 dark:disabled:bg-gray-800",
@class
]}
{@rest}
@ -248,6 +248,28 @@ defmodule PlausibleWeb.Components.Generic do
end
end
attr :class, :any, default: ""
def spinner(assigns) do
~H"""
<svg
class={["animate-spin h-4 w-4 text-indigo-500", @class]}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4">
</circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
"""
end
defp icon_class(link_assigns) do
if String.contains?(link_assigns[:class], "text-sm") do
["w-3 h-3"]

View File

@ -0,0 +1,155 @@
defmodule PlausibleWeb.Components.TwoFactor do
@moduledoc """
Reusable components specific to 2FA
"""
use Phoenix.Component
attr :text, :string, required: true
attr :scale, :integer, default: 4
def qr_code(assigns) do
qr_code =
assigns.text
|> EQRCode.encode()
|> EQRCode.svg(%{width: 160})
assigns = assign(assigns, :code, qr_code)
~H"""
<%= Phoenix.HTML.raw(@code) %>
"""
end
attr :id, :string, default: "verify-button"
attr :form, :any, required: true
attr :field, :any, required: true
attr :class, :string, default: ""
def verify_2fa_input(assigns) do
~H"""
<div class={[@class, "flex justify-center sm:justify-start"]}>
<%= 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",
oninput:
"this.value=this.value.replace(/[^0-9]/g, ''); if (this.value.length >= 6) document.getElementById('verify-button').focus()",
onclick: "this.select();",
oninvalid: "document.getElementById('verify-button').disabled = false",
maxlength: "6",
placeholder: "••••••",
value: "",
required: "required"
) %>
<PlausibleWeb.Components.Generic.button
type="submit"
id={@id}
class="rounded-l-none [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block"
>
<span class="label-enabled pointer-events-none">
Verify &rarr;
</span>
<span class="label-disabled">
<PlausibleWeb.Components.Generic.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" />
Verifying...
</span>
</PlausibleWeb.Components.Generic.button>
</div>
"""
end
attr :id, :string, required: true
attr :state_param, :string, required: true
attr :form_data, :any, required: true
attr :form_target, :string, required: true
attr :onsubmit, :string, default: nil
attr :title, :string, required: true
slot :icon, required: true
slot :inner_block, required: true
slot :buttons, required: true
def modal(assigns) do
~H"""
<div
id={@id}
x-cloak
x-show={@state_param}
x-on:keyup.escape.window={"#{@state_param} = false"}
class="fixed z-10 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div
x-show={@state_param}
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-500 dark:bg-gray-800 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
aria-hidden="true"
x-on:click={"#{@state_param} = false"}
>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
x-show={@state_param}
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<%= Phoenix.HTML.Form.form_for @form_data, @form_target, [onsubmit: @onsubmit], fn f -> %>
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
<a
href="#"
x-on:click.prevent={"#{@state_param} = false"}
class="bg-white dark:bg-gray-800 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none"
>
<span class="sr-only">Close</span>
<Heroicons.x_mark class="h-6 w-6" />
</a>
</div>
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
<%= render_slot(@icon) %>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left text-gray-900 dark:text-gray-100">
<h3 class="text-lg leading-6 font-medium" id="modal-title">
<%= @title %>
</h3>
<%= render_slot(@inner_block, f) %>
</div>
</div>
</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
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"}
>
Cancel
</button>
</div>
<% end %>
</div>
</div>
</div>
"""
end
end

View File

@ -1,8 +1,11 @@
defmodule PlausibleWeb.AuthController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Auth
alias Plausible.Billing.Quota
alias PlausibleWeb.TwoFactor
require Logger
plug(
@ -11,7 +14,11 @@ defmodule PlausibleWeb.AuthController do
:register,
:register_from_invitation,
:login_form,
:login
:login,
:verify_2fa_form,
:verify_2fa,
:verify_2fa_recovery_code_form,
:verify_2fa_recovery_code
]
)
@ -28,10 +35,29 @@ defmodule PlausibleWeb.AuthController do
:delete_me,
:activate_form,
:activate,
:request_activation_code
:request_activation_code,
:initiate_2fa,
:verify_2fa_setup_form,
:verify_2fa_setup,
:disable_2fa,
:generate_2fa_recovery_codes
]
)
plug(
:clear_2fa_user
when action not in [
:verify_2fa_form,
:verify_2fa,
:verify_2fa_recovery_code_form,
:verify_2fa_recovery_code
]
)
defp clear_2fa_user(conn, _opts) do
TwoFactor.Session.clear_2fa_user(conn)
end
def register(conn, %{"user" => %{"email" => email, "password" => password}}) do
with {:ok, user} <- login_user(conn, email, password) do
conn = set_user_session(conn, user)
@ -198,12 +224,13 @@ defmodule PlausibleWeb.AuthController do
def login(conn, %{"email" => email, "password" => password}) do
with {:ok, user} <- login_user(conn, email, password) do
login_dest = get_session(conn, :login_dest) || Routes.site_path(conn, :index)
conn
|> set_user_session(user)
|> put_session(:login_dest, nil)
|> redirect(to: login_dest)
if Auth.TOTP.enabled?(user) and not TwoFactor.Session.remember_2fa?(conn, user) do
conn
|> TwoFactor.Session.set_2fa_user(user)
|> redirect(to: Routes.auth_path(conn, :verify_2fa))
else
set_user_session_and_redirect(conn, user)
end
end
end
@ -242,8 +269,22 @@ defmodule PlausibleWeb.AuthController do
end
end
defp redirect_to_login(conn) do
redirect(conn, to: Routes.auth_path(conn, :login_form))
end
defp set_user_session_and_redirect(conn, user) do
login_dest = get_session(conn, :login_dest) || Routes.site_path(conn, :index)
conn
|> set_user_session(user)
|> put_session(:login_dest, nil)
|> redirect(external: login_dest)
end
defp set_user_session(conn, user) do
conn
|> TwoFactor.Session.clear_2fa_user()
|> put_session(:current_user_id, user.id)
|> put_resp_cookie("logged_in", "true",
http_only: false,
@ -259,6 +300,7 @@ defmodule PlausibleWeb.AuthController do
@login_interval 60_000
@login_limit 5
defp check_ip_rate_limit(conn) do
ip_address = PlausibleWeb.RemoteIp.get(conn)
@ -268,6 +310,13 @@ defmodule PlausibleWeb.AuthController do
end
end
defp check_user_rate_limit(user) do
case Hammer.check_rate("login:user:#{user.id}", @login_interval, @login_limit) do
{:allow, _} -> :ok
{:deny, _} -> {:rate_limit, :user}
end
end
defp find_user(email) do
user =
Repo.one(
@ -279,13 +328,6 @@ defmodule PlausibleWeb.AuthController do
if user, do: {:ok, user}, else: :user_not_found
end
defp check_user_rate_limit(user) do
case Hammer.check_rate("login:user:#{user.id}", @login_interval, @login_limit) do
{:allow, _} -> :ok
{:deny, _} -> {:rate_limit, :user}
end
end
defp check_password(user, password) do
if Plausible.Auth.Password.match?(password, user.password_hash || "") do
:ok
@ -299,8 +341,9 @@ defmodule PlausibleWeb.AuthController do
end
def user_settings(conn, _params) do
settings_changeset = Auth.User.settings_changeset(conn.assigns[:current_user])
email_changeset = Auth.User.settings_changeset(conn.assigns[:current_user])
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,
@ -308,8 +351,186 @@ defmodule PlausibleWeb.AuthController do
)
end
def initiate_2fa_setup(conn, _params) do
case Auth.TOTP.initiate(conn.assigns.current_user) do
{:ok, user, %{totp_uri: totp_uri, secret: secret}} ->
render(conn, "initiate_2fa_setup.html", user: user, totp_uri: totp_uri, secret: secret)
{:error, :already_setup} ->
conn
|> put_flash(:error, "Two-Factor Authentication is already setup for this account.")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
end
end
def verify_2fa_setup_form(conn, _params) do
if Auth.TOTP.initiated?(conn.assigns.current_user) do
render(conn, "verify_2fa_setup.html")
else
redirect(conn, to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
end
end
def verify_2fa_setup(conn, %{"code" => code}) do
case Auth.TOTP.enable(conn.assigns.current_user, code) do
{:ok, _, %{recovery_codes: codes}} ->
conn
|> put_flash(:success, "Two-Factor Authentication is fully enabled")
|> render("generate_2fa_recovery_codes.html", recovery_codes: codes, from_setup: true)
{:error, :invalid_code} ->
conn
|> put_flash(:error, "The provided code is invalid. Please try again")
|> render("verify_2fa_setup.html")
{:error, :not_initiated} ->
conn
|> put_flash(:error, "Please enable Two-Factor Authentication for this account first.")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
end
end
def disable_2fa(conn, %{"password" => password}) do
case Auth.TOTP.disable(conn.assigns.current_user, password) do
{:ok, _} ->
conn
|> TwoFactor.Session.clear_remember_2fa()
|> put_flash(:success, "Two-Factor Authentication is disabled")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
{:error, :invalid_password} ->
conn
|> put_flash(:error, "Incorrect password provided")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
end
end
def generate_2fa_recovery_codes(conn, %{"password" => password}) do
case Auth.TOTP.generate_recovery_codes(conn.assigns.current_user, password) do
{:ok, codes} ->
conn
|> put_flash(:success, "New Recovery Codes generated")
|> render("generate_2fa_recovery_codes.html", recovery_codes: codes, from_setup: false)
{:error, :invalid_password} ->
conn
|> put_flash(:error, "Incorrect password provided")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
{:error, :not_enabled} ->
conn
|> put_flash(:error, "Please enable Two-Factor Authentication for this account first.")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
end
end
def verify_2fa_form(conn, _) do
case TwoFactor.Session.get_2fa_user(conn) do
{:ok, user} ->
if Auth.TOTP.enabled?(user) do
render(conn, "verify_2fa.html",
remember_2fa_days: TwoFactor.Session.remember_2fa_days(),
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
else
redirect_to_login(conn)
end
{:error, :not_found} ->
redirect_to_login(conn)
end
end
def verify_2fa(conn, %{"code" => code} = params) do
with {:ok, user} <- get_2fa_user_limited(conn) do
case Auth.TOTP.validate_code(user, code) do
{:ok, user} ->
conn
|> TwoFactor.Session.maybe_set_remember_2fa(user, params["remember_2fa"])
|> set_user_session_and_redirect(user)
{:error, :invalid_code} ->
maybe_log_failed_login_attempts(
"wrong 2FA verification code provided for #{user.email}"
)
conn
|> put_flash(:error, "The provided code is invalid. Please try again")
|> render("verify_2fa.html",
remember_2fa_days: TwoFactor.Session.remember_2fa_days(),
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
{:error, :not_enabled} ->
set_user_session_and_redirect(conn, user)
end
end
end
def verify_2fa_recovery_code_form(conn, _params) do
case TwoFactor.Session.get_2fa_user(conn) do
{:ok, user} ->
if Auth.TOTP.enabled?(user) do
render(conn, "verify_2fa_recovery_code.html",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
else
redirect_to_login(conn)
end
{:error, :not_found} ->
redirect_to_login(conn)
end
end
def verify_2fa_recovery_code(conn, %{"recovery_code" => recovery_code}) do
with {:ok, user} <- get_2fa_user_limited(conn) do
case Auth.TOTP.use_recovery_code(user, recovery_code) do
:ok ->
set_user_session_and_redirect(conn, user)
{:error, :invalid_code} ->
maybe_log_failed_login_attempts("wrong 2FA recovery code provided for #{user.email}")
conn
|> put_flash(:error, "The provided recovery code is invalid. Please try another one")
|> render("verify_2fa_recovery_code.html",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
{:error, :not_enabled} ->
set_user_session_and_redirect(conn, user)
end
end
end
defp get_2fa_user_limited(conn) do
case TwoFactor.Session.get_2fa_user(conn) do
{:ok, user} ->
with :ok <- check_ip_rate_limit(conn),
:ok <- check_user_rate_limit(user) do
{:ok, user}
else
{:rate_limit, _} ->
maybe_log_failed_login_attempts("too many logging attempts for #{user.email}")
conn
|> TwoFactor.Session.clear_2fa_user()
|> render_error(
429,
"Too many login attempts. Wait a minute before trying again."
)
end
{:error, :not_found} ->
conn
|> redirect(to: Routes.auth_path(conn, :login_form))
end
end
def save_settings(conn, %{"user" => user_params}) do
changes = Auth.User.settings_changeset(conn.assigns[:current_user], user_params)
user = conn.assigns.current_user
changes = Auth.User.settings_changeset(user, user_params)
case Repo.update(changes) do
{:ok, _user} ->
@ -318,14 +539,18 @@ defmodule PlausibleWeb.AuthController do
|> redirect(to: Routes.auth_path(conn, :user_settings))
{:error, changeset} ->
email_changeset = Auth.User.settings_changeset(conn.assigns[:current_user])
email_changeset = Auth.User.settings_changeset(user)
render_settings(conn, settings_changeset: changeset, email_changeset: email_changeset)
render_settings(conn,
settings_changeset: changeset,
email_changeset: email_changeset
)
end
end
def update_email(conn, %{"user" => user_params}) do
changes = Auth.User.email_changeset(conn.assigns[:current_user], user_params)
user = conn.assigns.current_user
changes = Auth.User.email_changeset(user, user_params)
case Repo.update(changes) do
{:ok, user} ->
@ -337,9 +562,12 @@ defmodule PlausibleWeb.AuthController do
end
{:error, changeset} ->
settings_changeset = Auth.User.settings_changeset(conn.assigns[:current_user])
settings_changeset = Auth.User.settings_changeset(user)
render_settings(conn, settings_changeset: settings_changeset, email_changeset: changeset)
render_settings(conn,
settings_changeset: settings_changeset,
email_changeset: changeset
)
end
end
@ -386,7 +614,8 @@ defmodule PlausibleWeb.AuthController do
site_limit: Quota.site_limit(user),
site_usage: Quota.site_usage(user),
pageview_limit: Quota.monthly_pageview_limit(user.subscription),
pageview_usage: Quota.monthly_pageview_usage(user)
pageview_usage: Quota.monthly_pageview_usage(user),
totp_enabled?: Auth.TOTP.enabled?(user)
)
end

View File

@ -72,7 +72,7 @@ defmodule PlausibleWeb.Email do
priority_email()
|> to(user)
|> tag("two-factor-enabled-email")
|> subject("Plausible two-factor authentication enabled")
|> subject("Plausible Two-Factor Authentication enabled")
|> render("two_factor_enabled_email.html", user: user)
end
@ -80,7 +80,7 @@ defmodule PlausibleWeb.Email do
priority_email()
|> to(user)
|> tag("two-factor-disabled-email")
|> subject("Plausible two-factor authentication disabled")
|> subject("Plausible Two-Factor Authentication disabled")
|> render("two_factor_disabled_email.html", user: user)
end

View File

@ -86,11 +86,15 @@ defmodule PlausibleWeb.Live.Components.Form do
def input_with_clipboard(assigns) do
~H"""
<div class="my-4">
<div>
<.label for={@id}>
<%= @label %>
</.label>
</div>
<div class="relative mt-1">
<.input
id={@id}
name={@name}
label={@label}
value={@value}
type="text"
readonly="readonly"
@ -100,10 +104,12 @@ defmodule PlausibleWeb.Live.Components.Form do
<a
onclick={"var input = document.getElementById('#{@id}'); input.focus(); input.select(); document.execCommand('copy'); event.stopPropagation();"}
href="javascript:void(0)"
class="absolute flex items-center text-xs font-medium text-indigo-600 no-underline hover:underline"
style="top: 42px; right: 12px;"
class="absolute flex items-center text-xs font-medium text-indigo-600 no-underline hover:underline top-2 right-4"
>
<Heroicons.document_duplicate class="pr-1 text-indigo-600 dark:text-indigo-500 w-5 h-5" />COPY
<Heroicons.document_duplicate class="pr-1 text-indigo-600 dark:text-indigo-500 w-5 h-5" />
<span>
COPY
</span>
</a>
</div>
</div>

View File

@ -82,9 +82,20 @@ defmodule PlausibleWeb.Live.ResetPasswordForm do
end
def handle_event("set", %{"user" => %{"password" => password}}, socket) do
user = Auth.User.set_password(socket.assigns.user, password)
result =
Repo.transaction(fn ->
changeset = Auth.User.set_password(socket.assigns.user, password)
case Repo.update(user) do
case Repo.update(changeset) do
{:ok, user} ->
Auth.TOTP.reset_token(user)
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
case result do
{:ok, _user} ->
{:noreply, assign(socket, trigger_submit: true)}

View File

@ -558,28 +558,6 @@ defmodule PlausibleWeb.Live.Sites do
"""
end
attr :class, :any, default: ""
def spinner(assigns) do
~H"""
<svg
class={["animate-spin h-4 w-4 text-indigo-500", @class]}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4">
</circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
"""
end
def handle_event("pin-toggle", %{"domain" => domain}, socket) do
site = Enum.find(socket.assigns.sites.entries, &(&1.domain == domain))

View File

@ -194,6 +194,15 @@ defmodule PlausibleWeb.Router do
post "/login", AuthController, :login
get "/password/request-reset", AuthController, :password_reset_request_form
post "/password/request-reset", AuthController, :password_reset_request
post "/2fa/setup/initiate", AuthController, :initiate_2fa_setup
get "/2fa/setup/verify", AuthController, :verify_2fa_setup_form
post "/2fa/setup/verify", AuthController, :verify_2fa_setup
post "/2fa/disable", AuthController, :disable_2fa
post "/2fa/recovery_codes", AuthController, :generate_2fa_recovery_codes
get "/2fa/verify", AuthController, :verify_2fa_form
post "/2fa/verify", AuthController, :verify_2fa
get "/2fa/use_recovery_code", AuthController, :verify_2fa_recovery_code_form
post "/2fa/use_recovery_code", AuthController, :verify_2fa_recovery_code
get "/password/reset", AuthController, :password_reset_form
post "/password/reset", AuthController, :password_reset
get "/avatar/:hash", AvatarController, :avatar

View File

@ -27,7 +27,9 @@
required: "required"
) %>
</div>
<button id="submit" class="button rounded-l-none">Activate &rarr;</button>
<PlausibleWeb.Components.Generic.button id="submit" type="submit" class="rounded-l-none">
Activate &rarr;
</PlausibleWeb.Components.Generic.button>
</div>
<%= error_tag(assigns, :error) %>

View File

@ -0,0 +1,49 @@
<div class="w-full max-w-3xl mt-4 mx-auto flex">
<div class="w-full max-w-lg mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8">
<h2 class="text-xl font-black dark:text-gray-100">
<%= if @from_setup do %>
Setup Two-Factor Authentication
<% else %>
Your New Recovery Codes
<% end %>
</h2>
<div class="text-sm mt-2 text-gray-500 dark:text-gray-200 leading-tight">
Use these recovery codes to log in if you lose access to the authenticator application. Store them somewhere safe!
<div
id="recovery-codes-list"
class="font-mono border-2 border-dotted border-gray-200 dark:border-gray-700 rounded-md text-gray-600 dark:text-gray-200 text-lg bg-gray-100 dark:bg-gray-900 p-2 mt-6 flex flex-wrap"
>
<%= for code <- @recovery_codes do %>
<div class="basis-1/2 text-center"><%= code %></div>
<% end %>
</div>
</div>
<div class="mt-6 flex sm:flex-row flex-col justify-between">
<button onclick="print(); event.stopPropagation();" id="print" class="button">
Print Codes <Heroicons.printer class="h-4 w-4 ml-2 mt-1" />
</button>
<button
onclick="var list = document.getElementById('recovery-codes-list'); var selection = getSelection(); selection.removeAllRanges(); var range = createRange(); range.selectNodeContents(list); selection.addRange(range); document.execCommand('copy'); selection.removeAllRanges(); event.stopPropagation(); document.getElementById('copy-base-icon').classList.add('hidden'); document.getElementById('copy-done-icon').classList.remove('hidden'); setTimeout(function() { document.getElementById('copy-done-icon').classList.add('hidden'); document.getElementById('copy-base-icon').classList.remove('hidden'); }, 2000)"
id="copy"
class="button sm:mt-0 mt-3"
>
Copy to Clipboard
<span id="copy-base-icon">
<Heroicons.document_duplicate class="h-4 w-4 ml-2 mt-1" />
</span>
<span id="copy-done-icon" class="hidden">
<Heroicons.check class="h-4 w-4 ml-2 mt-1" />
</span>
</button>
<button
id="finish"
class="button sm:mt-0 mt-3"
onclick={"location.replace('#{Routes.auth_path(@conn, :user_settings) <> "#setup-2fa"}')"}
>
Finish
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,71 @@
<div class="w-full max-w-2xl mt-4 mx-auto flex">
<div class="w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8">
<h2 class="text-xl font-black dark:text-gray-100">
Setup Two-Factor Authentication
</h2>
<div class="text-sm mt-2 text-gray-500 dark:text-gray-200">
Link your Plausible account to the authenticator app you have installed either on your phone or computer.
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<div class="mt-8">
<div class="border-2 border-gray-300 inline-block p-2 dark:bg-white">
<PlausibleWeb.Components.TwoFactor.qr_code text={@totp_uri} />
</div>
</div>
<div class="mt-8 sm:ml-4">
<ol>
<li class="flex items-start">
<div class="flex-shrink-0 h-5 w-5 relative flex items-center justify-center">
<div class="h-2 w-2 bg-gray-300 dark:bg-gray-700 rounded-full"></div>
</div>
Open the authenticator application
</li>
<li class="mt-1 flex items-start">
<div class="flex-shrink-0 h-5 w-5 relative flex items-center justify-center">
<div class="h-2 w-2 bg-gray-300 dark:bg-gray-700 rounded-full"></div>
</div>
Tap Scan a QR Code
</li>
<li class="mt-1 flex items-start">
<div class="flex-shrink-0 h-5 w-5 relative flex items-center justify-center">
<div class="h-2 w-2 bg-gray-300 dark:bg-gray-700 rounded-full"></div>
</div>
Scan this code with your phone camera or paste the code manually
</li>
</ol>
<div class="sm:ml-2">
<PlausibleWeb.Live.Components.Form.input_with_clipboard
id="secret"
name="secret_clipboard"
label="Code"
value={@secret}
onfocus="this.value = this.value;"
class="focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-850 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
/>
</div>
</div>
</div>
<div class="mt-6 flex flex-col-reverse sm:flex-row justify-between items-center">
<p class="text-sm mt-4 sm:mt-0">
Changed your mind?
<a
href={Routes.auth_path(@conn, :user_settings) <> "#setup-2fa"}
class="underline text-indigo-600"
>
Go back to Settings
</a>
</p>
<.unstyled_link
id="proceed"
class="button sm:w-auto w-full"
href={Routes.auth_path(@conn, :verify_2fa_setup_form)}
>
Proceed &rarr;
</.unstyled_link>
</div>
</div>
</div>
</div>

View File

@ -1,378 +1,507 @@
<%= if full_build?() do %>
<div class="max-w-2xl px-8 pt-4 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
<div class="flex flex-wrap justify-between">
<h2 class="text-xl font-black dark:text-gray-100 w-max mr-4 mt-2">Subscription Plan</h2>
<div class="gap-x-2 mt-2 inline-flex">
<span
:if={@subscription && Plausible.Billing.Plans.business_tier?(@subscription)}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5 text-indigo-600 bg-blue-100 dark:text-yellow-200 dark:border dark:bg-inherit dark:border-yellow-200"
]}
>
Business
</span>
<span
:if={@subscription}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
subscription_colors(@subscription.status)
]}
>
<%= present_subscription_status(@subscription.status) %>
</span>
</div>
</div>
<div class="my-4 border-b border-gray-400"></div>
<PlausibleWeb.Components.Billing.subscription_cancelled_notice
user={@user}
dismissable={false}
/>
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
<PlausibleWeb.Components.Billing.monthly_quota_box
user={@user}
subscription={@subscription}
business_tier={FunWithFlags.enabled?(:business_tier, for: @user)}
/>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if @subscription && @subscription.status in [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= PlausibleWeb.BillingView.present_currency(@subscription.currency_code) %><%= @subscription.next_bill_amount %>
</div>
<.styled_link :if={@subscription.update_url} href={@subscription.update_url}>
Update billing info
</.styled_link>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && @subscription.status in [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %>
</div>
<span class="text-gray-600 dark:text-gray-400">
(<%= subscription_interval(@subscription) %> billing)
<div x-data="{disable2FAOpen: false, regenerate2FAOpen: false}">
<%= if full_build?() do %>
<div class="max-w-2xl px-8 pt-4 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
<div class="flex flex-wrap justify-between">
<h2 class="text-xl font-black dark:text-gray-100 w-max mr-4 mt-2">Subscription Plan</h2>
<div class="gap-x-2 mt-2 inline-flex">
<span
:if={@subscription && Plausible.Billing.Plans.business_tier?(@subscription)}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5 text-indigo-600 bg-blue-100 dark:text-yellow-200 dark:border dark:bg-inherit dark:border-yellow-200"
]}
>
Business
</span>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
<span
:if={@subscription}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
subscription_colors(@subscription.status)
]}
>
<%= present_subscription_status(@subscription.status) %>
</span>
</div>
</div>
<div class="my-4 border-b border-gray-400"></div>
<PlausibleWeb.Components.Billing.subscription_cancelled_notice
user={@user}
dismissable={false}
/>
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
<PlausibleWeb.Components.Billing.monthly_quota_box
user={@user}
subscription={@subscription}
business_tier={FunWithFlags.enabled?(:business_tier, for: @user)}
/>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if @subscription && @subscription.status in [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= PlausibleWeb.BillingView.present_currency(@subscription.currency_code) %><%= @subscription.next_bill_amount %>
</div>
<.styled_link :if={@subscription.update_url} href={@subscription.update_url}>
Update billing info
</.styled_link>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && @subscription.status in [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %>
</div>
<span class="text-gray-600 dark:text-gray-400">
(<%= subscription_interval(@subscription) %> billing)
</span>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
</div>
<PlausibleWeb.Components.Billing.render_monthly_pageview_usage
usage={@pageview_usage}
limit={@pageview_limit}
/>
<article class="mt-8">
<h1 class="text-xl mb-3 font-bold dark:text-gray-100">Sites & team members usage</h1>
<PlausibleWeb.Components.Billing.usage_and_limits_table>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="site-usage-row"
title="Owned sites"
usage={@site_usage}
limit={@site_limit}
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="team-member-usage-row"
title="Team members"
usage={@team_member_usage}
limit={@team_member_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table>
</article>
<%= cond do %>
<% Plausible.Billing.Subscriptions.resumable?(@subscription) && @subscription.cancel_url -> %>
<div class="mt-8">
<%= link("Cancel my subscription",
to: @subscription.cancel_url,
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-700 bg-white dark:bg-gray-800 hover:text-red-500 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
) %>
</div>
<% true -> %>
<div class="mt-8">
<PlausibleWeb.Components.Billing.upgrade_link
user={@user}
business_tier={FunWithFlags.enabled?(:business_tier, for: @user)}
/>
</div>
<% end %>
</div>
<PlausibleWeb.Components.Billing.render_monthly_pageview_usage
usage={@pageview_usage}
limit={@pageview_limit}
/>
<article class="mt-8">
<h1 class="text-xl mb-3 font-bold dark:text-gray-100">Sites & team members usage</h1>
<PlausibleWeb.Components.Billing.usage_and_limits_table>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="site-usage-row"
title="Owned sites"
usage={@site_usage}
limit={@site_limit}
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="team-member-usage-row"
title="Team members"
usage={@team_member_usage}
limit={@team_member_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table>
</article>
<%= cond do %>
<% Plausible.Billing.Subscriptions.resumable?(@subscription) && @subscription.cancel_url -> %>
<div class="mt-8">
<%= link("Cancel my subscription",
to: @subscription.cancel_url,
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-700 bg-white dark:bg-gray-800 hover:text-red-500 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
) %>
<%= case @invoices do %>
<% {:error, :no_invoices} -> %>
<% {:error, :request_failed} -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="text-center text-black dark:text-gray-100 m-2">
Something went wrong
</p>
</div>
<% true -> %>
<div class="mt-8">
<PlausibleWeb.Components.Billing.upgrade_link
user={@user}
business_tier={FunWithFlags.enabled?(:business_tier, for: @user)}
/>
<% {:ok, invoice_list} when is_list(invoice_list) -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Amount
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Invoice
</th>
</tr>
</thead>
<%= for invoice <- format_invoices(invoice_list) do %>
<tbody class="divide-y divide-gray-200">
<tr>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200 font-medium">
<%= invoice.date %>
</td>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200">
<%= invoice.currency <> invoice.amount %>
</td>
<td class="py-4 text-sm text-indigo-500">
<%= link("Link", to: invoice.url, target: "_blank") %>
</td>
</tr>
</tbody>
<% end %>
</table>
</div>
<% end %>
<% end %>
<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 class="text-xl font-black dark:text-gray-100">Dashboard Appearance</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @settings_changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="col-span-4 sm:col-span-2">
<%= label(f, :theme, "Theme Selection",
class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300"
) %>
<%= select(f, :theme, Plausible.Themes.options(),
class:
"dark:bg-gray-900 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100 cursor-pointer"
) %>
</div>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Save
</PlausibleWeb.Components.Generic.button>
<% end %>
</div>
<%= case @invoices do %>
<% {:error, :no_invoices} -> %>
<% {:error, :request_failed} -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="text-center text-black dark:text-gray-100 m-2">
Something went wrong
</p>
</div>
<% {:ok, invoice_list} when is_list(invoice_list) -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Amount
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Invoice
</th>
</tr>
</thead>
<%= for invoice <- format_invoices(invoice_list) do %>
<tbody class="divide-y divide-gray-200">
<tr>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200 font-medium">
<%= invoice.date %>
</td>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200">
<%= invoice.currency <> invoice.amount %>
</td>
<td class="py-4 text-sm text-indigo-500">
<%= link("Link", to: invoice.url, target: "_blank") %>
</td>
</tr>
</tbody>
<% end %>
</table>
</div>
<% end %>
<% end %>
<div
:if={FunWithFlags.enabled?(:two_factor, for: @user)}
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)
</h2>
<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 class="text-xl font-black dark:text-gray-100">Dashboard Appearance</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @settings_changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="col-span-4 sm:col-span-2">
<%= label(f, :theme, "Theme Selection",
class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300"
) %>
<%= select(f, :theme, Plausible.Themes.options(),
class:
"dark:bg-gray-900 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100 cursor-pointer"
) %>
</div>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Save
</PlausibleWeb.Components.Generic.button>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 id="change-account-name" class="text-xl font-black dark:text-gray-100">
Change account name
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @settings_changeset, "/settings#change-account-name", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300") %>
<div class="mt-1">
<%= text_input(f, :name,
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, :name) %>
</div>
</div>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Save
</PlausibleWeb.Components.Generic.button>
<% 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-email-address" class="text-xl font-black dark:text-gray-100">
Change email address
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @email_changeset, "/settings/email#change-email-address", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :password, "Account password",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= password_input(f, :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) %>
</div>
</div>
<div class="my-4">
<%= label(f, :current_email, "Current email",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= email_input(f, :current_email,
readonly: true,
value: f.data.email,
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 bg-gray-100"
) %>
</div>
</div>
<div class="my-4">
<%= label(f, :email, "New email",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= email_input(f, :email,
value: f.params["email"],
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"
) %>
<%= error_tag(f, :email) %>
</div>
</div>
<%= submit("Change my email",
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-700 bg-white dark:bg-gray-800 hover:text-red-500 dark: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
id="api-keys"
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500"
>
<h2 class="text-xl font-black dark:text-gray-100">API Keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<PlausibleWeb.Components.Billing.premium_feature_notice
billable_user={@current_user}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.StatsAPI}
/>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@user.api_keys) do %>
<div class="overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Name
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Key
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Revoke</span>
</th>
</tr>
</thead>
<tbody>
<%= for api_key <- @user.api_keys do %>
<tr class="bg-white dark:bg-gray-800">
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
<%= api_key.name %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-100 whitespace-nowrap">
<%= api_key.key_prefix %><%= String.duplicate("*", 32 - 6) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= button("Revoke",
to: "/settings/api-keys/#{api_key.id}",
class: "text-red-600 hover:text-red-900",
method: :delete,
"data-confirm":
"Are you sure you want to revoke this key? This action cannot be reversed."
) %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<PlausibleWeb.Components.Generic.button_link
:if={Plausible.Billing.Feature.StatsAPI.check_availability(@current_user) == :ok}
href={Routes.auth_path(@conn, :new_api_key)}
class="mt-4"
>
+ New API Key
</PlausibleWeb.Components.Generic.button_link>
</div>
</div>
</div>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 mb-24 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Delete account</h2>
</div>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="dark:text-gray-100">
Deleting your account removes all sites and stats you've collected
</p>
<%= if @subscription && @subscription.status == Plausible.Billing.Subscription.Status.active() do %>
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300 cursor-not-allowed">
Delete my account
</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">
Two-Factor Authentication protects your account by adding an extra security step when you log in.
</p>
<% else %>
<%= link("Delete my account",
to: "/me",
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-700 bg-white dark:bg-gray-800 hover:text-red-500 dark: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",
method: "delete",
data: [
confirm:
"Deleting your account will also delete all the sites and data that you own. This action cannot be reversed. Are you sure?"
]
) %>
<% end %>
<%= if @totp_enabled? do %>
<button
x-on:click="disable2FAOpen = true; $refs.disable2FAPassword.value = ''"
type="button"
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-700 bg-white dark:bg-gray-800 hover:text-red-500 dark: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"
>
Disable 2FA
</button>
<p class="mt-2 text-gray-600 text-sm dark:text-gray-400">
Lost your recovery codes?
<a
href="#setup-2fa"
x-on:click="regenerate2FAOpen = true; $refs.regenerate2FAPassword.value = ''"
class="underline text-indigo-600"
>
Generate new
</a>
</p>
<% else %>
<%= form_for @conn.params, Routes.auth_path(@conn, :initiate_2fa_setup), fn _ -> %>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Enable 2FA
</PlausibleWeb.Components.Generic.button>
<% end %>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 id="change-account-name" class="text-xl font-black dark:text-gray-100">
Change account name
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @settings_changeset, "/settings#change-account-name", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300") %>
<div class="mt-1">
<%= text_input(f, :name,
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, :name) %>
</div>
</div>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Save
</PlausibleWeb.Components.Generic.button>
<% 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-email-address" class="text-xl font-black dark:text-gray-100">
Change email address
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @email_changeset, "/settings/email#change-email-address", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :password, "Account password",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= password_input(f, :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) %>
</div>
</div>
<div class="my-4">
<%= label(f, :current_email, "Current email",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= email_input(f, :current_email,
readonly: true,
value: f.data.email,
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 bg-gray-100"
) %>
</div>
</div>
<div class="my-4">
<%= label(f, :email, "New email",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= email_input(f, :email,
value: f.params["email"],
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"
) %>
<%= error_tag(f, :email) %>
</div>
</div>
<%= submit("Change my email",
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-700 bg-white dark:bg-gray-800 hover:text-red-500 dark: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
id="api-keys"
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500"
>
<h2 class="text-xl font-black dark:text-gray-100">API Keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<PlausibleWeb.Components.Billing.premium_feature_notice
billable_user={@current_user}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.StatsAPI}
/>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@user.api_keys) do %>
<div class="overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Name
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Key
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Revoke</span>
</th>
</tr>
</thead>
<tbody>
<%= for api_key <- @user.api_keys do %>
<tr class="bg-white dark:bg-gray-800">
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
<%= api_key.name %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-100 whitespace-nowrap">
<%= api_key.key_prefix %><%= String.duplicate("*", 32 - 6) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= button("Revoke",
to: "/settings/api-keys/#{api_key.id}",
class: "text-red-600 hover:text-red-900",
method: :delete,
"data-confirm":
"Are you sure you want to revoke this key? This action cannot be reversed."
) %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<PlausibleWeb.Components.Generic.button_link
:if={Plausible.Billing.Feature.StatsAPI.check_availability(@current_user) == :ok}
href={Routes.auth_path(@conn, :new_api_key)}
class="mt-4"
>
+ New API Key
</PlausibleWeb.Components.Generic.button_link>
</div>
</div>
</div>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 mb-24 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Delete account</h2>
</div>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="dark:text-gray-100">
Deleting your account removes all sites and stats you've collected
</p>
<%= if @subscription && @subscription.status == Plausible.Billing.Subscription.Status.active() do %>
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300 cursor-not-allowed">
Delete my account
</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.
</p>
<% else %>
<%= link("Delete my account",
to: "/me",
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-700 bg-white dark:bg-gray-800 hover:text-red-500 dark: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",
method: "delete",
data: [
confirm:
"Deleting your account will also delete all the sites and data that you own. This action cannot be reversed. Are you sure?"
]
) %>
<% end %>
</div>
<PlausibleWeb.Components.TwoFactor.modal
:let={f}
:if={FunWithFlags.enabled?(:two_factor, for: @user)}
id="disable-2fa-modal"
state_param="disable2FAOpen"
form_data={@conn.params}
form_target={Routes.auth_path(@conn, :disable_2fa)}
title="Disable Two-Factor Authentication?"
>
<:icon>
<Heroicons.shield_exclamation class="h-6 w-6" />
</:icon>
<:buttons>
<.button type="submit" class="w-full sm:w-auto">
Disable 2FA
</.button>
</:buttons>
<div class="text-sm mt-2">
Once disabled, verification codes from the authenticator application and current recovery codes will become invalid. 2FA will have to be setup from the start.
</div>
<div class="text-sm mt-2">
Enter your password to disable 2FA.
</div>
<div class="mt-3">
<%= password_input(f, :password,
id: "disable_2fa_password",
placeholder: "Enter password",
"x-ref": "disable2FAPassword",
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"
) %>
</div>
</PlausibleWeb.Components.TwoFactor.modal>
<PlausibleWeb.Components.TwoFactor.modal
:let={f}
:if={FunWithFlags.enabled?(:two_factor, for: @user)}
id="regenerate-2fa-modal"
state_param="regenerate2FAOpen"
form_data={@conn.params}
form_target={Routes.auth_path(@conn, :generate_2fa_recovery_codes)}
onsubmit="document.getElementById('generate-2fa-recovery-button').disabled = true"
title="Generate New Recovery Codes?"
>
<:icon>
<Heroicons.key class="h-6 w-6" />
</:icon>
<:buttons>
<.button
id="generate-2fa-recovery-button"
type="submit"
class="w-full sm:w-auto [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block"
>
<span class="label-enabled pointer-events-none">
Generate New Codes
</span>
<span class="label-disabled">
<.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" />
Generating Codes
</span>
</.button>
</:buttons>
<div class="text-sm mt-2">
If you generate new codes, the old ones will become invalid.
</div>
<div class="text-sm mt-2">
Enter your password to continue.
</div>
<div class="mt-3 w-full">
<%= password_input(f, :password,
id: "regenerate_2fa_password",
placeholder: "Enter password",
"x-ref": "regenerate2FAPassword",
class:
"w-full 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"
) %>
</div>
</PlausibleWeb.Components.TwoFactor.modal>
</div>

View File

@ -0,0 +1,43 @@
<div class="w-full max-w-3xl mt-4 mx-auto flex">
<%= form_for @conn.params, Routes.auth_path(@conn, :verify_2fa), [
class: "w-full max-w-lg mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8",
onsubmit: "document.getElementById('verify-button').disabled = true"
], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">
Enter Your 2FA Code
</h2>
<div class="text-sm mt-2 text-gray-500 dark:text-gray-200 leading-tight">
Enter the code from your authenticator application before it expires or wait for a new one.
<PlausibleWeb.Components.TwoFactor.verify_2fa_input form={f} field={:code} class="mt-6" />
<div class="mt-4 flex flex-inline items-center sm:justify-start justify-center">
<%= checkbox(f, :remember_2fa,
class:
"block h-5 w-5 rounded dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600"
) %>
<label class="block ml-2" for="remember_2fa">
Trust this device for <%= @remember_2fa_days %> days
</label>
</div>
<div class="mt-6 flex flex-row justify-between items-center">
<p class="text-sm">
Can't access your authenticator application?
<a
href={Routes.auth_path(@conn, :verify_2fa_recovery_code_form)}
class="underline text-indigo-600"
>
Use recovery code
</a>
<%= if full_build?() do %>
<br /> Lost your recovery codes?
<a href="https://plausible.io/contact" class="underline text-indigo-600">
Contact us
</a>
<% end %>
</p>
</div>
</div>
<% end %>
</div>

View File

@ -0,0 +1,59 @@
<div class="w-full max-w-3xl mt-4 mx-auto flex">
<%= form_for @conn.params,
Routes.auth_path(@conn, :verify_2fa_recovery_code),
[
class: "w-full max-w-lg mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8",
onsubmit: "document.getElementById('use-code-button').disabled = true"
], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">
Enter Recovery Code
</h2>
<div class="text-sm mt-2 text-gray-500 dark:text-gray-200 leading-tight">
Can't access your authenticator application? Enter a recovery code instead.
<div class="mt-6">
<div>
<%= text_input(f, :recovery_code,
value: "",
autocomplete: "off",
class:
"font-medium shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full px-2 border-gray-300 dark:border-gray-500 dark:text-gray-200 dark:bg-gray-900 rounded-md",
maxlength: "10",
oninvalid: "document.getElementById('use-code-button').disabled = false",
placeholder: "Enter recovery code",
required: "required"
) %>
</div>
<.button
id="use-code-button"
type="submit"
class="w-full mt-2 [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block"
>
<span class="label-enabled pointer-events-none">
Use Code
</span>
<span class="label-disabled">
<PlausibleWeb.Components.Generic.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" />
Verifying...
</span>
</.button>
</div>
<div class="mt-6 flex flex-row justify-between items-center">
<p class="text-sm">
Authenticator application working again?
<a href={Routes.auth_path(@conn, :verify_2fa)} class="underline text-indigo-600">
Enter verification code
</a>
<%= if full_build?() do %>
<br /> Lost your recovery codes?
<a href="https://plausible.io/contact" class="underline text-indigo-600">
Contact us
</a>
<% end %>
</p>
</div>
</div>
<% end %>
</div>

View File

@ -0,0 +1,38 @@
<div class="w-full max-w-3xl mt-4 mx-auto flex">
<div class="w-full max-w-lg mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8">
<h2 class="text-xl font-black dark:text-gray-100">
Setup Two-Factor Authentication
</h2>
<div class="text-sm mt-2 text-gray-500 dark:text-gray-200 leading-tight">
<%= form_for @conn.params, Routes.auth_path(@conn, :verify_2fa_setup), [
id: "verify-2fa-form",
onsubmit: "document.getElementById('verify-button').disabled = true"
], fn f -> %>
Enter the code from your authenticator application before it expires or wait for a new one.
<PlausibleWeb.Components.TwoFactor.verify_2fa_input form={f} field={:code} class="mt-6" />
<% end %>
<div class="mt-6 flex flex-col">
<p class="text-sm">
Changed your mind?
<a
href={Routes.auth_path(@conn, :user_settings) <> "#setup-2fa"}
class="underline text-indigo-600"
>
Go back to Settings
</a>
</p>
<p class="text-sm">
<%= form_for @conn.params, Routes.auth_path(@conn, :initiate_2fa_setup), [id: "start-over-form"], fn _f -> %>
Having trouble?
<button class="underline text-indigo-600">
Start over
</button>
<% end %>
</p>
</div>
</div>
</div>
</div>

View File

@ -1 +1 @@
Two-factor authentication is now disabled on your account.
Two-Factor Authentication is now disabled on your account.

View File

@ -1 +1 @@
Two-factor authentication is now enabled on your account.
Two-Factor Authentication is now enabled on your account.

View File

@ -0,0 +1,96 @@
defmodule PlausibleWeb.TwoFactor.Session do
@moduledoc """
Functions for managing session data related to Two-Factor
Authentication.
"""
import Plug.Conn
alias Plausible.Auth
@remember_2fa_cookie "remember_2fa"
@remember_2fa_days 30
@remember_2fa_seconds @remember_2fa_days * 24 * 60 * 60
@session_2fa_cookie "session_2fa"
@session_2fa_seconds 5 * 60
@spec set_2fa_user(Plug.Conn.t(), Auth.User.t()) :: Plug.Conn.t()
def set_2fa_user(conn, %Auth.User{} = user) do
put_resp_cookie(conn, @session_2fa_cookie, %{current_2fa_user_id: user.id},
domain: domain(),
secure: secure_cookie?(),
encrypt: true,
max_age: @session_2fa_seconds,
same_site: "Lax"
)
end
@spec get_2fa_user(Plug.Conn.t()) :: {:ok, Auth.User.t()} | {:error, :not_found}
def get_2fa_user(conn) do
conn = fetch_cookies(conn, encrypted: [@session_2fa_cookie])
session_2fa = conn.cookies[@session_2fa_cookie]
with id when is_integer(id) <- session_2fa[:current_2fa_user_id],
%Auth.User{} = user <- Plausible.Users.with_subscription(id) do
{:ok, user}
else
_ -> {:error, :not_found}
end
end
@spec clear_2fa_user(Plug.Conn.t()) :: Plug.Conn.t()
def clear_2fa_user(conn) do
delete_resp_cookie(conn, @session_2fa_cookie,
domain: domain(),
secure: secure_cookie?(),
encrypt: true,
max_age: @session_2fa_seconds,
same_site: "Lax"
)
end
@spec remember_2fa_days() :: non_neg_integer()
def remember_2fa_days(), do: @remember_2fa_days
@spec remember_2fa?(Plug.Conn.t(), Auth.User.t()) :: boolean()
def remember_2fa?(conn, user) do
conn = fetch_cookies(conn, encrypted: [@remember_2fa_cookie])
not is_nil(user.totp_token) and conn.cookies[@remember_2fa_cookie] == user.totp_token
end
@spec maybe_set_remember_2fa(Plug.Conn.t(), Auth.User.t(), String.t() | nil) :: Plug.Conn.t()
def maybe_set_remember_2fa(conn, user, "true") do
put_resp_cookie(conn, @remember_2fa_cookie, user.totp_token,
domain: domain(),
secure: secure_cookie?(),
encrypt: true,
max_age: @remember_2fa_seconds,
same_site: "Lax"
)
end
def maybe_set_remember_2fa(conn, _, _) do
clear_remember_2fa(conn)
end
@spec clear_remember_2fa(Plug.Conn.t()) :: Plug.Conn.t()
def clear_remember_2fa(conn) do
delete_resp_cookie(conn, @remember_2fa_cookie,
domain: domain(),
secure: secure_cookie?(),
encrypt: true,
max_age: @remember_2fa_seconds,
same_site: "Lax"
)
end
defp domain(), do: PlausibleWeb.Endpoint.host()
defp secure_cookie?() do
:plausible
|> Application.fetch_env!(PlausibleWeb.Endpoint)
|> Keyword.fetch!(:secure_cookie)
end
end

View File

@ -80,6 +80,7 @@ defmodule Plausible.MixProject do
{:ecto, "~> 3.10.0"},
{:ecto_sql, "~> 3.10.0"},
{:envy, "~> 1.1.1"},
{:eqrcode, "~> 0.1.10"},
{:ex_machina, "~> 2.3", only: [:dev, :test, :small_dev, :small_test]},
{:excoveralls, "~> 0.10", only: :test},
{:finch, "~> 0.16.0"},

View File

@ -37,6 +37,7 @@
"ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"},
"elixir_make": {:hex, :elixir_make, "0.7.6", "67716309dc5d43e16b5abbd00c01b8df6a0c2ab54a8f595468035a50189f9169", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5a0569756b0f7873a77687800c164cca6dfc03a09418e6fcf853d78991f49940"},
"envy": {:hex, :envy, "1.1.1", "0bc9bd654dec24fcdf203f7c5aa1b8f30620f12cfb28c589d5e9c38fe1b07475", [:mix], [], "hexpm", "7061eb1a47415fd757145d8dec10dc0b1e48344960265cb108f194c4252c3a89"},
"eqrcode": {:hex, :eqrcode, "0.1.10", "6294fece9d68ad64eef1c3c92cf111cfd6469f4fbf230a2d4cc905a682178f3f", [:mix], [], "hexpm", "da30e373c36a0fd37ab6f58664b16029919896d6c45a68a95cc4d713e81076f1"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"},
"eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"},

View File

@ -10,6 +10,8 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
FunWithFlags.enable(:two_factor)
user = Plausible.Factory.insert(:user, email: "user@plausible.test", password: "plausible")
native_stats_range =

View File

@ -37,6 +37,7 @@ defmodule Plausible.Auth.TOTPTest do
assert updated_user.id == user.id
refute updated_user.totp_enabled
assert is_nil(updated_user.totp_token)
assert byte_size(updated_user.totp_secret) > 0
assert Regex.match?(~r/[0-9A-Z]+/, params.secret)
@ -53,6 +54,7 @@ defmodule Plausible.Auth.TOTPTest do
assert new_params.secret != params.secret
refute updated_user.totp_enabled
assert is_nil(updated_user.totp_token)
assert byte_size(updated_user.totp_secret) > 0
assert updated_user.totp_secret != user.totp_secret
end
@ -83,10 +85,11 @@ defmodule Plausible.Auth.TOTPTest do
assert_email_delivered_with(
to: [{user.name, user.email}],
subject: "Plausible two-factor authentication enabled"
subject: "Plausible Two-Factor Authentication enabled"
)
assert user.totp_enabled
assert byte_size(user.totp_token) > 0
assert byte_size(user.totp_secret) > 0
persisted_recovery_codes = Repo.all(RecoveryCode)
@ -117,6 +120,8 @@ defmodule Plausible.Auth.TOTPTest do
assert updated_user.id == user.id
assert updated_user.totp_enabled
assert byte_size(updated_user.totp_token) > 0
assert updated_user.totp_token != user.totp_token
assert updated_user.totp_secret == user.totp_secret
assert Enum.uniq(recovery_codes ++ new_recovery_codes) ==
@ -155,18 +160,19 @@ defmodule Plausible.Auth.TOTPTest do
assert_email_delivered_with(
to: [{user.name, user.email}],
subject: "Plausible two-factor authentication enabled"
subject: "Plausible Two-Factor Authentication enabled"
)
assert {:ok, updated_user} = TOTP.disable(user, "VeryStrongVerySecret")
assert_email_delivered_with(
to: [{user.name, user.email}],
subject: "Plausible two-factor authentication disabled"
subject: "Plausible Two-Factor Authentication disabled"
)
assert updated_user.id == user.id
refute updated_user.totp_enabled
assert is_nil(updated_user.totp_token)
assert is_nil(updated_user.totp_secret)
assert Repo.all(RecoveryCode) == []
@ -179,6 +185,7 @@ defmodule Plausible.Auth.TOTPTest do
assert updated_user.id == user.id
refute updated_user.totp_enabled
assert is_nil(updated_user.totp_token)
assert is_nil(updated_user.totp_secret)
end
@ -190,7 +197,7 @@ defmodule Plausible.Auth.TOTPTest do
assert_email_delivered_with(
to: [{user.name, user.email}],
subject: "Plausible two-factor authentication enabled"
subject: "Plausible Two-Factor Authentication enabled"
)
assert {:error, :invalid_password} = TOTP.disable(user, "invalid")
@ -199,6 +206,39 @@ defmodule Plausible.Auth.TOTPTest do
end
end
describe "reset_token/1" do
test "generates new token when TOTP enabled" do
user = insert(:user, password: "VeryStrongVerySecret")
{:ok, user, _} = TOTP.initiate(user)
code = NimbleTOTP.verification_code(user.totp_secret)
{:ok, user, _} = TOTP.enable(user, code)
assert %{totp_token: new_token} = TOTP.reset_token(user)
assert byte_size(new_token) > 0
assert new_token != user.totp_token
end
test "sets to nil when TOTP disabled" do
user = insert(:user)
assert %{totp_token: nil} = TOTP.reset_token(user)
user2 = insert(:user, password: "VeryStrongVerySecret")
{:ok, user2, _} = TOTP.initiate(user2)
code = NimbleTOTP.verification_code(user2.totp_secret)
{:ok, user2, _} = TOTP.enable(user2, code)
{:ok, user2} = TOTP.disable(user2, "VeryStrongVerySecret")
assert %{totp_token: nil} = TOTP.reset_token(user2)
user3 = insert(:user)
{:ok, user3, _} = TOTP.initiate(user3)
assert %{totp_token: nil} = TOTP.reset_token(user3)
end
end
describe "generate_recovery_codes/1" do
test "generates recovery codes for user with enabled TOTP" do
user = insert(:user, password: "VeryStrongVerySecret")

View File

@ -335,6 +335,73 @@ defmodule PlausibleWeb.AuthControllerTest do
assert redirected_to(conn) == "/sites"
end
test "valid email and password with login_dest set - redirects properly", %{conn: conn} do
user = insert(:user, password: "password")
conn =
conn
|> init_session()
|> put_session(:login_dest, "/settings")
conn = post(conn, "/login", email: user.email, password: "password")
assert redirected_to(conn, 302) == "/settings"
end
test "valid email and password with 2FA enabled - sets 2FA session and redirects", %{
conn: conn
} do
user = insert(:user, password: "password")
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = post(conn, "/login", email: user.email, password: "password")
assert redirected_to(conn, 302) == Routes.auth_path(conn, :verify_2fa_form)
assert fetch_cookies(conn).cookies["session_2fa"].current_2fa_user_id == user.id
refute get_session(conn)["current_user_id"]
end
test "valid email and password with 2FA enabled and remember 2FA cookie set - logs the user in",
%{conn: conn} do
user = insert(:user, password: "password")
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = set_remember_2fa_cookie(conn, user)
conn = post(conn, "/login", email: user.email, password: "password")
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert conn.resp_cookies["session_2fa"].max_age == 0
assert get_session(conn, :current_user_id) == user.id
end
test "valid email and password with 2FA enabled and rogue remember 2FA cookie set - logs the user in",
%{conn: conn} do
user = insert(:user, password: "password")
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
another_user = insert(:user)
conn = set_remember_2fa_cookie(conn, another_user)
conn = post(conn, "/login", email: user.email, password: "password")
assert redirected_to(conn, 302) == Routes.auth_path(conn, :verify_2fa_form)
assert fetch_cookies(conn).cookies["session_2fa"].current_2fa_user_id == user.id
refute get_session(conn, :current_user_id)
end
test "email does not exist - renders login form again", %{conn: conn} do
conn = post(conn, "/login", email: "user@example.com", password: "password")
@ -354,28 +421,28 @@ defmodule PlausibleWeb.AuthControllerTest do
user = insert(:user, password: "password")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> put_req_header("x-forwarded-for", "1.2.3.5")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> put_req_header("x-forwarded-for", "1.2.3.5")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> put_req_header("x-forwarded-for", "1.2.3.5")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> put_req_header("x-forwarded-for", "1.2.3.5")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> put_req_header("x-forwarded-for", "1.2.3.5")
|> post("/login", email: user.email, password: "wrong")
conn =
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> put_req_header("x-forwarded-for", "1.2.3.5")
|> post("/login", email: user.email, password: "wrong")
assert get_session(conn, :current_user_id) == nil
@ -1029,6 +1096,21 @@ defmodule PlausibleWeb.AuthControllerTest do
assert team_member_usage_row_text =~ "Team members 0 / 3"
end
test "redners 2FA section in disabled state", %{conn: conn} do
conn = get(conn, "/settings")
assert html_response(conn, 200) =~ "Enable 2FA"
end
test "renders 2FA in enabled state", %{conn: conn, user: user} do
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = get(conn, "/settings")
assert html_response(conn, 200) =~ "Disable 2FA"
end
end
describe "PUT /settings" do
@ -1375,6 +1457,627 @@ defmodule PlausibleWeb.AuthControllerTest do
end
end
describe "POST /2fa/setup/initiate" do
setup [:create_user, :log_in]
test "initiates setup rendering QR and human friendly versions of secret", %{
conn: conn,
user: user
} do
conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup))
secret = Base.encode32(Repo.reload!(user).totp_secret)
assert html = html_response(conn, 200)
assert element_exists?(html, "svg")
assert html =~ secret
end
test "redirects back to settings if 2FA is already setup", %{conn: conn, user: user} do
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Two-Factor Authentication is already setup"
end
end
describe "GET /2fa/setup/verify" do
setup [:create_user, :log_in]
test "renders form when 2FA setup is initiated", %{conn: conn, user: user} do
{:ok, _, _} = Auth.TOTP.initiate(user)
conn = get(conn, Routes.auth_path(conn, :verify_2fa_setup))
assert html = html_response(conn, 200)
assert text_of_attr(html, "form#verify-2fa-form", "action") ==
Routes.auth_path(conn, :verify_2fa_setup)
assert element_exists?(html, "input[name=code]")
assert text_of_attr(html, "form#start-over-form", "action") ==
Routes.auth_path(conn, :initiate_2fa_setup)
end
test "redirects back to settings if 2FA not initiated", %{conn: conn} do
conn = get(conn, Routes.auth_path(conn, :verify_2fa_setup))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
end
end
describe "POST /2fa/setup/verify" do
setup [:create_user, :log_in]
test "enables 2FA and renders recovery codes when valid code provided", %{
conn: conn,
user: user
} do
{:ok, user, _} = Auth.TOTP.initiate(user)
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: code})
assert html = html_response(conn, 200)
assert list = [_ | _] = find(html, "#recovery-codes-list > *")
assert length(list) == 10
assert user |> Repo.reload!() |> Auth.TOTP.enabled?()
end
test "renders error on invalid code provided", %{conn: conn, user: user} do
{:ok, _, _} = Auth.TOTP.initiate(user)
conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: "invalid"})
assert html_response(conn, 200)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"The provided code is invalid."
end
test "redirects to settings when 2FA is not initiated", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: "123123"})
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Please enable Two-Factor Authentication"
end
end
describe "POST /2fa/disable" do
setup [:create_user, :log_in]
test "disables 2FA when valid password provided", %{conn: conn, user: user} do
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"})
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
assert Phoenix.Flash.get(conn.assigns.flash, :success) =~
"Two-Factor Authentication is disabled"
refute user |> Repo.reload!() |> Auth.TOTP.enabled?()
end
test "renders error when invalid password provided", %{conn: conn, user: user} do
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "invalid"})
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided"
end
end
describe "POST /2fa/recovery_codes" do
setup [:create_user, :log_in]
test "generates new recovery codes when valid password provided", %{conn: conn, user: user} do
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn =
post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "password"})
assert html = html_response(conn, 200)
assert list = [_ | _] = find(html, "#recovery-codes-list > *")
assert length(list) == 10
end
test "renders error when invalid password provided", %{conn: conn, user: user} do
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn =
post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "invalid"})
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided"
end
test "renders error when 2FA is not enabled", %{conn: conn} do
conn =
post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "password"})
assert redirected_to(conn, 302) == Routes.auth_path(conn, :user_settings) <> "#setup-2fa"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Please enable Two-Factor Authentication"
end
end
describe "GET /2fa/verify" do
test "renders verification form when 2FA session present", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn = get(conn, Routes.auth_path(conn, :verify_2fa_form))
assert html = html_response(conn, 200)
assert text_of_attr(html, "form", "action") == Routes.auth_path(conn, :verify_2fa)
assert element_exists?(html, "input[name=code]")
assert element_exists?(html, "input[name=remember_2fa]")
assert element_exists?(
html,
"a[href='#{Routes.auth_path(conn, :verify_2fa_recovery_code_form)}']"
)
end
test "redirects to login when cookie not found", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = get(conn, Routes.auth_path(conn, :verify_2fa_form))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form)
end
test "redirects to login when 2FA not enabled", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
{:ok, _} = Auth.TOTP.disable(user, "password")
conn = get(conn, Routes.auth_path(conn, :verify_2fa_form))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form)
end
end
describe "POST /2fa/verify" do
test "redirects to sites when code verification succeeds", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
# Remember cookie unset
assert conn.resp_cookies["remember_2fa"].max_age == 0
end
test "redirects to login_dest when set", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn =
conn
|> init_session()
|> put_session(:login_dest, "/settings")
conn = login_with_cookie(conn, user.email, "password")
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code})
assert redirected_to(conn, 302) == "/settings"
end
test "sets remember cookie when device trusted", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, remember_2fa: "true"})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
# Remember cookie set
assert conn.resp_cookies["remember_2fa"].max_age > 0
end
test "overwrites rogue remember cookie when device trusted", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
another_user = insert(:user, totp_token: "different_token")
conn = set_remember_2fa_cookie(conn, another_user)
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, remember_2fa: "true"})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
# Remember cookie set
assert conn.resp_cookies["remember_2fa"].max_age > 0
assert fetch_cookies(conn).cookies["remember_2fa"] == user.totp_token
end
test "clears rogue remember cookie when device _not_ trusted", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
another_user = insert(:user, totp_token: "different_token")
conn = set_remember_2fa_cookie(conn, another_user)
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
# Remember cookie cleared
assert conn.resp_cookies["remember_2fa"].max_age == 0
end
test "returns error on invalid code", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
assert html_response(conn, 200)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"The provided code is invalid"
end
test "redirects to login when cookie not found", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
code = NimbleTOTP.verification_code(user.totp_secret)
conn = post(conn, Routes.auth_path(conn, :verify_2fa, %{code: code}))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form)
end
test "passes through when 2FA is disabled", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
code = NimbleTOTP.verification_code(user.totp_secret)
{:ok, _} = Auth.TOTP.disable(user, "password")
conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
end
test "limits verification attempts to 5 per minute", %{conn: conn} do
user = insert(:user, email: "ratio#{Ecto.UUID.generate()}@example.com")
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post(Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post(Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post(Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post(Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post(Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
conn =
conn
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post(Routes.auth_path(conn, :verify_2fa), %{code: "invalid"})
assert get_session(conn, :current_user_id) == nil
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
assert html_response(conn, 429) =~ "Too many login attempts"
end
end
describe "GET /2fa/use_recovery_code" do
test "renders recovery verification form when 2FA session present", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form))
assert html = html_response(conn, 200)
assert text_of_attr(html, "form", "action") ==
Routes.auth_path(conn, :verify_2fa_recovery_code)
assert element_exists?(html, "input[name=recovery_code]")
assert element_exists?(html, "a[href='#{Routes.auth_path(conn, :verify_2fa_form)}']")
end
test "redirects to login when cookie not found", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form)
end
test "redirects to login when 2FA not enabled", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
{:ok, _} = Auth.TOTP.disable(user, "password")
conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form))
assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form)
end
end
describe "POST /2fa/use_recovery_code" do
test "redirects to sites when recovery code verification succeeds", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn =
post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{
recovery_code: recovery_code
})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
end
test "returns error on invalid recovery code", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn =
post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
assert html_response(conn, 200)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"The provided recovery code is invalid"
end
test "redirects to login when cookie not found", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, _, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify)
conn =
post(
conn,
Routes.auth_path(conn, :verify_2fa_recovery_code, %{recovery_code: recovery_code})
)
assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form)
end
test "passes through when 2FA is disabled", %{conn: conn} do
user = insert(:user)
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
{:ok, _} = Auth.TOTP.disable(user, "password")
conn =
post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{
recovery_code: recovery_code
})
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert get_session(conn)["current_user_id"] == user.id
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
end
test "limits verification attempts to 5 per minute", %{conn: conn} do
user = insert(:user, email: "ratio#{Ecto.UUID.generate()}@example.com")
# enable 2FA
{:ok, user, _} = Auth.TOTP.initiate(user)
{:ok, user, _} = Auth.TOTP.enable(user, :skip_verify)
conn = login_with_cookie(conn, user.email, "password")
conn
|> put_req_header("x-forwarded-for", "1.2.3.4")
|> post(Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.2.3.4")
|> post(Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.2.3.4")
|> post(Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.2.3.4")
|> post(Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
conn
|> put_req_header("x-forwarded-for", "1.2.3.4")
|> post(Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
conn =
conn
|> put_req_header("x-forwarded-for", "1.2.3.4")
|> post(Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"})
assert get_session(conn, :current_user_id) == nil
# 2FA session terminated
assert conn.resp_cookies["session_2fa"].max_age == 0
assert html_response(conn, 429) =~ "Too many login attempts"
end
end
defp login_with_cookie(conn, email, password) do
conn
|> post(Routes.auth_path(conn, :login), %{
email: email,
password: password
})
|> recycle()
|> Map.put(:secret_key_base, secret_key_base())
|> Plug.Conn.put_req_header("x-forwarded-for", Plausible.TestUtils.random_ip())
end
defp set_remember_2fa_cookie(conn, user) do
conn
|> PlausibleWeb.TwoFactor.Session.maybe_set_remember_2fa(user, "true")
|> recycle()
|> Map.put(:secret_key_base, secret_key_base())
|> Plug.Conn.put_req_header("x-forwarded-for", Plausible.TestUtils.random_ip())
end
defp mock_captcha_success() do
mock_captcha(true)
end
@ -1406,4 +2109,10 @@ defmodule PlausibleWeb.AuthControllerTest do
billing_interval: :yearly
)
end
defp secret_key_base() do
:plausible
|> Application.fetch_env!(PlausibleWeb.Endpoint)
|> Keyword.fetch!(:secret_key_base)
end
end

View File

@ -6,6 +6,7 @@ defmodule PlausibleWeb.Live.ResetPasswordFormTest do
alias Plausible.Auth.User
alias Plausible.Auth.Token
alias Plausible.Auth.TOTP
alias Plausible.Repo
describe "/password/reset" do
@ -25,6 +26,24 @@ defmodule PlausibleWeb.Live.ResetPasswordFormTest do
assert new_hash != user.password_hash
end
test "reset's user's TOTP token when present", %{conn: conn} do
user = insert(:user)
{:ok, user, _} = TOTP.initiate(user)
{:ok, user, _} = TOTP.enable(user, :skip_verify)
token = Token.sign_password_reset(user.email)
lv = get_liveview(conn, "/password/reset?token=#{token}")
type_into_passowrd(lv, "very-secret-and-very-long-123")
lv |> element("form") |> render_submit()
updated_user = Repo.reload!(user)
assert byte_size(updated_user.totp_token) > 0
assert updated_user.totp_token != user.totp_token
end
test "renders error when new password fails validation", %{conn: conn} do
user = insert(:user)
token = Token.sign_password_reset(user.email)

View File

@ -41,8 +41,15 @@ defmodule PlausibleWeb.ConnCase do
# rate limiting during tests
conn =
Phoenix.ConnTest.build_conn()
|> Map.put(:secret_key_base, secret_key_base())
|> Plug.Conn.put_req_header("x-forwarded-for", Plausible.TestUtils.random_ip())
{:ok, conn: conn}
end
defp secret_key_base() do
:plausible
|> Application.fetch_env!(PlausibleWeb.Endpoint)
|> Keyword.fetch!(:secret_key_base)
end
end

View File

@ -3,6 +3,7 @@ Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
Application.ensure_all_started(:double)
FunWithFlags.enable(:business_tier)
FunWithFlags.enable(:window_time_on_page)
FunWithFlags.enable(:two_factor)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
if Mix.env() == :small_test do