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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
2.9 KiB · text
History
b868907