mirror of
https://github.com/plausible/analytics.git
synced 2025-01-03 07:08:04 +03:00
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:
parent
4566e6b530
commit
da0fa6c355
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
|
155
lib/plausible_web/components/two_factor.ex
Normal file
155
lib/plausible_web/components/two_factor.ex
Normal 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 →
|
||||
</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">
|
||||
​
|
||||
</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
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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)}
|
||||
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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
|
||||
|
@ -27,7 +27,9 @@
|
||||
required: "required"
|
||||
) %>
|
||||
</div>
|
||||
<button id="submit" class="button rounded-l-none">Activate →</button>
|
||||
<PlausibleWeb.Components.Generic.button id="submit" type="submit" class="rounded-l-none">
|
||||
Activate →
|
||||
</PlausibleWeb.Components.Generic.button>
|
||||
</div>
|
||||
<%= error_tag(assigns, :error) %>
|
||||
|
||||
|
@ -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>
|
@ -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 →
|
||||
</.unstyled_link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -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>
|
||||
|
43
lib/plausible_web/templates/auth/verify_2fa.html.heex
Normal file
43
lib/plausible_web/templates/auth/verify_2fa.html.heex
Normal 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>
|
@ -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>
|
38
lib/plausible_web/templates/auth/verify_2fa_setup.html.heex
Normal file
38
lib/plausible_web/templates/auth/verify_2fa_setup.html.heex
Normal 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>
|
@ -1 +1 @@
|
||||
Two-factor authentication is now disabled on your account.
|
||||
Two-Factor Authentication is now disabled on your account.
|
||||
|
@ -1 +1 @@
|
||||
Two-factor authentication is now enabled on your account.
|
||||
Two-Factor Authentication is now enabled on your account.
|
||||
|
96
lib/plausible_web/two_factor/session.ex
Normal file
96
lib/plausible_web/two_factor/session.ex
Normal 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
|
1
mix.exs
1
mix.exs
@ -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"},
|
||||
|
1
mix.lock
1
mix.lock
@ -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"},
|
||||
|
@ -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 =
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user