2.9 KiB · text History b868907
defmodule GitGud.Accounts.TwoFactorAttempts do
  @moduledoc """
  Counts failed second-factor codes (TOTP and recovery codes) so they
  can't be guessed without bound.

  Two counters, both bumped on every failure:

    * per pending login (keyed by the nonce `UserAuth.log_in_user/3`
      puts in the session): five failures and that login is dead, the
      user has to sign in with their password again;
    * per user, across logins: twenty failures an hour, so re-entering a
      known password doesn't buy fresh guesses forever.

  Backed by a public ETS table owned by this process, swept once a
  minute. Per node: in a cluster the per-user cap is multiplied by the
  number of nodes, which still keeps a 6-digit code out of reach.
  """

  use GenServer

  @table :gitgud_two_factor_attempts
  @sweep_interval_ms 60_000

  @per_login 5
  @login_ttl_ms :timer.minutes(30)
  @per_user 20
  @user_ttl_ms :timer.hours(1)

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  @impl true
  def init(_opts) do
    :ets.new(@table, [:public, :named_table, :set, write_concurrency: true])
    schedule_sweep()
    {:ok, %{}}
  end

  @impl true
  def handle_info(:sweep, state) do
    now = now()
    :ets.select_delete(@table, [{{:_, :_, :"$1"}, [{:<, :"$1", now}], [true]}])
    schedule_sweep()
    {:noreply, state}
  end

  defp schedule_sweep, do: Process.send_after(self(), :sweep, @sweep_interval_ms)

  @doc "Failures allowed per pending login before it's abandoned."
  def per_login_limit, do: @per_login

  @doc "Failures allowed per user per hour."
  def per_user_limit, do: @per_user

  @doc """
  True when no more codes may be tried for `user_id` — either this
  pending login (`login_nonce`, nil for none) or the user as a whole
  has used up its failures.
  """
  def locked?(user_id, login_nonce) do
    (login_nonce != nil and count({:login, login_nonce}) >= @per_login) or
      count({:user, user_id}) >= @per_user
  end

  @doc "Record a failed code. Returns `:locked` once the limit is reached."
  def record_failure(user_id, login_nonce) do
    if login_nonce != nil, do: bump({:login, login_nonce}, @login_ttl_ms)
    bump({:user, user_id}, @user_ttl_ms)

    if locked?(user_id, login_nonce), do: :locked, else: :ok
  end

  @doc "Forget the failures after a successful factor."
  def clear(user_id, login_nonce) do
    if login_nonce != nil, do: :ets.delete(@table, {:login, login_nonce})
    :ets.delete(@table, {:user, user_id})
    :ok
  end

  defp count(key) do
    now = now()

    case :ets.lookup(@table, key) do
      [{^key, n, expires}] when expires >= now -> n
      _ -> 0
    end
  end

  defp bump(key, ttl) do
    now = now()

    case :ets.lookup(@table, key) do
      [{^key, _, expires}] when expires < now -> :ets.insert(@table, {key, 1, now + ttl})
      _ -> :ets.update_counter(@table, key, {2, 1}, {key, 0, now + ttl})
    end
  end

  defp now, do: System.monotonic_time(:millisecond)
end