Skip to content

Commit

Permalink
Merge pull request #4532 from santiment/generalize-access-attempts
Browse files Browse the repository at this point in the history
Generalize attempts to use resources
  • Loading branch information
tspenov authored Jan 28, 2025
2 parents c2ca93b + 5024638 commit 02afec1
Show file tree
Hide file tree
Showing 12 changed files with 415 additions and 75 deletions.
80 changes: 80 additions & 0 deletions lib/sanbase/accounts/access_attempt.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule Sanbase.Accounts.AccessAttempt do
use Ecto.Schema
import Ecto.{Query, Changeset}
alias Sanbase.Repo

schema "access_attempts" do
belongs_to(:user, Sanbase.Accounts.User)
field(:ip_address, :string)
field(:type, :string)

timestamps()
end

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
end
end

def create(type, user, remote_ip) do
%__MODULE__{}
|> changeset(%{
user_id: user && user.id,
ip_address: remote_ip,
type: type
})
|> Repo.insert()
|> case do
{:error, changeset} -> {:error, changeset}
attempt -> attempt
end
end

def changeset(%__MODULE__{} = attempt, attrs \\ %{}) do
attempt
|> cast(attrs, [:user_id, :ip_address, :type])
|> validate_required([:ip_address, :type])
|> foreign_key_constraint(:user_id)
end

defp attempts_count(type, remote_ip) when is_binary(remote_ip) do
config = get_config(type)
interval_limit = Timex.shift(Timex.now(), minutes: -config.interval_in_minutes)

from(attempt in __MODULE__,
where:
attempt.type == ^type and
attempt.ip_address == ^remote_ip and
attempt.inserted_at > ^interval_limit
)
|> Repo.aggregate(:count, :id)
end

defp attempts_count(type, %{id: user_id}) do
config = get_config(type)
interval_limit = Timex.shift(Timex.now(), minutes: -config.interval_in_minutes)

from(attempt in __MODULE__,
where:
attempt.type == ^type and
attempt.user_id == ^user_id and
attempt.inserted_at > ^interval_limit
)
|> Repo.aggregate(:count, :id)
end

defp get_config(type) do
case type do
"email_login" -> Sanbase.Accounts.EmailLoginAttempt.config()
"coupon" -> Sanbase.Accounts.CouponAttempt.config()
_ -> raise "Unknown access attempt type: #{type}"
end
end
end
15 changes: 15 additions & 0 deletions lib/sanbase/accounts/access_attempt_behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Sanbase.Accounts.AccessAttemptBehaviour do
@callback config() :: %{
interval_in_minutes: pos_integer(),
allowed_user_attempts: pos_integer(),
allowed_ip_attempts: pos_integer()
}

@callback type() :: String.t()

@callback check_attempt_limit(user :: term(), remote_ip :: String.t()) ::
:ok | {:error, :too_many_attempts}

@callback create(user :: term(), remote_ip :: String.t()) ::
{:ok, term()} | {:error, term()}
end
26 changes: 26 additions & 0 deletions lib/sanbase/accounts/coupon_attempt.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Sanbase.Accounts.CouponAttempt do
@behaviour Sanbase.Accounts.AccessAttemptBehaviour
alias Sanbase.Accounts.AccessAttempt

@impl true
def type, do: "coupon"

@impl true
def config do
%{
interval_in_minutes: 10,
allowed_user_attempts: 5,
allowed_ip_attempts: 20
}
end

@impl true
def check_attempt_limit(user, remote_ip) do
AccessAttempt.check_attempt_limit(type(), user, remote_ip)
end

@impl true
def create(user, remote_ip) do
AccessAttempt.create(type(), user, remote_ip)
end
end
77 changes: 19 additions & 58 deletions lib/sanbase/accounts/email_login_attempt.ex
Original file line number Diff line number Diff line change
@@ -1,65 +1,26 @@
defmodule Sanbase.Accounts.EmailLoginAttempt do
use Ecto.Schema

import Ecto.{Query, Changeset}

alias Sanbase.Repo

@interval_in_minutes 5
@allowed_login_attempts 5
@allowed_ip_attempts 20

schema "email_login_attempts" do
belongs_to(:user, Sanbase.Accounts.User)
field(:ip_address, :string)

timestamps()
end

def has_allowed_login_attempts(user, remote_ip) do
too_many_login_attempts? = login_attempts_count(user) > @allowed_login_attempts
too_many_ip_attempts? = login_attempts_count(remote_ip) > @allowed_ip_attempts

if too_many_login_attempts? or too_many_ip_attempts? do
{:error, :too_many_login_attempts}
else
:ok
end
end

def create(%{id: user_id}, remote_ip) do
%__MODULE__{}
|> changeset(%{user_id: user_id, ip_address: remote_ip})
|> Repo.insert()
|> case do
{:error, _} -> {:error, :too_many_login_attempts}
attempt -> attempt
end
@behaviour Sanbase.Accounts.AccessAttemptBehaviour
alias Sanbase.Accounts.AccessAttempt

@impl true
def type, do: "email_login"

@impl true
def config do
%{
interval_in_minutes: 5,
allowed_user_attempts: 5,
allowed_ip_attempts: 20
}
end

def changeset(%__MODULE__{} = attempt, attrs \\ %{}) do
attempt
|> cast(attrs, [:user_id, :ip_address])
|> validate_required([:user_id, :ip_address])
|> foreign_key_constraint(:user_id)
@impl true
def check_attempt_limit(user, remote_ip) do
AccessAttempt.check_attempt_limit(type(), user, remote_ip)
end

