2.1 KiB · text History b868907
defmodule GitGud.Accounts.PasswordThrottle do
  @moduledoc """
  Password sign-in with a cap on wrong guesses, for every place a
  password is accepted: the login form and Basic auth for git and the
  package registries.

  Two counts, both bumped by every wrong password and checked before
  the password is tried:

    * per login (the email or handle as typed, case-folded) — ten in
      15 minutes, so one account can't be guessed at however many
      addresses the guesses come from;
    * per client address — fifty in 15 minutes, so one client can't
      walk a list of accounts.

  Counted whether or not the account exists, so being refused says
  nothing about whether it does. A right password clears its login's
  count. Limits live in `GitGud.RateLimit`.
  """

  alias GitGud.Accounts
  alias GitGud.Accounts.User
  alias GitGud.RateLimit

  require Logger

  @doc """
  `{:ok, user}` for the right password, `:error` for a wrong one or an
  unknown login, `{:error, :rate_limited, retry_after_s}` when this
  login or address has run out of guesses (the password isn't tried).
  """
  @spec authenticate(String.t(), String.t(), :inet.ip_address() | nil) ::
          {:ok, User.t()} | :error | {:error, :rate_limited, pos_integer()}
  def authenticate(login, password, ip) when is_binary(login) and is_binary(password) do
    login_key = normalize(login)
    ip_key = RateLimit.ip_key(ip)

    with :ok <- RateLimit.check(:login_account, login_key),
         :ok <- RateLimit.check(:login_ip, ip_key) do
      case Accounts.get_user_by_email_and_password(login, password) do
        %User{} = user ->
          RateLimit.clear(:login_account, login_key)
          {:ok, user}

        _ ->
          RateLimit.record(:login_account, login_key)
          RateLimit.record(:login_ip, ip_key)
          :error
      end
    else
      {:error, retry_after} ->
        Logger.warning("password sign-in refused for #{ip_key}: too many wrong passwords")
        {:error, :rate_limited, retry_after}
    end
  end

  def authenticate(_login, _password, _ip), do: :error

  defp normalize(login), do: login |> String.trim() |> String.downcase()
end