analytics/lib/plausible_web/controllers/auth_controller.ex
hq1 e3af1a317d
Onboarding improvements (#4459)
* Migration: add installation meta

* Update site schema with installation meta

* Remove VERIFICATION_ENABLED env var

* Add context API to create/remove special goals

* Add context api to update installation meta

* Remove verification enabled check

* Update new progress flow definitions

* Update generic components

* Remove internal /status API

* Implement installation live view

* Update traffic change notifier link

* Update verification, no more modal

* Update routes

* Remove focus.html - will unify everything under app layout

* Fix broken link

* Update templates with focus_box mostly

* Update controller tests

* Update controllers and stop using the focus layout

* copy changes

* Update verification.ex

* Remove dead template

* Update settings_general.html.heex

* Update copy in tests

* Update installation.ex

* Remove dangling dot

* Fix link

* Update installation.ex

* Update installation.ex

* Better tooltips?

* Simpler labels

* Revert "Simpler labels"

This reverts commit 797560ef82f2067458b03b884be5aecc8fdc72bc.

* Add copy to clipboard link and fix snippet's dark mode

* Offer installation detection skip only if ws connected

* Put COPY link at the bottom with background

* Make tooltips link to docs

* Fix cherry-pick gone wrong

* Hide tooltips on mobile screens

* WIP: 404 tracking wizard

* Revert "WIP: 404 tracking wizard"

This reverts commit a9c9c79bbd.

* Update lib/plausible_web/live/components/verification.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Update lib/plausible_web/live/installation.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Use current_user from socket.assigns

* Update lib/plausible_web/live/installation.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Use current_user from socket.assigns

* Use conn.private to steer verification tests

* Drop non-sticky tooltip in favour of component parametrization

Co-authored-by: Artur Pata <artur.pata@gmail.com>

* Reapply "WIP: 404 tracking wizard"

This reverts commit 3ba81671d7.

* Fix installation tests including 404 tracking

* Fixup the tooltip component

* Format

* Update installation.ex

* Put flash whenever installation option changes

* Use last known installation type on domain change

* Extract user flow definition to provide compile-time checks

* See if this helps running CE migrations successfully

* Use `styled_link` on registration/login views

* Don't crash when there's no conn.private carried over

* Format

* Push "Determining installation type" message a bit lower

* Use links and footer lists uniformly

This commit introduces a `<.focus_list/>` component
for rendering focus box footer links with colored
discs. It also equips generic link components
with the ability of sending non-GET requests
along with CSRF token, so we can apply uniform
styling and stop using legacy Phoenix link tags.

cc @zoldar @apata

* ws 👾

* Render more descriptive flashes on script config change

---------

Co-authored-by: Marko Saric <34340819+metmarkosaric@users.noreply.github.com>
Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
Co-authored-by: Artur Pata <artur.pata@gmail.com>
2024-09-02 12:49:54 +02:00

686 lines
21 KiB
Elixir

defmodule PlausibleWeb.AuthController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Auth
alias Plausible.Billing.Quota
alias PlausibleWeb.TwoFactor
alias PlausibleWeb.UserAuth
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
]
)
# Plug purging 2FA user session cookie outsite 2FA flow
defp clear_2fa_user(conn, _opts) do
TwoFactor.Session.clear_2fa_user(conn)
end
def activate_form(conn, params) do
user = conn.assigns.current_user
flow = params["flow"] || PlausibleWeb.Flows.register()
render(conn, "activate.html",
has_email_code?: Plausible.Users.has_email_code?(user),
has_any_memberships?: Plausible.Site.Memberships.any?(user),
form_submit_url: "/activate?flow=#{flow}"
)
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)
flow = conn.params["flow"]
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, flow: flow))
true ->
redirect(conn, to: Routes.site_path(conn, :new, flow: flow))
end
{:error, :incorrect} ->
render(conn, "activate.html",
error: "Incorrect activation code",
has_email_code?: true,
has_any_memberships?: has_any_memberships?,
form_submit_url: "/activate?flow=#{flow}"
)
{:error, :expired} ->
render(conn, "activate.html",
error: "Code is expired, please request another one",
has_email_code?: false,
has_any_memberships?: has_any_memberships?,
form_submit_url: "/activate?flow=#{flow}"
)
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")
end
def password_reset_request(conn, %{"email" => ""}) do
render(conn, "password_reset_request_form.html", error: "Please enter an email address")
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)
else
render(conn, "password_reset_request_success.html", email: email)
end
else
render(conn, "password_reset_request_form.html",
error: "Please complete the captcha to reset your password"
)
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
)
{: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
|> UserAuth.log_out_user()
|> put_flash(:login_title, "Password updated successfully")
|> put_flash(:login_instructions, "Please log in with your new credentials")
|> redirect(to: Routes.auth_path(conn, :login_form))
end
def login_form(conn, _params) do
render(conn, "login_form.html")
end
def login(conn, %{"user" => params}) do
login(conn, params)
end
def login(conn, %{"email" => email, "password" => password} = params) do
with :ok <- Auth.rate_limit(:login_ip, conn),
{:ok, user} <- Auth.get_user_by(email: email),
:ok <- Auth.rate_limit(:login_user, user),
:ok <- Auth.check_password(user, password),
:ok <- check_2fa_verified(conn, user) do
redirect_path =
cond do
not is_nil(params["register_action"]) and not user.email_verified ->
Auth.EmailVerification.issue_code(user)
flow =
if params["register_action"] == "register_form" do
PlausibleWeb.Flows.register()
else
PlausibleWeb.Flows.invitation()
end
Routes.auth_path(conn, :activate_form, flow: flow)
params["register_action"] == "register_from_invitation_form" ->
Routes.site_path(conn, :index)
params["register_action"] == "register_form" ->
Routes.site_path(conn, :new)
true ->
nil
end
UserAuth.log_in_user(conn, user, redirect_path)
else
{:error, :wrong_password} ->
maybe_log_failed_login_attempts("wrong password for #{email}")
render(conn, "login_form.html", error: "Wrong email or password. Please try again.")
{:error, :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.")
{:error, {:rate_limit, _}} ->
maybe_log_failed_login_attempts("too many login attempts for #{email}")
render_error(
conn,
429,
"Too many login attempts. Wait a minute before trying again."
)
{:error, {:unverified_2fa, user}} ->
conn
|> TwoFactor.Session.set_2fa_user(user)
|> redirect(to: Routes.auth_path(conn, :verify_2fa))
end
end
defp check_2fa_verified(conn, user) do
if Auth.TOTP.enabled?(user) and not TwoFactor.Session.remember_2fa?(conn, user) do
{:error, {:unverified_2fa, user}}
else
:ok
end
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()
)
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"])
|> UserAuth.log_in_user(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()
)
{:error, :not_enabled} ->
UserAuth.log_in_user(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")
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 ->
UserAuth.log_in_user(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")
{:error, :not_enabled} ->
UserAuth.log_in_user(conn, user)
end
end
end
defp get_2fa_user_limited(conn) do
case TwoFactor.Session.get_2fa_user(conn) do
{:ok, user} ->
with :ok <- Auth.rate_limit(:login_ip, conn),
:ok <- Auth.rate_limit(:login_user, user) do
{:ok, user}
else
{:error, {:rate_limit, _}} ->
maybe_log_failed_login_attempts("too many login 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
with :ok <- Auth.rate_limit(:email_change_user, user),
changes = Auth.User.email_changeset(user, user_params),
{:ok, user} <- Repo.update(changes) do
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
else
{:error, %Ecto.Changeset{} = changeset} ->
settings_changeset = Auth.User.settings_changeset(user)
render_settings(conn,
settings_changeset: settings_changeset,
email_changeset: changeset
)
{:error, {:rate_limit, _}} ->
settings_changeset = Auth.User.settings_changeset(user)
{:error, changeset} =
user
|> Auth.User.email_changeset(user_params)
|> Ecto.Changeset.add_error(:email, "too many requests, try again in an hour")
|> Ecto.Changeset.apply_action(:validate)
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
current_user = conn.assigns.current_user
settings_changeset = Keyword.fetch!(opts, :settings_changeset)
email_changeset = Keyword.fetch!(opts, :email_changeset)
api_keys = Repo.preload(current_user, :api_keys).api_keys
render(conn, "user_settings.html",
api_keys: api_keys,
settings_changeset: settings_changeset,
email_changeset: email_changeset,
subscription: current_user.subscription,
invoices: Plausible.Billing.paddle_api().get_invoices(current_user.subscription),
theme: current_user.theme || "system",
team_member_limit: Quota.Limits.team_member_limit(current_user),
team_member_usage: Quota.Usage.team_member_usage(current_user),
site_limit: Quota.Limits.site_limit(current_user),
site_usage: Quota.Usage.site_usage(current_user),
pageview_limit: Quota.Limits.monthly_pageview_limit(current_user),
pageview_usage: Quota.Usage.monthly_pageview_usage(current_user),
totp_enabled?: Auth.TOTP.enabled?(current_user)
)
end
def new_api_key(conn, _params) do
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{})
render(conn, "new_api_key.html", changeset: changeset)
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)
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
|> UserAuth.log_out_user()
|> redirect(to: redirect_to)
end
def google_auth_callback(conn, %{"error" => error, "state" => state} = params) do
[site_id, redirected_to | _] = Jason.decode!(state)
site = Repo.get(Plausible.Site, site_id)
redirect_route =
if redirected_to == "import" do
Routes.site_path(conn, :settings_imports_exports, site.domain)
else
Routes.site_path(conn, :settings_integrations, site.domain)
end
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: redirect_route)
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: redirect_route)
_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: redirect_route)
end
end
def google_auth_callback(conn, %{"code" => code, "state" => state}) do
res = Plausible.Google.API.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.google_analytics_path(conn, :property_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
defp redirect_to_login(conn) do
redirect(conn, to: Routes.auth_path(conn, :login_form))
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
end