/__w/shurbej/shurbej/_build/test/cover/aggregate/shurbej_http_login.html

1 -module(shurbej_http_login).
2 -include_lib("shurbej_store/include/shurbej_records.hrl").
3
4 -export([init/2]).
5
6 -define(HTML_HEADERS, #{
7 <<"content-type">> => <<"text/html; charset=utf-8">>,
8 <<"x-frame-options">> => <<"DENY">>,
9 <<"x-content-type-options">> => <<"nosniff">>,
10 <<"content-security-policy">> =>
11 <<"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'">>
12 }).
13
14 %% GET /login?token=... — serve the HTML login form
15 %% POST /login — handle form submission
16 init(Req0, State) ->
17 10 case cowboy_req:method(Req0) of
18 6 <<"GET">> -> handle_get(Req0, State);
19 4 <<"POST">> -> handle_post(Req0, State);
20 _ ->
21
:-(
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0),
22
:-(
{ok, Req, State}
23 end.
24
25 handle_get(Req0, State) ->
26 6 #{token := Token} = cowboy_req:match_qs([{token, [], <<>>}], Req0),
27 6 case shurbej_session:get(Token) of
28 {ok, #{status := pending, csrf_token := Csrf}} ->
29 4 Html = login_page(Token, Csrf, <<>>),
30 4 Req = cowboy_req:reply(200, ?HTML_HEADERS,
31 Html, Req0),
32 4 {ok, Req, State};
33 {ok, #{status := completed}} ->
34 1 Req = cowboy_req:reply(200, ?HTML_HEADERS,
35 success_page(), Req0),
36 1 {ok, Req, State};
37 _ ->
38 1 Req = cowboy_req:reply(404, ?HTML_HEADERS,
39 error_page(<<"Session not found or expired.">>), Req0),
40 1 {ok, Req, State}
41 end.
42
43 handle_post(Req0, State) ->
44 4 case cowboy_req:read_body(Req0, #{length => 4096}) of
45 {ok, Body, Req1} ->
46 4 handle_post_body(Body, Req1, State);
47 {more, _, Req1} ->
48
:-(
Req = cowboy_req:reply(413, ?HTML_HEADERS,
49 error_page(<<"Request too large.">>), Req1),
50
:-(
{ok, Req, State}
51 end.
52
53 handle_post_body(Body, Req1, State) ->
54 4 Params = cow_qs:parse_qs(Body),
55 4 Token = proplists:get_value(<<"token">>, Params, <<>>),
56 4 Username = proplists:get_value(<<"username">>, Params, <<>>),
57 4 Password = proplists:get_value(<<"password">>, Params, <<>>),
58 4 CsrfParam = proplists:get_value(<<"csrf">>, Params, <<>>),
59 4 case shurbej_session:get(Token) of
60 {ok, #{status := pending, csrf_token := ExpectedCsrf}} ->
61 %% Verify CSRF token
62 4 case constant_time_compare(ExpectedCsrf, CsrfParam) of
63 false ->
64
:-(
Html = login_page(Token, ExpectedCsrf, <<"Invalid request. Please try again.">>),
65
:-(
Req = cowboy_req:reply(403, ?HTML_HEADERS,
66 Html, Req1),
67
:-(
{ok, Req, State};
68 true ->
69 %% Check rate limiting
70 4 case shurbej_session:check_login_rate(Username) of
71 {error, rate_limited} ->
72
:-(
Html = login_page(Token, ExpectedCsrf,
73 <<"Too many login attempts. Please wait a few minutes.">>),
74
:-(
Req = cowboy_req:reply(429, ?HTML_HEADERS,
75 Html, Req1),
76
:-(
{ok, Req, State};
77 ok ->
78 4 case shurbej_db:authenticate_user(Username, Password) of
79 {ok, UserId} ->
80 3 shurbej_session:record_login_success(Username),
81 3 ApiKey = generate_api_key(),
82 3 shurbej_db:create_key(ApiKey, UserId,
83 shurbej_http_common:normalize_perms(undefined)),
84 3 UserInfo = #{user_id => UserId, username => Username, display_name => Username},
85 3 ok = shurbej_session:complete(Token, ApiKey, UserInfo),
86 3 Req = cowboy_req:reply(200,
87 ?HTML_HEADERS,
88 success_page(), Req1),
89 3 {ok, Req, State};
90 {error, invalid} ->
91 1 Html = login_page(Token, ExpectedCsrf, <<"Invalid username or password.">>),
92 1 Req = cowboy_req:reply(200,
93 ?HTML_HEADERS,
94 Html, Req1),
95 1 {ok, Req, State}
96 end
97 end
98 end;
99 _ ->
100
:-(
Req = cowboy_req:reply(404,
101 ?HTML_HEADERS,
102 error_page(<<"Session not found or expired.">>), Req1),
103
:-(
{ok, Req, State}
104 end.
105
106 %% Internal — key generation (256 bits)
107 generate_api_key() ->
108 3 binary:encode_hex(crypto:strong_rand_bytes(32), lowercase).
109
110 %% HTML escaping to prevent XSS
111 html_escape(Bin) ->
112 12 B1 = binary:replace(Bin, <<"&">>, <<"&amp;">>, [global]),
113 12 B2 = binary:replace(B1, <<"<">>, <<"&lt;">>, [global]),
114 12 B3 = binary:replace(B2, <<">">>, <<"&gt;">>, [global]),
115 12 binary:replace(B3, <<"\"">>, <<"&quot;">>, [global]).
116
117 login_page(Token, CsrfToken, Error) ->
118 5 ErrorHtml = case Error of
119 4 <<>> -> <<>>;
120 1 Msg -> <<"<p style=\"color:#c33;margin-bottom:16px\">", (html_escape(Msg))/binary, "</p>">>
121 end,
122 5 EscToken = html_escape(Token),
123 5 EscCsrf = html_escape(CsrfToken),
124 5 <<"<!DOCTYPE html>
125 <html>
126 <head>
127 <meta charset=\"utf-8\">
128 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">
129 <title>Shurbej — Sign In</title>
130 <style>
131 *{box-sizing:border-box;margin:0;padding:0}
132 body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
133 align-items:center;min-height:100vh;background:#f5f5f5}
134 .card{background:#fff;border-radius:8px;padding:32px;width:360px;
135 box-shadow:0 2px 8px rgba(0,0,0,.1)}
136 h1{font-size:20px;margin-bottom:24px;text-align:center}
137 label{display:block;font-size:14px;margin-bottom:4px;font-weight:500}
138 input[type=text],input[type=password]{width:100%;padding:8px 12px;
139 border:1px solid #ccc;border-radius:4px;font-size:14px;margin-bottom:16px}
140 button{width:100%;padding:10px;background:#2563eb;color:#fff;border:none;
141 border-radius:4px;font-size:14px;cursor:pointer}
142 button:hover{background:#1d4ed8}
143 </style>
144 </head>
145 <body>
146 <div class=\"card\">
147 <h1>Shurbej</h1>",
148 ErrorHtml/binary,
149 "<form method=\"POST\" action=\"/login\">
150 <input type=\"hidden\" name=\"token\" value=\"", EscToken/binary, "\">
151 <input type=\"hidden\" name=\"csrf\" value=\"", EscCsrf/binary, "\">
152 <label for=\"username\">Username</label>
153 <input type=\"text\" id=\"username\" name=\"username\" required autofocus>
154 <label for=\"password\">Password</label>
155 <input type=\"password\" id=\"password\" name=\"password\" required>
156 <button type=\"submit\">Sign In</button>
157 </form>
158 </div>
159 </body>
160 </html>">>.
161
162 success_page() ->
163 4 <<"<!DOCTYPE html>
164 <html>
165 <head>
166 <meta charset=\"utf-8\">
167 <title>Shurbej — Signed In</title>
168 <style>
169 body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
170 align-items:center;min-height:100vh;background:#f5f5f5}
171 .card{background:#fff;border-radius:8px;padding:32px;width:360px;
172 box-shadow:0 2px 8px rgba(0,0,0,.1);text-align:center}
173 h1{font-size:20px;margin-bottom:12px}
174 p{color:#666}
175 </style>
176 </head>
177 <body>
178 <div class=\"card\">
179 <h1>Signed in</h1>
180 <p>You can close this window and return to Zotero.</p>
181 </div>
182 </body>
183 </html>">>.
184
185 error_page(Message) ->
186 1 <<"<!DOCTYPE html>
187 <html>
188 <head>
189 <meta charset=\"utf-8\">
190 <title>Shurbej — Error</title>
191 <style>
192 body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
193 align-items:center;min-height:100vh;background:#f5f5f5}
194 .card{background:#fff;border-radius:8px;padding:32px;width:360px;
195 box-shadow:0 2px 8px rgba(0,0,0,.1);text-align:center}
196 p{color:#c33}
197 </style>
198 </head>
199 <body>
200 <div class=\"card\"><p>", (html_escape(Message))/binary, "</p></div>
201 </body>
202 </html>">>.
203
204 %% Constant-time binary comparison to prevent timing side-channels.
205 constant_time_compare(<<A, RestA/binary>>, <<B, RestB/binary>>) ->
206 4 constant_time_compare(RestA, RestB, A bxor B);
207
:-(
constant_time_compare(<<>>, <<>>) -> true;
208
:-(
constant_time_compare(_, _) -> false.
209
210 constant_time_compare(<<A, RestA/binary>>, <<B, RestB/binary>>, Acc) ->
211 188 constant_time_compare(RestA, RestB, Acc bor (A bxor B));
212 4 constant_time_compare(<<>>, <<>>, 0) -> true;
213
:-(
constant_time_compare(<<>>, <<>>, _) -> false.
Line Hits Source