Throttling login attempts

Is there a best practice for throttling login attempts in Phoenix?

What is everybody else doing right now?

Check this package out https://github.com/grempe/ex_rated

We use it on one project and have our own (a genserver with simple state and some custom logic) on another.

One possible approach, assuming the login is database backed, is to also store it in the database. You need a column with the attempts and the time of the first attempt. Once login succeeds, you clear those. If login fails and it is within the initial timestamp, you bump the counter. The goal is to not allow more than 5 attempts in X minutes.

4 Likes

I recommend this approach as well, then you also have an audit trail as a side effect

def check_rate(ip, email) do
    expiry = Timex.shift(Timex.now(), minutes: -15)
    {:ok, ip} = EctoNetwork.INET.cast(ip)

    query =
      from a in Attempt,
        where:
          a.ip == ^ip and a.inserted_at >= ^expiry and
            a.success == false and a.email == ^email

    {:rate, Repo.aggregate(query, :count, :ip) < 5}
  end

or similar depending on the requirements.

3 Likes

I implemented on registration and login form Recaptcha from Google:

Thanks, guys.

I’ll be rolling with a similar DB query version for now.

1 Like

I’m late to the party, but we’ve been very pleased with this erlang library we’ve had in production for a few years now. We use it primarily for rate limiting our outgoing calls, but it works just as good for incoming calls. The mnesia backend it offers was our main reason to choose it for our 2 node cluster.

3 Likes

Yeah, in the long run, an ETS (or mnesia?) based approach that doesn’t query the database with every attempt would probably be better.

Here’s what I’ve got for now. (don’t ask me why I made options even though I’ll probably never not use the defaults, I don’t know, it’s a sickness :sweat_smile:)

  def check_login_attempts(ip_address, email, opts \\ []) do
    {limit, opts} = Keyword.pop(opts, :limit, 5)

    if get_login_attempt_rate(ip_address, email, opts) < limit do
      :ok
    else
      {:error, :too_many_requests}
    end
  end

  def get_login_attempt_rate(ip_address, email, opts \\ []) do
    time_ago_opts = Keyword.get(opts, :time_ago, [minutes: -1])
    time_ago = Timex.shift(Timex.now(), time_ago_opts)

    query =
      from a in LoginAttempt,
        where: a.ip_address == ^ip_address or a.email == ^email,
        where: a.attempted_at >= ^time_ago,
        where: not a.success

    Repo.aggregate(query, :count)
  end

I didn’t limit it to just ip_address but also email address (which is a login credential). In case, for example and for whatever reason, someone is using a botnet or something to attempt to login to a single account, not only will the IPs get throttled, the account itself will be as well.

3 Likes