diff --git a/lib/sanbase/accounts/access_attempt.ex b/lib/sanbase/accounts/access_attempt.ex index b4a5329269..da7b3e2e51 100644 --- a/lib/sanbase/accounts/access_attempt.ex +++ b/lib/sanbase/accounts/access_attempt.ex @@ -13,13 +13,51 @@ defmodule Sanbase.Accounts.AccessAttempt do def check_attempt_limit(type, user, remote_ip) do config = get_config(type) - too_many_user_attempts? = attempts_count(type, user) > config.allowed_user_attempts - too_many_ip_attempts? = attempts_count(type, remote_ip) > config.allowed_ip_attempts - if too_many_user_attempts? or too_many_ip_attempts? do - {:error, :too_many_attempts} - else - :ok + # Check burst limits (short-term) + too_many_user_burst? = attempts_count(type, user, :burst) > config.allowed_user_burst_attempts + + too_many_ip_burst? = + attempts_count(type, remote_ip, :burst) > config.allowed_ip_burst_attempts + + # Check daily limits (long-term) + too_many_user_daily? = attempts_count(type, user, :daily) > config.allowed_user_daily_attempts + + too_many_ip_daily? = + attempts_count(type, remote_ip, :daily) > config.allowed_ip_daily_attempts + + cond do + too_many_user_burst? or too_many_ip_burst? -> + {:error, :too_many_burst_attempts} + + too_many_user_daily? or too_many_ip_daily? -> + {:error, :too_many_daily_attempts} + + true -> + :ok + end + end + + def check_ip_attempt_limit(type, remote_ip) do + config = get_config(type) + + # Check burst limits (short-term) + too_many_ip_burst? = + attempts_count(type, remote_ip, :burst) > config.allowed_ip_burst_attempts + + # Check daily limits (long-term) + too_many_ip_daily? = + attempts_count(type, remote_ip, :daily) > config.allowed_ip_daily_attempts + + cond do + too_many_ip_burst? -> + {:error, :too_many_burst_attempts} + + too_many_ip_daily? -> + {:error, :too_many_daily_attempts} + + true -> + :ok end end @@ -44,9 +82,10 @@ defmodule Sanbase.Accounts.AccessAttempt do |> foreign_key_constraint(:user_id) end - defp attempts_count(type, remote_ip) when is_binary(remote_ip) do + defp attempts_count(type, remote_ip, limit_type) when is_binary(remote_ip) do config = get_config(type) - interval_limit = Timex.shift(Timex.now(), minutes: -config.interval_in_minutes) + interval_minutes = get_interval_minutes(config, limit_type) + interval_limit = Timex.shift(Timex.now(), minutes: -interval_minutes) from(attempt in __MODULE__, where: @@ -57,9 +96,10 @@ defmodule Sanbase.Accounts.AccessAttempt do |> Repo.aggregate(:count, :id) end - defp attempts_count(type, %{id: user_id}) do + defp attempts_count(type, %{id: user_id}, limit_type) do config = get_config(type) - interval_limit = Timex.shift(Timex.now(), minutes: -config.interval_in_minutes) + interval_minutes = get_interval_minutes(config, limit_type) + interval_limit = Timex.shift(Timex.now(), minutes: -interval_minutes) from(attempt in __MODULE__, where: @@ -70,6 +110,9 @@ defmodule Sanbase.Accounts.AccessAttempt do |> Repo.aggregate(:count, :id) end + defp get_interval_minutes(config, :burst), do: config.burst_interval_in_minutes + defp get_interval_minutes(config, :daily), do: config.daily_interval_in_minutes + defp get_config(type) do case type do "email_login" -> Sanbase.Accounts.EmailLoginAttempt.config() diff --git a/lib/sanbase/accounts/access_attempt_behaviour.ex b/lib/sanbase/accounts/access_attempt_behaviour.ex index 5427f3a7ba..afe4400e56 100644 --- a/lib/sanbase/accounts/access_attempt_behaviour.ex +++ b/lib/sanbase/accounts/access_attempt_behaviour.ex @@ -8,7 +8,7 @@ defmodule Sanbase.Accounts.AccessAttemptBehaviour do @callback type() :: String.t() @callback check_attempt_limit(user :: term(), remote_ip :: String.t()) :: - :ok | {:error, :too_many_attempts} + :ok | {:error, atom()} @callback create(user :: term(), remote_ip :: String.t()) :: {:ok, term()} | {:error, term()} diff --git a/lib/sanbase/accounts/coupon_attempt.ex b/lib/sanbase/accounts/coupon_attempt.ex index 92e105f85d..a554d77eab 100644 --- a/lib/sanbase/accounts/coupon_attempt.ex +++ b/lib/sanbase/accounts/coupon_attempt.ex @@ -9,9 +9,16 @@ defmodule Sanbase.Accounts.CouponAttempt do @impl true def config do %{ - interval_in_minutes: 10, - allowed_user_attempts: 30, - allowed_ip_attempts: 60 + # Burst limits (short-term protection) + burst_interval_in_minutes: 5, + allowed_user_burst_attempts: 30, + allowed_ip_burst_attempts: 60, + + # Daily limits (long-term protection) + # 24 hours + daily_interval_in_minutes: 24 * 60, + allowed_user_daily_attempts: 200, + allowed_ip_daily_attempts: 500 } end @@ -20,6 +27,10 @@ defmodule Sanbase.Accounts.CouponAttempt do AccessAttempt.check_attempt_limit(type(), user, remote_ip) end + def check_ip_attempt_limit(remote_ip) do + AccessAttempt.check_ip_attempt_limit(type(), remote_ip) + end + @impl true def create(user, remote_ip) do AccessAttempt.create(type(), user, remote_ip) diff --git a/lib/sanbase/accounts/email_login_attempt.ex b/lib/sanbase/accounts/email_login_attempt.ex index 7798d371ed..67ac8dcbe8 100644 --- a/lib/sanbase/accounts/email_login_attempt.ex +++ b/lib/sanbase/accounts/email_login_attempt.ex @@ -8,9 +8,16 @@ defmodule Sanbase.Accounts.EmailLoginAttempt do @impl true def config do %{ - interval_in_minutes: 5, - allowed_user_attempts: 5, - allowed_ip_attempts: 20 + # Burst limits (short-term protection) + burst_interval_in_minutes: 5, + allowed_user_burst_attempts: 5, + allowed_ip_burst_attempts: 10, + + # Daily limits (long-term protection) + # 24 hours + daily_interval_in_minutes: 24 * 60, + allowed_user_daily_attempts: 20, + allowed_ip_daily_attempts: 100 } end @@ -19,6 +26,10 @@ defmodule Sanbase.Accounts.EmailLoginAttempt do AccessAttempt.check_attempt_limit(type(), user, remote_ip) end + def check_ip_attempt_limit(remote_ip) do + AccessAttempt.check_ip_attempt_limit(type(), remote_ip) + end + @impl true def create(user, remote_ip) do AccessAttempt.create(type(), user, remote_ip) diff --git a/lib/sanbase_web/graphql/resolvers/billing_resolver.ex b/lib/sanbase_web/graphql/resolvers/billing_resolver.ex index ce2e79c44e..f68514829d 100644 --- a/lib/sanbase_web/graphql/resolvers/billing_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/billing_resolver.ex @@ -197,9 +197,12 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do amount_off: amount_off }} else - {:error, :too_many_attempts} -> + {:error, :too_many_burst_attempts} -> {:error, "Too many coupon attempts. Please try again later."} + {:error, :too_many_daily_attempts} -> + {:error, "Too many coupon attempts. Please try again tomorrow"} + {:error, %Stripe.Error{message: message} = reason} -> log_error("Error checking coupon", reason) {:error, message} diff --git a/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex b/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex index 6b4f85043f..77e2a87f7d 100644 --- a/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex @@ -87,8 +87,11 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do with true <- allowed_email_domain?(email), true <- allowed_origin?(origin_host_parts, origin_url), + # Check IP limits before creating/finding user + :ok <- EmailLoginAttempt.check_ip_attempt_limit(remote_ip), {:ok, %{first_login: first_login} = user} <- User.find_or_insert_by(:email, email, %{username: args[:username]}), + # Check user-specific limits after user creation :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip), {:ok, user} <- User.Email.update_email_token(user, args[:consent]), {:ok, _res} <- User.Email.send_login_email(user, first_login, origin_host_parts, args), @@ -106,6 +109,20 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do {:error, message: message} + {:error, :too_many_burst_attempts} -> + Logger.info( + "Login failed: too many burst attempts. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" + ) + + {:error, message: "Too many login attempts, try again after a few minutes"} + + {:error, :too_many_daily_attempts} -> + Logger.info( + "Login failed: too many daily attempts. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" + ) + + {:error, message: "Too many login attempts, try again tomorrow"} + {:error, :too_many_attempts} -> Logger.info( "Login failed: too many login attempts. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" diff --git a/test/sanbase/accounts/access_attempt_test.exs b/test/sanbase/accounts/access_attempt_test.exs index 9b16d288c9..b434cf340f 100644 --- a/test/sanbase/accounts/access_attempt_test.exs +++ b/test/sanbase/accounts/access_attempt_test.exs @@ -13,36 +13,90 @@ defmodule Sanbase.Accounts.AccessAttemptTest do end describe "check_attempt_limit/3" do - test "allows attempts within user limits", %{user: user, ip: ip, type: type, config: config} do - for _i <- 1..(config.allowed_user_attempts + 1) do + test "allows attempts within user burst limits", %{ + user: user, + ip: ip, + type: type, + config: config + } do + for _i <- 1..(config.allowed_user_burst_attempts + 1) do {:ok, _} = AccessAttempt.create(type, user, ip) end - assert {:error, :too_many_attempts} = AccessAttempt.check_attempt_limit(type, user, ip) + assert {:error, :too_many_burst_attempts} = + AccessAttempt.check_attempt_limit(type, user, ip) end - test "allows attempts within IP limits", %{ip: ip, type: type, config: config} do - for _i <- 1..(config.allowed_ip_attempts + 1) do + test "allows attempts within IP burst limits", %{ip: ip, type: type, config: config} do + for _i <- 1..(config.allowed_ip_burst_attempts + 1) do user = insert(:user) {:ok, _} = AccessAttempt.create(type, user, ip) end new_user = insert(:user) - assert {:error, :too_many_attempts} = + assert {:error, :too_many_burst_attempts} = AccessAttempt.check_attempt_limit(type, new_user, ip) end - test "resets attempts count after interval", %{user: user, ip: ip, type: type, config: config} do - # Make maximum allowed attempts - for _i <- 1..(config.allowed_user_attempts + 1) do + test "allows attempts within user daily limits", %{ + user: user, + ip: ip, + type: type, + config: config + } do + # Create attempts just under daily limit but over burst limit + # Set them in the past to avoid burst limit interference + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_user_daily_attempts + 1) do + {:ok, attempt} = AccessAttempt.create(type, user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + assert {:error, :too_many_daily_attempts} = + AccessAttempt.check_attempt_limit(type, user, ip) + end + + test "allows attempts within IP daily limits", %{ip: ip, type: type, config: config} do + # Create attempts just under daily limit but over burst limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_ip_daily_attempts + 1) do + user = insert(:user) + {:ok, attempt} = AccessAttempt.create(type, user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + new_user = insert(:user) + + assert {:error, :too_many_daily_attempts} = + AccessAttempt.check_attempt_limit(type, new_user, ip) + end + + test "resets attempts count after burst interval", %{ + user: user, + ip: ip, + type: type, + config: config + } do + # Make maximum allowed burst attempts + for _i <- 1..(config.allowed_user_burst_attempts + 1) do {:ok, _} = AccessAttempt.create(type, user, ip) end - assert {:error, :too_many_attempts} = AccessAttempt.check_attempt_limit(type, user, ip) + assert {:error, :too_many_burst_attempts} = + AccessAttempt.check_attempt_limit(type, user, ip) - # Time travel past the interval by updating timestamps - past_time = DateTime.utc_now() |> DateTime.add(-(config.interval_in_minutes + 1), :minute) + # Time travel past the burst interval + past_time = + DateTime.utc_now() |> DateTime.add(-(config.burst_interval_in_minutes + 1), :minute) Repo.update_all( from(a in AccessAttempt, where: a.user_id == ^user.id), @@ -52,6 +106,66 @@ defmodule Sanbase.Accounts.AccessAttemptTest do # Should allow new attempts assert :ok = AccessAttempt.check_attempt_limit(type, user, ip) end + + test "resets attempts count after daily interval", %{ + user: user, + ip: ip, + type: type, + config: config + } do + # Create attempts at daily limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_user_daily_attempts + 1) do + {:ok, attempt} = AccessAttempt.create(type, user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + assert {:error, :too_many_daily_attempts} = + AccessAttempt.check_attempt_limit(type, user, ip) + + # Time travel past the daily interval + very_past_time = + DateTime.utc_now() |> DateTime.add(-(config.daily_interval_in_minutes + 1), :minute) + + Repo.update_all( + from(a in AccessAttempt, where: a.user_id == ^user.id), + set: [inserted_at: very_past_time] + ) + + # Should allow new attempts + assert :ok = AccessAttempt.check_attempt_limit(type, user, ip) + end + end + + describe "check_ip_attempt_limit/2" do + test "allows attempts within IP burst limits", %{ip: ip, type: type, config: config} do + for _i <- 1..(config.allowed_ip_burst_attempts + 1) do + user = insert(:user) + {:ok, _} = AccessAttempt.create(type, user, ip) + end + + assert {:error, :too_many_burst_attempts} = AccessAttempt.check_ip_attempt_limit(type, ip) + end + + test "allows attempts within IP daily limits", %{ip: ip, type: type, config: config} do + # Create attempts just under daily limit but over burst limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_ip_daily_attempts + 1) do + user = insert(:user) + {:ok, attempt} = AccessAttempt.create(type, user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + assert {:error, :too_many_daily_attempts} = AccessAttempt.check_ip_attempt_limit(type, ip) + end end describe "create/3" do @@ -71,11 +185,11 @@ defmodule Sanbase.Accounts.AccessAttemptTest do describe "different attempt types" do test "handles different rate limit configs", %{user: user, ip: ip, config: config} do - for _i <- 1..(config.allowed_user_attempts + 1) do + for _i <- 1..(config.allowed_user_burst_attempts + 1) do {:ok, _} = AccessAttempt.create("email_login", user, ip) end - assert {:error, :too_many_attempts} = + assert {:error, :too_many_burst_attempts} = AccessAttempt.check_attempt_limit("email_login", user, ip) # Coupon attempts should still work (different type) @@ -90,44 +204,82 @@ defmodule Sanbase.Accounts.AccessAttemptTest do end describe "coupon attempts" do - test "allows attempts within coupon-specific user limits", %{user: user, ip: ip} do + test "allows attempts within coupon-specific user burst limits", %{user: user, ip: ip} do config = Sanbase.Accounts.CouponAttempt.config() - for _i <- 1..(config.allowed_user_attempts + 1) do + for _i <- 1..(config.allowed_user_burst_attempts + 1) do {:ok, _} = AccessAttempt.create("coupon", user, ip) end - assert {:error, :too_many_attempts} = + assert {:error, :too_many_burst_attempts} = AccessAttempt.check_attempt_limit("coupon", user, ip) end - test "allows attempts within coupon-specific IP limits", %{ip: ip} do + test "allows attempts within coupon-specific IP burst limits", %{ip: ip} do config = Sanbase.Accounts.CouponAttempt.config() - for _i <- 1..(config.allowed_ip_attempts + 1) do + for _i <- 1..(config.allowed_ip_burst_attempts + 1) do user = insert(:user) {:ok, _} = AccessAttempt.create("coupon", user, ip) end new_user = insert(:user) - assert {:error, :too_many_attempts} = + assert {:error, :too_many_burst_attempts} = AccessAttempt.check_attempt_limit("coupon", new_user, ip) end - test "resets coupon attempts after interval", %{user: user, ip: ip} do + test "allows attempts within coupon-specific user daily limits", %{user: user, ip: ip} do + config = Sanbase.Accounts.CouponAttempt.config() + # Create attempts just under daily limit but over burst limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_user_daily_attempts + 1) do + {:ok, attempt} = AccessAttempt.create("coupon", user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + assert {:error, :too_many_daily_attempts} = + AccessAttempt.check_attempt_limit("coupon", user, ip) + end + + test "allows attempts within coupon-specific IP daily limits", %{ip: ip} do config = Sanbase.Accounts.CouponAttempt.config() + # Create attempts just under daily limit but over burst limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_ip_daily_attempts + 1) do + user = insert(:user) + {:ok, attempt} = AccessAttempt.create("coupon", user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + new_user = insert(:user) - # Make maximum allowed attempts - for _i <- 1..(config.allowed_user_attempts + 1) do + assert {:error, :too_many_daily_attempts} = + AccessAttempt.check_attempt_limit("coupon", new_user, ip) + end + + test "resets coupon attempts after burst interval", %{user: user, ip: ip} do + config = Sanbase.Accounts.CouponAttempt.config() + + # Make maximum allowed burst attempts + for _i <- 1..(config.allowed_user_burst_attempts + 1) do {:ok, _} = AccessAttempt.create("coupon", user, ip) end - assert {:error, :too_many_attempts} = + assert {:error, :too_many_burst_attempts} = AccessAttempt.check_attempt_limit("coupon", user, ip) - # Time travel past the interval - past_time = DateTime.utc_now() |> DateTime.add(-(config.interval_in_minutes + 1), :minute) + # Time travel past the burst interval + past_time = + DateTime.utc_now() |> DateTime.add(-(config.burst_interval_in_minutes + 1), :minute) Repo.update_all( from(a in AccessAttempt, where: a.user_id == ^user.id), @@ -137,5 +289,34 @@ defmodule Sanbase.Accounts.AccessAttemptTest do # Should allow new attempts assert :ok = AccessAttempt.check_attempt_limit("coupon", user, ip) end + + test "resets coupon attempts after daily interval", %{user: user, ip: ip} do + config = Sanbase.Accounts.CouponAttempt.config() + # Create attempts at daily limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for _i <- 1..(config.allowed_user_daily_attempts + 1) do + {:ok, attempt} = AccessAttempt.create("coupon", user, ip) + + Repo.update_all(from(a in AccessAttempt, where: a.id == ^attempt.id), + set: [inserted_at: past_time] + ) + end + + assert {:error, :too_many_daily_attempts} = + AccessAttempt.check_attempt_limit("coupon", user, ip) + + # Time travel past the daily interval + very_past_time = + DateTime.utc_now() |> DateTime.add(-(config.daily_interval_in_minutes + 1), :minute) + + Repo.update_all( + from(a in AccessAttempt, where: a.user_id == ^user.id), + set: [inserted_at: very_past_time] + ) + + # Should allow new attempts + assert :ok = AccessAttempt.check_attempt_limit("coupon", user, ip) + end end end diff --git a/test/sanbase_web/graphql/auth/email_login_api_test.exs b/test/sanbase_web/graphql/auth/email_login_api_test.exs index a36e9fae7f..cb6ac2453f 100644 --- a/test/sanbase_web/graphql/auth/email_login_api_test.exs +++ b/test/sanbase_web/graphql/auth/email_login_api_test.exs @@ -5,6 +5,7 @@ defmodule SanbaseWeb.Graphql.EmailLoginApiTest do import Mox import SanbaseWeb.Graphql.TestHelpers import Sanbase.Factory + import Ecto.Query alias Sanbase.Accounts.User alias Sanbase.Repo @@ -314,24 +315,21 @@ defmodule SanbaseWeb.Graphql.EmailLoginApiTest do assert msg =~ "Invalid success_redirect_url: https://example.com/success" end - test "succeeds when different users login from the same ip not more than 20 times", context do + test "succeeds when different users login from the same ip within burst limits", context do + config = Sanbase.Accounts.EmailLoginAttempt.config() user1 = insert(:user, email: "john@example.com") user2 = insert(:user, email: "jane@example.com") - user3 = insert(:user, email: "jake@example.com") - user4 = insert(:user, email: "joel@example.com") - for _ <- 1..5, + # Create attempts just under the IP burst limit + attempts_per_user = div(config.allowed_ip_burst_attempts - 1, 2) + + for _ <- 1..attempts_per_user, do: insert(:email_login_attempt, user: user1, ip_address: "127.0.0.1") - for _ <- 1..5, + for _ <- 1..attempts_per_user, do: insert(:email_login_attempt, user: user2, ip_address: "127.0.0.1") - for _ <- 1..5, - do: insert(:email_login_attempt, user: user3, ip_address: "127.0.0.1") - - for _ <- 1..5, - do: insert(:email_login_attempt, user: user4, ip_address: "127.0.0.1") - + # Total attempts still under the burst limit result = context.conn |> Plug.Conn.put_req_header("origin", "https://app.santiment.net") @@ -341,11 +339,13 @@ defmodule SanbaseWeb.Graphql.EmailLoginApiTest do assert result["success"] end - test "fails if the user has attempted to login more than 5 times having the same email", + test "fails if the user has attempted to login more than user burst limit having the same email", context do + config = Sanbase.Accounts.EmailLoginAttempt.config() user = insert(:user, email: "john@example.com") - for _ <- 1..6, + # Exceed user burst limit + for _ <- 1..(config.allowed_user_burst_attempts + 1), # As the login attemt in this test is made on localhost, the below ip should not match do: insert(:email_login_attempt, user: user, ip_address: "157.7.7.7") @@ -360,15 +360,19 @@ defmodule SanbaseWeb.Graphql.EmailLoginApiTest do assert msg =~ "Too many login attempts" end - test "fails if the user has attempted to login more than 20 having the same ip", + test "fails if the user has attempted to login more than IP burst limit having the same ip", context do + config = Sanbase.Accounts.EmailLoginAttempt.config() user1 = insert(:user, email: "john@example.com") user2 = insert(:user, email: "jane@example.com") - for _ <- 1..11, + # Exceed IP burst limit by creating one more attempt than allowed + attempts_per_user = div(config.allowed_ip_burst_attempts + 1, 2) + + for _ <- 1..attempts_per_user, do: insert(:email_login_attempt, user: user1, ip_address: "127.0.0.1") - for _ <- 1..11, + for _ <- 1..(config.allowed_ip_burst_attempts + 1 - attempts_per_user), do: insert(:email_login_attempt, user: user2, ip_address: "127.0.0.1") msg = @@ -381,6 +385,73 @@ defmodule SanbaseWeb.Graphql.EmailLoginApiTest do assert msg =~ "Too many login attempts" end + + test "fails if IP exceeds burst limit before user creation (prevents user pollution)", + context do + config = Sanbase.Accounts.EmailLoginAttempt.config() + test_ip = "127.0.0.1" + + for i <- 1..(config.allowed_ip_burst_attempts + 1) do + existing_user = insert(:user, email: "user#{i}@example.com") + {:ok, _} = Sanbase.Accounts.AccessAttempt.create("email_login", existing_user, test_ip) + end + + # Verify we've exceeded the IP burst limit + ip_check_result = Sanbase.Accounts.EmailLoginAttempt.check_ip_attempt_limit(test_ip) + assert ip_check_result == {:error, :too_many_burst_attempts} + + # Generate a unique email to avoid conflicts with other tests + unique_email = "newuser#{System.unique_integer([:positive])}@example.com" + + # Now try to send email login for a completely new email - this should be blocked by IP rate limiting + response = + context.conn + |> Plug.Conn.put_req_header("origin", "https://app.santiment.net") + |> email_login(%{email: unique_email}) + + assert response["errors"] != nil + + assert Enum.any?(response["errors"], fn error -> + String.contains?(error["message"], "Too many login attempts") + end) + + # Verify the user doesn't exist in the database + assert {:error, _} = Sanbase.Accounts.User.by_email(unique_email) + end + + test "fails if IP exceeds daily limit before user creation", context do + config = Sanbase.Accounts.EmailLoginAttempt.config() + test_ip = "127.0.0.1" + + # Create attempts from past (to avoid burst limit interference) that exceed daily limit + past_time = DateTime.utc_now() |> DateTime.add(-10, :minute) + + for i <- 1..(config.allowed_ip_daily_attempts + 1) do + existing_user = insert(:user, email: "dailyuser#{i}@example.com") + + {:ok, attempt} = + Sanbase.Accounts.AccessAttempt.create("email_login", existing_user, test_ip) + + from(a in Sanbase.Accounts.AccessAttempt, where: a.id == ^attempt.id) + |> Sanbase.Repo.update_all(set: [inserted_at: past_time]) + end + + user_count_before = Sanbase.Repo.aggregate(Sanbase.Accounts.User, :count, :id) + + response = + context.conn + |> Plug.Conn.put_req_header("origin", "https://app.santiment.net") + |> Plug.Conn.put_req_header("x-forwarded-for", test_ip) + |> email_login(%{email: "newdailyuser@example.com"}) + + msg = response["errors"] |> hd() |> Map.get("message") + + user_count_after = Sanbase.Repo.aggregate(Sanbase.Accounts.User, :count, :id) + + assert msg != nil and msg =~ "Too many login attempts" + assert user_count_before == user_count_after + assert {:error, _} = Sanbase.Accounts.User.by_email("newdailyuser@example.com") + end end defp email_login_verify_mutation(user) do diff --git a/test/sanbase_web/graphql/billing/subscribe_api_test.exs b/test/sanbase_web/graphql/billing/subscribe_api_test.exs index c7c18fd49b..e9526b85ef 100644 --- a/test/sanbase_web/graphql/billing/subscribe_api_test.exs +++ b/test/sanbase_web/graphql/billing/subscribe_api_test.exs @@ -94,7 +94,7 @@ defmodule SanbaseWeb.Graphql.Billing.SubscribeApiTest do with_mocks([ {Sanbase.Accounts.CouponAttempt, [], [ - check_attempt_limit: fn _, _ -> {:error, :too_many_attempts} end + check_attempt_limit: fn _, _ -> {:error, :too_many_burst_attempts} end ]} ]) do query = check_coupon(@coupon_code)