/__w/shurbej/shurbej/_build/test/cover/ct/shurbej_session.html

1 -module(shurbej_session).
2 -behaviour(gen_server).
3
4 %% Login session manager — gen_server owning protected ETS tables.
5 %% All writes go through gen_server calls.
6
7 -export([
8 start_link/0,
9 create/0,
10 get/1,
11 complete/3,
12 cancel/1,
13 delete/1,
14 subscribe/2,
15 cleanup_expired/0,
16 check_login_rate/1,
17 record_login_success/1,
18 insert_raw/2
19 ]).
20
21 -define(MAX_LOGIN_ATTEMPTS, 5).
22 -define(RATE_WINDOW_SECS, 300). %% 5 minutes
23
24 -export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
25
26 -define(TABLE, shurbej_login_sessions).
27 -define(SUBSCRIBERS, shurbej_login_subscribers).
28 -define(RATE_TABLE, shurbej_login_rate).
29 -define(SESSION_TTL_MS, 600000). %% 10 minutes
30 -define(MAX_PENDING_SESSIONS, 100).
31
32 start_link() ->
33 1 gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
34
35 %% Public API — reads are direct ETS lookups (safe on protected tables),
36 %% writes go through gen_server.
37
38 create() ->
39 6 gen_server:call(?MODULE, create).
40
41 get(Token) ->
42 %% Reads are safe from any process
43 16 case ets:lookup(?TABLE, Token) of
44 [{_, #{created := Created} = Session}] ->
45 13 Now = erlang:system_time(millisecond),
46 13 case Now - Created > ?SESSION_TTL_MS of
47 true ->
48 %% Expired — delete via gen_server
49 2 gen_server:cast(?MODULE, {delete, Token}),
50 2 {error, expired};
51 false ->
52 11 {ok, Session}
53 end;
54 [] ->
55 3 {error, not_found}
56 end.
57
58 complete(Token, ApiKey, UserInfo) ->
59 3 gen_server:call(?MODULE, {complete, Token, ApiKey, UserInfo}).
60
61 cancel(Token) ->
62 2 gen_server:call(?MODULE, {cancel, Token}).
63
64 delete(Token) ->
65 1 gen_server:call(?MODULE, {delete, Token}).
66
67 subscribe(Token, Pid) ->
68 1 gen_server:call(?MODULE, {subscribe, Token, Pid}).
69
70 cleanup_expired() ->
71 1 gen_server:call(?MODULE, cleanup_expired).
72
73 %% Rate limiting for login attempts.
74 %%
75 %% check_login_rate atomically gates AND reserves a slot in the counter
76 %% (inside a single gen_server call) so concurrent bursts can't race past
77 %% the limit. Callers MUST call record_login_success/1 on a successful
78 %% authenticate to refund the slot; otherwise the reservation stands and
79 %% the attempt counts against the budget.
80 check_login_rate(Username) ->
81 27 gen_server:call(?MODULE, {check_login_rate, Username}).
82
83 record_login_success(Username) ->
84 7 gen_server:call(?MODULE, {release_login_rate, Username}).
85
86 %% Insert a session with a specific timestamp (for testing expiry).
87 insert_raw(Token, Session) ->
88 3 gen_server:call(?MODULE, {insert_raw, Token, Session}).
89
90 %% gen_server callbacks
91
92 init([]) ->
93 1 ets:new(?TABLE, [named_table, protected, set]),
94 1 ets:new(?SUBSCRIBERS, [named_table, protected, bag]),
95 1 ets:new(?RATE_TABLE, [named_table, protected, set]),
96 1 {ok, #{}}.
97
98 handle_call(create, _From, State) ->
99 6 PendingCount = ets:info(?TABLE, size),
100 6 case PendingCount >= ?MAX_PENDING_SESSIONS of
101 true ->
102
:-(
{reply, {error, too_many}, State};
103 false ->
104 6 Token = generate_token(),
105 6 CsrfToken = generate_token(),
106 6 BaseUrl = to_binary(application:get_env(shurbej, base_url, <<"http://localhost:8080">>)),
107 6 LoginUrl = <<BaseUrl/binary, "/login?token=", Token/binary>>,
108 6 ets:insert(?TABLE, {Token, #{
109 status => pending,
110 created => erlang:system_time(millisecond),
111 login_url => LoginUrl,
112 csrf_token => CsrfToken
113 }}),
114 6 {reply, {ok, Token, LoginUrl}, State}
115 end;
116
117 handle_call({complete, Token, ApiKey, UserInfo}, _From, State) ->
118 3 Result = case ets:lookup(?TABLE, Token) of
119 [{_, #{status := pending} = Session}] ->
120 3 Completed = Session#{
121 status => completed,
122 api_key => ApiKey,
123 user_info => UserInfo
124 },
125 3 ets:insert(?TABLE, {Token, Completed}),
126 3 do_notify(Token, {login_complete, ApiKey, UserInfo}),
127 3 ok;
128 _ ->
129
:-(
{error, not_found}
130 end,
131 3 {reply, Result, State};
132
133 handle_call({cancel, Token}, _From, State) ->
134 2 Result = case ets:lookup(?TABLE, Token) of
135 [{_, _}] ->
136 1 do_notify(Token, login_cancelled),
137 1 ets:delete(?TABLE, Token),
138 1 ok;
139 [] ->
140 1 {error, not_found}
141 end,
142 2 {reply, Result, State};
143
144 handle_call({delete, Token}, _From, State) ->
145 1 ets:delete(?TABLE, Token),
146 1 ets:delete(?SUBSCRIBERS, Token),
147 1 {reply, ok, State};
148
149 handle_call({subscribe, Token, Pid}, _From, State) ->
150 1 ets:insert(?SUBSCRIBERS, {Token, Pid}),
151 1 {reply, ok, State};
152
153 handle_call(cleanup_expired, _From, State) ->
154 1 Now = erlang:system_time(millisecond),
155 1 Expired = ets:foldl(fun
156 ({Token, #{created := Created}}, Acc) when Now - Created > ?SESSION_TTL_MS ->
157 1 [Token | Acc];
158 1 (_, Acc) -> Acc
159 end, [], ?TABLE),
160 1 lists:foreach(fun(Token) ->
161 1 ets:delete(?TABLE, Token),
162 1 ets:delete(?SUBSCRIBERS, Token)
163 end, Expired),
164 1 {reply, ok, State};
165
166 handle_call({insert_raw, Token, Session}, _From, State) ->
167 3 ets:insert(?TABLE, {Token, Session}),
168 3 {reply, ok, State};
169
170 handle_call({check_login_rate, Username}, _From, State) ->
171 27 Now = erlang:system_time(second),
172 27 Reply = case ets:lookup(?RATE_TABLE, Username) of
173 [{_, Count, WindowStart}] when Now - WindowStart < ?RATE_WINDOW_SECS ->
174 20 case Count >= ?MAX_LOGIN_ATTEMPTS of
175 7 true -> {error, rate_limited};
176 false ->
177 13 ets:insert(?RATE_TABLE, {Username, Count + 1, WindowStart}),
178 13 ok
179 end;
180 _ ->
181 7 ets:insert(?RATE_TABLE, {Username, 1, Now}),
182 7 ok
183 end,
184 27 {reply, Reply, State};
185
186 handle_call({release_login_rate, Username}, _From, State) ->
187 %% Successful login — roll back the slot reserved by check_login_rate.
188 7 case ets:lookup(?RATE_TABLE, Username) of
189 [{_, Count, _WindowStart}] when Count =< 1 ->
190 4 ets:delete(?RATE_TABLE, Username);
191 [{_, Count, WindowStart}] ->
192 3 ets:insert(?RATE_TABLE, {Username, Count - 1, WindowStart});
193 _ ->
194
:-(
ok
195 end,
196 7 {reply, ok, State};
197
198 handle_call(_Msg, _From, State) ->
199
:-(
{reply, {error, unknown}, State}.
200
201 handle_cast({delete, Token}, State) ->
202 2 ets:delete(?TABLE, Token),
203 2 ets:delete(?SUBSCRIBERS, Token),
204 2 {noreply, State};
205
206 handle_cast(_Msg, State) ->
207
:-(
{noreply, State}.
208
209 handle_info(_Msg, State) ->
210
:-(
{noreply, State}.
211
212 %% Internal
213
214 do_notify(Token, Event) ->
215 4 Subscribers = ets:lookup(?SUBSCRIBERS, Token),
216 4 lists:foreach(fun({_, Pid}) ->
217 1 Pid ! {session_event, Event}
218 end, Subscribers),
219 4 ets:delete(?SUBSCRIBERS, Token).
220
221 generate_token() ->
222 12 binary:encode_hex(crypto:strong_rand_bytes(24), lowercase).
223
224
:-(
to_binary(B) when is_binary(B) -> B;
225 6 to_binary(L) when is_list(L) -> list_to_binary(L).
Line Hits Source