mirror of
https://github.com/plausible/analytics.git
synced 2024-12-28 12:01:39 +03:00
da0fa6c355
* 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>
746 lines
23 KiB
Elixir
746 lines
23 KiB
Elixir
defmodule PlausibleWeb.AuthController do
|
|
use PlausibleWeb, :controller
|
|
use Plausible.Repo
|
|
|
|
alias Plausible.Auth
|
|
alias Plausible.Billing.Quota
|
|
alias PlausibleWeb.TwoFactor
|
|
|
|
require Logger
|
|
|
|
plug(
|
|
PlausibleWeb.RequireLoggedOutPlug
|
|
when action in [
|
|
:register,
|
|
:register_from_invitation,
|
|
:login_form,
|
|
:login,
|
|
:verify_2fa_form,
|
|
:verify_2fa,
|
|
:verify_2fa_recovery_code_form,
|
|
:verify_2fa_recovery_code
|
|
]
|
|
)
|
|
|
|
plug(
|
|
PlausibleWeb.RequireAccountPlug
|
|
when action in [
|
|
:user_settings,
|
|
:save_settings,
|
|
:update_email,
|
|
:cancel_update_email,
|
|
:new_api_key,
|
|
:create_api_key,
|
|
:delete_api_key,
|
|
:delete_me,
|
|
:activate_form,
|
|
:activate,
|
|
: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)
|
|
|
|
if user.email_verified do
|
|
redirect(conn, to: Routes.site_path(conn, :new))
|
|
else
|
|
Auth.EmailVerification.issue_code(user)
|
|
redirect(conn, to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
end
|
|
end
|
|
|
|
def register_from_invitation(conn, %{"user" => %{"email" => email, "password" => password}}) do
|
|
with {:ok, user} <- login_user(conn, email, password) do
|
|
conn = set_user_session(conn, user)
|
|
|
|
if user.email_verified do
|
|
redirect(conn, to: Routes.site_path(conn, :index))
|
|
else
|
|
Auth.EmailVerification.issue_code(user)
|
|
redirect(conn, to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
end
|
|
end
|
|
|
|
def activate_form(conn, _params) do
|
|
user = conn.assigns.current_user
|
|
|
|
render(conn, "activate.html",
|
|
has_email_code?: Plausible.Users.has_email_code?(user),
|
|
has_any_invitations?: Plausible.Site.Memberships.pending?(user.email),
|
|
has_any_memberships?: Plausible.Site.Memberships.any?(user),
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def activate(conn, %{"code" => code}) do
|
|
user = conn.assigns[:current_user]
|
|
|
|
has_any_invitations? = Plausible.Site.Memberships.pending?(user.email)
|
|
has_any_memberships? = Plausible.Site.Memberships.any?(user)
|
|
|
|
case Auth.EmailVerification.verify_code(user, code) do
|
|
:ok ->
|
|
cond do
|
|
has_any_memberships? ->
|
|
handle_email_updated(conn)
|
|
|
|
has_any_invitations? ->
|
|
redirect(conn, to: Routes.site_path(conn, :index))
|
|
|
|
true ->
|
|
redirect(conn, to: Routes.site_path(conn, :new))
|
|
end
|
|
|
|
{:error, :incorrect} ->
|
|
render(conn, "activate.html",
|
|
error: "Incorrect activation code",
|
|
has_email_code?: true,
|
|
has_any_invitations?: has_any_invitations?,
|
|
has_any_memberships?: has_any_memberships?,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
{:error, :expired} ->
|
|
render(conn, "activate.html",
|
|
error: "Code is expired, please request another one",
|
|
has_email_code?: false,
|
|
has_any_invitations?: has_any_invitations?,
|
|
has_any_memberships?: has_any_memberships?,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def request_activation_code(conn, _params) do
|
|
user = conn.assigns.current_user
|
|
Auth.EmailVerification.issue_code(user)
|
|
|
|
conn
|
|
|> put_flash(:success, "Activation code was sent to #{user.email}")
|
|
|> redirect(to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
|
|
def password_reset_request_form(conn, _) do
|
|
render(conn, "password_reset_request_form.html",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def password_reset_request(conn, %{"email" => ""}) do
|
|
render(conn, "password_reset_request_form.html",
|
|
error: "Please enter an email address",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def password_reset_request(conn, %{"email" => email} = params) do
|
|
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
|
user = Repo.get_by(Plausible.Auth.User, email: email)
|
|
|
|
if user do
|
|
token = Auth.Token.sign_password_reset(email)
|
|
url = PlausibleWeb.Endpoint.url() <> "/password/reset?token=#{token}"
|
|
email_template = PlausibleWeb.Email.password_reset_email(email, url)
|
|
Plausible.Mailer.deliver_later(email_template)
|
|
|
|
Logger.debug(
|
|
"Password reset e-mail sent. In dev environment GET /sent-emails for details."
|
|
)
|
|
|
|
render(conn, "password_reset_request_success.html",
|
|
email: email,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
else
|
|
render(conn, "password_reset_request_success.html",
|
|
email: email,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
else
|
|
render(conn, "password_reset_request_form.html",
|
|
error: "Please complete the captcha to reset your password",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def password_reset_form(conn, params) do
|
|
case Auth.Token.verify_password_reset(params["token"]) do
|
|
{:ok, %{email: email}} ->
|
|
render(conn, "password_reset_form.html",
|
|
connect_live_socket: true,
|
|
email: email,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
{:error, :expired} ->
|
|
render_error(
|
|
conn,
|
|
401,
|
|
"Your token has expired. Please request another password reset link."
|
|
)
|
|
|
|
{:error, _} ->
|
|
render_error(
|
|
conn,
|
|
401,
|
|
"Your token is invalid. Please request another password reset link."
|
|
)
|
|
end
|
|
end
|
|
|
|
def password_reset(conn, _params) do
|
|
conn
|
|
|> put_flash(:login_title, "Password updated successfully")
|
|
|> put_flash(:login_instructions, "Please log in with your new credentials")
|
|
|> put_session(:current_user_id, nil)
|
|
|> delete_resp_cookie("logged_in")
|
|
|> redirect(to: Routes.auth_path(conn, :login_form))
|
|
end
|
|
|
|
def login(conn, %{"email" => email, "password" => password}) do
|
|
with {:ok, user} <- login_user(conn, email, password) do
|
|
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
|
|
|
|
defp login_user(conn, email, password) do
|
|
with :ok <- check_ip_rate_limit(conn),
|
|
{:ok, user} <- find_user(email),
|
|
:ok <- check_user_rate_limit(user),
|
|
:ok <- check_password(user, password) do
|
|
{:ok, user}
|
|
else
|
|
:wrong_password ->
|
|
maybe_log_failed_login_attempts("wrong password for #{email}")
|
|
|
|
render(conn, "login_form.html",
|
|
error: "Wrong email or password. Please try again.",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
:user_not_found ->
|
|
maybe_log_failed_login_attempts("user not found for #{email}")
|
|
Plausible.Auth.Password.dummy_calculation()
|
|
|
|
render(conn, "login_form.html",
|
|
error: "Wrong email or password. Please try again.",
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
|
|
{:rate_limit, _} ->
|
|
maybe_log_failed_login_attempts("too many logging attempts for #{email}")
|
|
|
|
render_error(
|
|
conn,
|
|
429,
|
|
"Too many login attempts. Wait a minute before trying again."
|
|
)
|
|
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,
|
|
max_age: 60 * 60 * 24 * 365 * 5000
|
|
)
|
|
end
|
|
|
|
defp maybe_log_failed_login_attempts(message) do
|
|
if Application.get_env(:plausible, :log_failed_login_attempts) do
|
|
Logger.warning("[login] #{message}")
|
|
end
|
|
end
|
|
|
|
@login_interval 60_000
|
|
@login_limit 5
|
|
|
|
defp check_ip_rate_limit(conn) do
|
|
ip_address = PlausibleWeb.RemoteIp.get(conn)
|
|
|
|
case Hammer.check_rate("login:ip:#{ip_address}", @login_interval, @login_limit) do
|
|
{:allow, _} -> :ok
|
|
{:deny, _} -> {:rate_limit, :ip_address}
|
|
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(
|
|
from(u in Plausible.Auth.User,
|
|
where: u.email == ^email
|
|
)
|
|
)
|
|
|
|
if user, do: {:ok, user}, else: :user_not_found
|
|
end
|
|
|
|
defp check_password(user, password) do
|
|
if Plausible.Auth.Password.match?(password, user.password_hash || "") do
|
|
:ok
|
|
else
|
|
:wrong_password
|
|
end
|
|
end
|
|
|
|
def login_form(conn, _params) do
|
|
render(conn, "login_form.html", layout: {PlausibleWeb.LayoutView, "focus.html"})
|
|
end
|
|
|
|
def user_settings(conn, _params) do
|
|
user = conn.assigns.current_user
|
|
settings_changeset = Auth.User.settings_changeset(user)
|
|
email_changeset = Auth.User.settings_changeset(user)
|
|
|
|
render_settings(conn,
|
|
settings_changeset: settings_changeset,
|
|
email_changeset: email_changeset
|
|
)
|
|
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
|
|
user = conn.assigns.current_user
|
|
changes = Auth.User.settings_changeset(user, user_params)
|
|
|
|
case Repo.update(changes) do
|
|
{:ok, _user} ->
|
|
conn
|
|
|> put_flash(:success, "Account settings saved successfully")
|
|
|> redirect(to: Routes.auth_path(conn, :user_settings))
|
|
|
|
{:error, changeset} ->
|
|
email_changeset = Auth.User.settings_changeset(user)
|
|
|
|
render_settings(conn,
|
|
settings_changeset: changeset,
|
|
email_changeset: email_changeset
|
|
)
|
|
end
|
|
end
|
|
|
|
def update_email(conn, %{"user" => user_params}) do
|
|
user = conn.assigns.current_user
|
|
changes = Auth.User.email_changeset(user, user_params)
|
|
|
|
case Repo.update(changes) do
|
|
{:ok, user} ->
|
|
if user.email_verified do
|
|
handle_email_updated(conn)
|
|
else
|
|
Auth.EmailVerification.issue_code(user)
|
|
redirect(conn, to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
|
|
{:error, changeset} ->
|
|
settings_changeset = Auth.User.settings_changeset(user)
|
|
|
|
render_settings(conn,
|
|
settings_changeset: settings_changeset,
|
|
email_changeset: changeset
|
|
)
|
|
end
|
|
end
|
|
|
|
def cancel_update_email(conn, _params) do
|
|
changeset = Auth.User.cancel_email_changeset(conn.assigns.current_user)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, user} ->
|
|
conn
|
|
|> put_flash(:success, "Email changed back to #{user.email}")
|
|
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#change-email-address")
|
|
|
|
{:error, _} ->
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"Could not cancel email update because previous email has already been taken"
|
|
)
|
|
|> redirect(to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
end
|
|
|
|
defp handle_email_updated(conn) do
|
|
conn
|
|
|> put_flash(:success, "Email updated successfully")
|
|
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#change-email-address")
|
|
end
|
|
|
|
defp render_settings(conn, opts) do
|
|
settings_changeset = Keyword.fetch!(opts, :settings_changeset)
|
|
email_changeset = Keyword.fetch!(opts, :email_changeset)
|
|
|
|
user = Plausible.Users.with_subscription(conn.assigns[:current_user])
|
|
|
|
render(conn, "user_settings.html",
|
|
user: user |> Repo.preload(:api_keys),
|
|
settings_changeset: settings_changeset,
|
|
email_changeset: email_changeset,
|
|
subscription: user.subscription,
|
|
invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription),
|
|
theme: user.theme || "system",
|
|
team_member_limit: Quota.team_member_limit(user),
|
|
team_member_usage: Quota.team_member_usage(user),
|
|
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),
|
|
totp_enabled?: Auth.TOTP.enabled?(user)
|
|
)
|
|
end
|
|
|
|
def new_api_key(conn, _params) do
|
|
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{})
|
|
|
|
render(conn, "new_api_key.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
|
|
def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key}}) do
|
|
case Auth.create_api_key(conn.assigns.current_user, name, key) do
|
|
{:ok, _api_key} ->
|
|
conn
|
|
|> put_flash(:success, "API key created successfully")
|
|
|> redirect(to: "/settings#api-keys")
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "new_api_key.html",
|
|
changeset: changeset,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
|
)
|
|
end
|
|
end
|
|
|
|
def delete_api_key(conn, %{"id" => id}) do
|
|
case Auth.delete_api_key(conn.assigns.current_user, id) do
|
|
:ok ->
|
|
conn
|
|
|> put_flash(:success, "API key revoked successfully")
|
|
|> redirect(to: "/settings#api-keys")
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_flash(:error, "Could not find API Key to delete")
|
|
|> redirect(to: "/settings#api-keys")
|
|
end
|
|
end
|
|
|
|
def delete_me(conn, params) do
|
|
Plausible.Auth.delete_user(conn.assigns[:current_user])
|
|
|
|
logout(conn, params)
|
|
end
|
|
|
|
def logout(conn, params) do
|
|
redirect_to = Map.get(params, "redirect", "/")
|
|
|
|
conn
|
|
|> configure_session(drop: true)
|
|
|> delete_resp_cookie("logged_in")
|
|
|> redirect(to: redirect_to)
|
|
end
|
|
|
|
def google_auth_callback(conn, %{"error" => error, "state" => state} = params) do
|
|
[site_id, _redirect_to] = Jason.decode!(state)
|
|
site = Repo.get(Plausible.Site, site_id)
|
|
|
|
case error do
|
|
"access_denied" ->
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"We were unable to authenticate your Google Analytics account. Please check that you have granted us permission to 'See and download your Google Analytics data' and try again."
|
|
)
|
|
|> redirect(external: Routes.site_path(conn, :settings_general, site.domain))
|
|
|
|
message when message in ["server_error", "temporarily_unavailable"] ->
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"We are unable to authenticate your Google Analytics account because Google's authentication service is temporarily unavailable. Please try again in a few moments."
|
|
)
|
|
|> redirect(external: Routes.site_path(conn, :settings_general, site.domain))
|
|
|
|
_any ->
|
|
Sentry.capture_message("Google OAuth callback failed. Reason: #{inspect(params)}")
|
|
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"We were unable to authenticate your Google Analytics account. If the problem persists, please contact support for assistance."
|
|
)
|
|
|> redirect(external: Routes.site_path(conn, :settings_general, site.domain))
|
|
end
|
|
end
|
|
|
|
def google_auth_callback(conn, %{"code" => code, "state" => state}) do
|
|
res = Plausible.Google.HTTP.fetch_access_token(code)
|
|
[site_id, redirect_to] = Jason.decode!(state)
|
|
site = Repo.get(Plausible.Site, site_id)
|
|
expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), res["expires_in"])
|
|
|
|
case redirect_to do
|
|
"import" ->
|
|
redirect(conn,
|
|
external:
|
|
Routes.site_path(conn, :import_from_google_view_id_form, site.domain,
|
|
access_token: res["access_token"],
|
|
refresh_token: res["refresh_token"],
|
|
expires_at: NaiveDateTime.to_iso8601(expires_at)
|
|
)
|
|
)
|
|
|
|
_ ->
|
|
id_token = res["id_token"]
|
|
[_, body, _] = String.split(id_token, ".")
|
|
id = body |> Base.decode64!(padding: false) |> Jason.decode!()
|
|
|
|
Plausible.Site.GoogleAuth.changeset(%Plausible.Site.GoogleAuth{}, %{
|
|
email: id["email"],
|
|
refresh_token: res["refresh_token"],
|
|
access_token: res["access_token"],
|
|
expires: expires_at,
|
|
user_id: conn.assigns[:current_user].id,
|
|
site_id: site_id
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
site = Repo.get(Plausible.Site, site_id)
|
|
|
|
redirect(conn, external: "/#{URI.encode_www_form(site.domain)}/settings/integrations")
|
|
end
|
|
end
|
|
end
|