cover/Elixir.WalletAuth.Otp.OtpStore.html

1 defmodule WalletAuth.Otp.OtpStore do
2 @moduledoc """
3 ETS-backed GenServer for OTP challenge lifecycle management.
4
5 Per ADR 0006 and Phase 2 checklist:
6 - Challenge TTL: configurable, default 5 minutes.
7 - Attempt limit: configurable, default 3 attempts before lockout.
8 - Challenge scope: bound to a (user_id, purpose) key.
9
10 Purposes supported:
11 - `:login_mfa` — second factor during login.
12 - `:step_up` — risk-based step-up for high-risk operations.
13 - `:device_verify` — device trust enrollment.
14 """
15
16 use GenServer
17
18 @table :wallet_auth_otp_store
19 @default_ttl_seconds 300
20 @default_max_attempts 3
21
22 @type purpose :: :login_mfa | :step_up | :device_verify
23 @type challenge_key :: {user_id :: String.t(), purpose()}
24 @type challenge_record :: %{
25 code: String.t(),
26 user_id: String.t(),
27 purpose: purpose(),
28 attempts: non_neg_integer(),
29 max_attempts: pos_integer(),
30 expires_at: integer(),
31 status: :pending | :verified | :exhausted | :expired
32 }
33
34 # --- Client API ---
35
36 def start_link(opts) do
37
:-(
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
38 end
39
40 @doc """
41 Starts a new OTP challenge for the given user and purpose.
42 Any existing challenge for the same (user_id, purpose) is replaced.
43
44 Returns `{:ok, code}`.
45 """
46 @spec start_challenge(String.t(), purpose(), keyword()) :: {:ok, String.t()}
47 7 def start_challenge(user_id, purpose, opts \\ []) do
48 10 code = WalletAuth.Otp.OtpChallenge.generate()
49 10 GenServer.call(__MODULE__, {:start, user_id, purpose, code, opts})
50 {:ok, code}
51 end
52
53 @doc """
54 Verifies an OTP challenge.
55
56 Returns:
57 - `{:ok, :verified}` — code matches, challenge consumed.
58 - `{:error, :invalid_code}` — code wrong, attempt incremented.
59 - `{:error, :exhausted}` — max attempts exceeded.
60 - `{:error, :expired}` — challenge TTL elapsed.
61 - `{:error, :not_found}` — no active challenge for this user/purpose.
62 """
63 @spec verify_challenge(String.t(), purpose(), String.t()) ::
64 {:ok, :verified}
65 | {:error, :invalid_code | :exhausted | :expired | :not_found}
66 def verify_challenge(user_id, purpose, code) do
67 13 GenServer.call(__MODULE__, {:verify, user_id, purpose, code})
68 end
69
70 @doc "Returns number of remaining attempts or nil if no challenge exists."
71 @spec remaining_attempts(String.t(), purpose()) :: non_neg_integer() | nil
72 def remaining_attempts(user_id, purpose) do
73 3 GenServer.call(__MODULE__, {:remaining, user_id, purpose})
74 end
75
76 @doc "Resets all OTP store state. For test use only."
77 16 def reset, do: GenServer.call(__MODULE__, :reset)
78
79 # --- Server Callbacks ---
80
81 @impl true
82 def init(_opts) do
83
:-(
table = :ets.new(@table, [:set, :protected, :named_table])
84 {:ok, %{table: table}}
85 end
86
87 @impl true
88 def handle_call({:start, user_id, purpose, code, opts}, _from, state) do
89 10 ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
90 10 max_attempts = Keyword.get(opts, :max_attempts, @default_max_attempts)
91 10 now = System.system_time(:second)
92
93 10 record = %{
94 code: code,
95 user_id: user_id,
96 purpose: purpose,
97 attempts: 0,
98 max_attempts: max_attempts,
99 expires_at: now + ttl,
100 status: :pending
101 }
102
103 10 :ets.insert(@table, {{user_id, purpose}, record})
104 10 {:reply, :ok, state}
105 end
106
107 @impl true
108 def handle_call({:verify, user_id, purpose, submitted_code}, _from, state) do
109 13 now = System.system_time(:second)
110 13 key = {user_id, purpose}
111
112 13 result =
113 case :ets.lookup(@table, key) do
114 1 [] ->
115 {:error, :not_found}
116
117 1 [{_key, %{status: :verified}}] ->
118 {:error, :not_found}
119
120 1 [{_key, %{status: :exhausted}}] ->
121 {:error, :exhausted}
122
123 1 [{_key, %{expires_at: exp}}] when exp <= now ->
124 {:error, :expired}
125
126
:-(
[{_key, %{attempts: att, max_attempts: max}}] when att >= max ->
127 {:error, :exhausted}
128
129 [{_key, record}] ->
130 9 if Plug.Crypto.secure_compare(record.code, submitted_code) do
131 4 :ets.insert(@table, {key, %{record | status: :verified}})
132 {:ok, :verified}
133 else
134 5 new_att = record.attempts + 1
135 5 new_status = if new_att >= record.max_attempts, do: :exhausted, else: :pending
136 5 :ets.insert(@table, {key, %{record | attempts: new_att, status: new_status}})
137 {:error, :invalid_code}
138 end
139 end
140
141 13 {:reply, result, state}
142 end
143
144 @impl true
145 def handle_call({:remaining, user_id, purpose}, _from, state) do
146 3 now = System.system_time(:second)
147
148 3 result =
149 case :ets.lookup(@table, {user_id, purpose}) do
150 [{_key, %{status: :pending, attempts: att, max_attempts: max, expires_at: exp}}]
151 when exp > now ->
152 2 max - att
153
154 1 _ ->
155 nil
156 end
157
158 3 {:reply, result, state}
159 end
160
161 @impl true
162 def handle_call(:reset, _from, state) do
163 16 :ets.delete_all_objects(@table)
164 16 {:reply, :ok, state}
165 end
166 end
Line Hits Source