cover/Elixir.WalletAuth.RateLimiter.html

1 defmodule WalletAuth.RateLimiter do
2 @moduledoc """
3 ETS-backed rate limiter for login and OTP anti-bruteforce controls.
4
5 Per ADR 0006:
6 - Rate limiting and burst controls at app edge.
7 - Login and OTP anti-bruteforce controls.
8 - Account/device lockout policy with configurable thresholds.
9
10 Buckets:
11 - `:login` — per-identifier (email/phone) login attempts.
12 - `:otp` — per-identifier OTP verification attempts.
13
14 Strategy: sliding-window counter per (bucket, identifier).
15 After `max_attempts` within `window_seconds`, returns `{:error, :rate_limited}`.
16 Lockout is reset after `window_seconds`.
17 """
18
19 use GenServer
20
21 @table :wallet_auth_rate_limiter
22
23 # Default policy: 5 attempts per 15 minutes per (bucket, identifier)
24 @defaults %{
25 login: %{max_attempts: 5, window_seconds: 900},
26 otp: %{max_attempts: 5, window_seconds: 300}
27 }
28
29 @type bucket :: :login | :otp
30 @type auth_identifier :: String.t()
31
32 # --- Client API ---
33
34 def start_link(opts) do
35
:-(
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
36 end
37
38 @doc """
39 Records an attempt and checks if rate limit is exceeded.
40
41 Returns:
42 - `:ok` — allowed (under limit).
43 - `{:error, :rate_limited}` — limit exceeded; caller must reject request.
44 """
45 @spec check_and_increment(bucket(), identifier()) :: :ok | {:error, :rate_limited}
46 def check_and_increment(bucket, identifier) do
47 28 GenServer.call(__MODULE__, {:check_and_increment, bucket, identifier})
48 end
49
50 @doc "Returns remaining attempts for a bucket/identifier pair."
51 @spec remaining(bucket(), identifier()) :: non_neg_integer()
52 def remaining(bucket, identifier) do
53 4 GenServer.call(__MODULE__, {:remaining, bucket, identifier})
54 end
55
56 @doc "Manually resets the counter for a bucket/identifier (e.g., after successful login)."
57 @spec reset(bucket(), identifier()) :: :ok
58 def reset(bucket, identifier) do
59 2 GenServer.call(__MODULE__, {:reset, bucket, identifier})
60 end
61
62 @doc "Resets all rate limiter state. For test use only."
63 11 def reset_all, do: GenServer.call(__MODULE__, :reset_all)
64
65 # --- Server Callbacks ---
66
67 @impl true
68 def init(_opts) do
69
:-(
table = :ets.new(@table, [:set, :protected, :named_table])
70 {:ok, %{table: table}}
71 end
72
73 @impl true
74 def handle_call({:check_and_increment, bucket, identifier}, _from, state) do
75 28 policy = policy_for(bucket)
76 28 now = System.system_time(:second)
77 28 window_start = now - policy.window_seconds
78 28 key = {bucket, identifier}
79
80 28 {attempts, timestamps} =
81 case :ets.lookup(@table, key) do
82 [{_k, ts_list}] ->
83 # Keep only timestamps within the current window
84 15 valid = Enum.filter(ts_list, &(&1 > window_start))
85 {length(valid), valid}
86
87 13 [] ->
88 {0, []}
89 end
90
91 28 if attempts >= policy.max_attempts do
92 3 {:reply, {:error, :rate_limited}, state}
93 else
94 25 :ets.insert(@table, {key, [now | timestamps]})
95 25 {:reply, :ok, state}
96 end
97 end
98
99 @impl true
100 def handle_call({:remaining, bucket, identifier}, _from, state) do
101 4 policy = policy_for(bucket)
102 4 now = System.system_time(:second)
103 4 window_start = now - policy.window_seconds
104 4 key = {bucket, identifier}
105
106 4 attempts =
107 case :ets.lookup(@table, key) do
108 2 [{_k, ts_list}] -> length(Enum.filter(ts_list, &(&1 > window_start)))
109 2 [] -> 0
110 end
111
112 4 {:reply, max(0, policy.max_attempts - attempts), state}
113 end
114
115 @impl true
116 def handle_call({:reset, bucket, identifier}, _from, state) do
117 2 :ets.delete(@table, {bucket, identifier})
118 2 {:reply, :ok, state}
119 end
120
121 @impl true
122 def handle_call(:reset_all, _from, state) do
123 11 :ets.delete_all_objects(@table)
124 11 {:reply, :ok, state}
125 end
126
127 defp policy_for(bucket) do
128 Application.get_env(:wallet_auth, :rate_limit_policy, @defaults)
129 32 |> Map.get(bucket, @defaults[bucket])
130 end
131 end
Line Hits Source