# Private
defp login_attempts_count(remote_ip) when is_binary(remote_ip) do
interval_limit = Timex.shift(Timex.now(), minutes: -@interval_in_minutes)

from(attempt in __MODULE__,
where: attempt.ip_address == ^remote_ip and attempt.inserted_at > ^interval_limit
)
|> Repo.aggregate(:count, :id)
end

defp login_attempts_count(%{id: user_id}) do
interval_limit = Timex.shift(Timex.now(), minutes: -@interval_in_minutes)

from(attempt in __MODULE__,
where: attempt.user_id == ^user_id and attempt.inserted_at > ^interval_limit
)
|> Repo.aggregate(:count, :id)
@impl true
def create(user, remote_ip) do
AccessAttempt.create(type(), user, remote_ip)
end
end
2 changes: 1 addition & 1 deletion lib/sanbase/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ defmodule Sanbase.Accounts.User do
has_many(:triggers, Sanbase.Alert.UserTrigger, on_delete: :delete_all)
has_many(:chart_configurations, Sanbase.Chart.Configuration, on_delete: :delete_all)
has_many(:user_events, Sanbase.Intercom.UserEvent, on_delete: :delete_all)
has_many(:email_login_attempts, Sanbase.Accounts.EmailLoginAttempt, on_delete: :delete_all)
has_many(:access_attempts, Sanbase.Accounts.AccessAttempt, on_delete: :delete_all)
has_many(:short_urls, Sanbase.ShortUrl, on_delete: :delete_all)

timestamps()
Expand Down
29 changes: 22 additions & 7 deletions lib/sanbase_web/graphql/resolvers/billing_resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,33 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do
end
end

def get_coupon(_root, %{coupon: coupon}, _resolution) do
case Sanbase.StripeApi.retrieve_coupon(coupon) do
def get_coupon(_root, %{coupon: coupon}, %{
context: %{remote_ip: remote_ip, auth: %{current_user: current_user}}
}) do
remote_ip = Sanbase.Utils.IP.ip_tuple_to_string(remote_ip)

with :ok <- Sanbase.Accounts.CouponAttempt.check_attempt_limit(current_user, remote_ip),
{:ok,
%Stripe.Coupon{
valid: valid,
id: id,
name: name,
percent_off: percent_off,
amount_off: amount_off
}} <- Sanbase.StripeApi.retrieve_coupon(coupon) do
Sanbase.Accounts.CouponAttempt.create(current_user, remote_ip)

{:ok,
%Stripe.Coupon{
valid: valid,
%{
is_valid: valid,
id: id,
name: name,
percent_off: percent_off,
amount_off: amount_off
}} ->
{:ok,
%{is_valid: valid, id: id, name: name, percent_off: percent_off, amount_off: amount_off}}
}}
else
{:error, :too_many_attempts} ->
{:error, "Too many coupon attempts. Please try again later."}

{:error, %Stripe.Error{message: message} = reason} ->
log_error("Error checking coupon", reason)
Expand Down
12 changes: 6 additions & 6 deletions lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do

alias Sanbase.InternalServices.Ethauth
alias Sanbase.Accounts
alias Sanbase.Accounts.{User, EthAccount, EmailLoginAttempt}
alias Sanbase.Accounts.{User, EthAccount, EmailLoginAttempt, AccessAttempt}

require Logger

Expand Down Expand Up @@ -89,10 +89,10 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do
true <- allowed_origin?(origin_host_parts, origin_url),
{:ok, %{first_login: first_login} = user} <-
User.find_or_insert_by(:email, email, %{username: args[:username]}),
:ok <- EmailLoginAttempt.has_allowed_login_attempts(user, remote_ip),
: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),
{:ok, %EmailLoginAttempt{}} <- EmailLoginAttempt.create(user, remote_ip),
{:ok, %AccessAttempt{}} <- AccessAttempt.create("email_login", user, remote_ip),
{:ok, _, user} <-
Accounts.forward_registration(user, "send_login_email", %{"origin_url" => origin_url}) do
emit_event({:ok, user}, :send_email_login_link, %{origin_url: origin_url})
Expand All @@ -106,7 +106,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do

{:error, message: message}

{:error, :too_many_login_attempts} ->
{:error, :too_many_attempts} ->
Logger.info(
"Login failed: too many login attempts. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}"
)
Expand Down Expand Up @@ -154,10 +154,10 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do
}) do
remote_ip = Sanbase.Utils.IP.ip_tuple_to_string(remote_ip)

with :ok <- EmailLoginAttempt.has_allowed_login_attempts(user, remote_ip),
with :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip),
{:ok, user} <- User.Email.update_email_candidate(user, email_candidate),
{:ok, _user} <- User.Email.send_verify_email(user),
{:ok, %EmailLoginAttempt{}} <- EmailLoginAttempt.create(user, remote_ip) do
{:ok, %AccessAttempt{}} <- EmailLoginAttempt.create(user, remote_ip) do
{:ok, %{success: true}}
else
{:error, error} ->
Expand Down
16 changes: 16 additions & 0 deletions priv/repo/migrations/20250121155544_add_access_attempts.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Sanbase.Repo.Migrations.AddAccessAttempts do
use Ecto.Migration

def change do
create table(:access_attempts) do
add(:user_id, references(:users), null: true)
add(:ip_address, :string, null: false)
add(:type, :string, null: false)

timestamps()
end

create(index(:access_attempts, [:type, :ip_address, :inserted_at]))
create(index(:access_attempts, [:type, :user_id, :inserted_at]))
end
end
Loading

0 comments on commit 02afec1

Please sign in to comment.