diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 91606dcde5da..49587b483d67 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -15,12 +15,33 @@ defmodule Plausible.Auth do require Logger - if Mix.env() == :e2e_test do - @ip_rate_limit 100_000 - @user_rate_limit 100_000 - else - @ip_rate_limit 5 - @user_rate_limit 5 + case Mix.env() do + :e2e_test -> + @ip_rate_limit 100_000 + @user_rate_limit 100_000 + @activation_limit 100_000 + @activation_ip_limit 100_000 + @activation_request_limit 100_000 + @totp_setup_limit 100_000 + @totp_setup_ip_limit 100_000 + + env when env in [:test, :ce_test] -> + @ip_rate_limit 5 + @user_rate_limit 5 + @activation_limit 10 + @totp_setup_limit 10 + @activation_ip_limit 100_000 + @totp_setup_ip_limit 100_000 + @activation_request_limit 100_000 + + _ -> + @ip_rate_limit 5 + @user_rate_limit 5 + @activation_limit 10 + @totp_setup_limit 10 + @activation_ip_limit 2 + @totp_setup_ip_limit 2 + @activation_request_limit 5 end @rate_limits %{ @@ -43,6 +64,36 @@ defmodule Plausible.Auth do prefix: "password-change:user", limit: 5, interval: :timer.minutes(20) + }, + activation_ip: %{ + prefix: "activation:ip", + limit: @activation_ip_limit, + interval: :timer.minutes(1) + }, + activation_user: %{ + prefix: "activation:user", + limit: @activation_limit, + interval: :timer.minutes(5) + }, + activation_request_ip: %{ + prefix: "activation-request:ip", + limit: @activation_request_limit, + interval: :timer.minutes(1) + }, + activation_request_user: %{ + prefix: "activation-request:user", + limit: @activation_request_limit, + interval: :timer.minutes(10) + }, + totp_setup_ip: %{ + prefix: "totp-setup:ip", + limit: @totp_setup_ip_limit, + interval: :timer.minutes(1) + }, + totp_setup_user: %{ + prefix: "totp-setup:user", + limit: @totp_setup_limit, + interval: :timer.minutes(5) } } diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 9fbd6237677d..5dc6c251343d 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -123,6 +123,20 @@ defmodule PlausibleWeb.AuthController do def activate(conn, %{"code" => code}) do user = conn.assigns[:current_user] + with :ok <- Auth.rate_limit(:activation_ip, conn), + :ok <- Auth.rate_limit(:activation_user, user) do + do_activate(conn, user, code) + else + {:error, {:rate_limit, _}} -> + render_error( + conn, + 429, + "Too many activation attempts. Wait a few minutes before trying again." + ) + end + end + + defp do_activate(conn, user, code) do has_any_invitations? = Plausible.Teams.Users.has_sites?(user, include_pending?: true) has_any_memberships? = Plausible.Teams.Users.has_sites?(user, include_pending?: false) @@ -167,11 +181,20 @@ defmodule PlausibleWeb.AuthController do 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)) + with :ok <- Auth.rate_limit(:activation_request_ip, conn), + :ok <- Auth.rate_limit(:activation_request_user, user) do + Auth.EmailVerification.issue_code(user) + + conn + |> put_flash(:success, "Activation code was sent to #{user.email}") + |> redirect(to: Routes.auth_path(conn, :activate_form)) + else + {:error, {:rate_limit, _}} -> + conn + |> put_flash(:error, "Too many code requests. Please wait before requesting another.") + |> redirect(to: Routes.auth_path(conn, :activate_form)) + end end def password_reset_request_form(conn, _) do @@ -396,11 +419,21 @@ defmodule PlausibleWeb.AuthController do 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) + user = conn.assigns.current_user + + with :ok <- Auth.rate_limit(:totp_setup_ip, conn), + :ok <- Auth.rate_limit(:totp_setup_user, user), + {:ok, _, %{recovery_codes: codes}} <- Auth.TOTP.enable(user, code) do + conn + |> put_flash(:success, "Two-Factor Authentication is fully enabled") + |> render("generate_2fa_recovery_codes.html", recovery_codes: codes, from_setup: true) + else + {:error, {:rate_limit, _}} -> + render_error( + conn, + 429, + "Too many attempts. Wait a minute before trying again." + ) {:error, :invalid_code} -> conn diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs index d3f76c99ad8f..501183e41ce3 100644 --- a/test/plausible_web/controllers/auth_controller_test.exs +++ b/test/plausible_web/controllers/auth_controller_test.exs @@ -467,6 +467,26 @@ defmodule PlausibleWeb.AuthControllerTest do refute Repo.get_by(Auth.EmailActivationCode, user_id: user.id) end + + test "limits activation attempts to 10 per 5 minutes", %{conn: conn} do + conn = put_req_header(conn, "x-forwarded-for", "10.9.8.7") + + response = + eventually( + fn -> + Enum.each(1..10, fn _ -> + post(conn, "/activate", %{code: "1111"}) + end) + + conn = post(conn, "/activate", %{code: "1111"}) + + {conn.status == 429, conn} + end, + 500 + ) + + assert html_response(response, 429) =~ "Too many activation attempts" + end end describe "GET /login_form" do