[OCaml] High Intensity Training Online
1
open Hito_app
2
open Support
3
4
let suite =
5
[
6
( "web.auth",
7
[
8
( "an unauthenticated visit to the overview redirects to sign-in",
9
`Quick,
10
fun () ->
11
let c = client () in
12
let response = get c "/" in
13
Alcotest.(check int) "redirect" 303 (status response);
14
Alcotest.(check string) "to /login" "/login" (location response) );
15
( "the sign-in page is served",
16
`Quick,
17
fun () ->
18
let c = client () in
19
Alcotest.(check int) "GET /login" 200 (status (get c "/login")) );
20
( "every response carries the browser hardening headers",
21
`Quick,
22
fun () ->
23
let c = client () in
24
let response = get c "/login" in
25
let header name = Dream.header response name in
26
Alcotest.(check bool)
27
"a same-origin CSP that refuses framing" true
28
(match header "Content-Security-Policy" with
29
| Some csp ->
30
Option.is_some (index_from ~needle:"default-src 'self'" csp 0)
31
&& Option.is_some
32
(index_from ~needle:"frame-ancestors 'none'" csp 0)
33
| None -> false);
34
Alcotest.(check (option string))
35
"framing denied" (Some "DENY") (header "X-Frame-Options");
36
Alcotest.(check (option string))
37
"no type sniffing" (Some "nosniff")
38
(header "X-Content-Type-Options");
39
Alcotest.(check (option string))
40
"strict referrer" (Some "strict-origin-when-cross-origin")
41
(header "Referrer-Policy") );
42
( "signing in rotates the session identifier",
43
`Quick,
44
fun () ->
45
(* A session identifier that existed before authentication must not
46
survive into the authenticated session. Otherwise an attacker
47
who planted the identifier would inherit the login (session
48
fixation). Read the pre-login cookie, sign in, and confirm the
49
cookie the server now sets is different. *)
50
let session_cookie c =
51
List.find_opt
52
(fun cookie ->
53
String.length cookie >= 13
54
&& String.sub cookie 0 13 = "dream.session")
55
c.jar
56
in
57
let c = client () in
58
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
59
(* Sign out, then obtain a fresh anonymous session cookie. *)
60
let token = Option.get (csrf_token (body (get c "/"))) in
61
let _ = post c "/logout" [ ("dream.csrf", token) ] in
62
let _ = get c "/login" in
63
let before = session_cookie c in
64
Alcotest.(check bool)
65
"an anonymous session cookie exists before sign-in" true
66
(Option.is_some before);
67
let token = Option.get (csrf_token (body (get c "/login"))) in
68
let _ =
69
post c "/login"
70
[
71
("dream.csrf", token);
72
("username", "lifter");
73
("password", "heavyduty1");
74
]
75
in
76
let after = session_cookie c in
77
Alcotest.(check bool)
78
"a session cookie exists after sign-in" true
79
(Option.is_some after);
80
Alcotest.(check bool)
81
"the session identifier changed on sign-in" true (before <> after)
82
);
83
( "repeated sign-in attempts from one client are throttled",
84
`Quick,
85
fun () ->
86
(* Online password guessing is bounded per client: ten attempts a
87
minute. The eleventh is refused with 429 before any credential
88
check, and a wrong password never becomes a right one by
89
repetition. *)
90
let c = client () in
91
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
92
let token = Option.get (csrf_token (body (get c "/"))) in
93
let _ = post c "/logout" [ ("dream.csrf", token) ] in
94
let attempt () =
95
let token = Option.get (csrf_token (body (get c "/login"))) in
96
post c "/login"
97
[
98
("dream.csrf", token);
99
("username", "lifter");
100
("password", "wrong");
101
]
102
in
103
for _ = 1 to 10 do
104
Alcotest.(check int)
105
"a wrong password is refused" 401
106
(status (attempt ()))
107
done;
108
Alcotest.(check int)
109
"the eleventh attempt is throttled" 429
110
(status (attempt ()));
111
(* Even the right password is refused while throttled. *)
112
let token = Option.get (csrf_token (body (get c "/login"))) in
113
let right =
114
post c "/login"
115
[
116
("dream.csrf", token);
117
("username", "lifter");
118
("password", "heavyduty1");
119
]
120
in
121
Alcotest.(check int)
122
"a correct password is also refused while throttled" 429
123
(status right) );
124
( "registration creates the account, signs in, and reaches the overview",
125
`Quick,
126
fun () ->
127
let c, repo = client_with_repo () in
128
let registered =
129
register c ~username:"lifter" ~password:"heavyduty1"
130
in
131
Alcotest.(check int)
132
"registered, redirected" 303 (status registered);
133
(* The account now exists: the flow orchestrated a real create. *)
134
let trainee = the_trainee repo ~username:"lifter" in
135
Alcotest.(check string)
136
"stored under the normalized username" "lifter"
137
(Trainee.username_to_string trainee.Trainee.username);
138
(* The session authenticates the follow-up request. *)
139
Alcotest.(check int)
140
"authenticated overview" 200
141
(status (get c "/")) );
142
( "an authenticated trainee reaches the overview",
143
`Quick,
144
fun () ->
145
let c = client () in
146
let _ = sign_in_new c in
147
Alcotest.(check int) "home" 200 (status (get c "/")) );
148
( "sign-out clears the session",
149
`Quick,
150
fun () ->
151
let c = client () in
152
let _ = sign_in_new c in
153
let token = Option.get (csrf_token (body (get c "/"))) in
154
let out = post c "/logout" [ ("dream.csrf", token) ] in
155
Alcotest.(check int) "logout redirects" 303 (status out);
156
(* The session is gone: the overview again bounces to sign-in. *)
157
let after = get c "/" in
158
Alcotest.(check int) "overview now redirects" 303 (status after);
159
Alcotest.(check string) "to /login" "/login" (location after) );
160
( "a form post without a CSRF token is refused and changes nothing",
161
`Quick,
162
fun () ->
163
let c, repo = client_with_repo () in
164
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
165
let response =
166
post c "/routines/ideal/select" [ (* no dream.csrf *) ]
167
in
168
Alcotest.(check int)
169
"redirects after the refused post" 303 (status response);
170
(* No routine was selected: the refused post had no domain effect. *)
171
let trainee = the_trainee repo ~username:"lifter" in
172
Alcotest.(check bool)
173
"no active routine" true
174
(Option.is_none
175
(Lwt_main.run
176
(Test_repo.active_routine repo trainee.Trainee.id))) );
177
( "a too-short username is refused",
178
`Quick,
179
fun () ->
180
let c, repo = client_with_repo () in
181
let response = register c ~username:"abc" ~password:"heavyduty1" in
182
Alcotest.(check int) "rejected" 400 (status response);
183
Alcotest.(check (option string))
184
"no account created" None
185
(Lwt_main.run
186
(match Trainee.username "abc" with
187
| Ok u -> Test_repo.find_trainee_by_username repo u
188
| Error _ -> Lwt.return None)
189
|> Option.map (fun t -> Trainee.id_to_string t.Trainee.id)) );
190
( "a too-long username is refused",
191
`Quick,
192
fun () ->
193
let c = client () in
194
let response =
195
register c ~username:"abcdefghijklmnopqrstu"
196
~password:"heavyduty1"
197
in
198
Alcotest.(check int) "rejected" 400 (status response) );
199
( "a short password is accepted (passwords carry no policy)",
200
`Quick,
201
fun () ->
202
let c = client () in
203
let response = register c ~username:"lifter" ~password:"x" in
204
Alcotest.(check int) "registered, redirected" 303 (status response);
205
Alcotest.(check int)
206
"authenticated overview" 200
207
(status (get c "/")) );
208
( "with registration closed the register routes are absent",
209
`Quick,
210
fun () ->
211
(* The production default: [registration_open] unset. *)
212
let handlers = Handlers.make ~repo:(Test_repo.create ()) () in
213
let app =
214
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers)
215
|> fun h -> Dream.set_secret "test-secret-value" h
216
in
217
let c = { app; jar = [] } in
218
Alcotest.(check int)
219
"GET /register is not found" 404
220
(status (get c "/register"));
221
Alcotest.(check int)
222
"GET /login is served" 200
223
(status (get c "/login")) );
224
( "a returning trainee signs in with their credentials",
225
`Quick,
226
fun () ->
227
(* Register, then sign out, then sign back in through the credential
228
form. This exercises [POST /login], which registration's
229
auto-sign-in never touches. *)
230
let c = client () in
231
let _ = sign_in_new c in
232
let token = Option.get (csrf_token (body (get c "/"))) in
233
let _ = post c "/logout" [ ("dream.csrf", token) ] in
234
Alcotest.(check int)
235
"signed out, overview redirects" 303
236
(status (get c "/"));
237
let signed_in =
238
sign_in c ~username:"lifter" ~password:"heavyduty1"
239
in
240
Alcotest.(check int) "sign-in redirects" 303 (status signed_in);
241
Alcotest.(check bool)
242
"sign-in lands on Home" true
243
(List.mem "/" (Dream.headers signed_in "Location"));
244
Alcotest.(check int)
245
"authenticated overview" 200
246
(status (get c "/")) );
247
( "a wrong password is refused and establishes no session",
248
`Quick,
249
fun () ->
250
let c = client () in
251
let _ = sign_in_new c in
252
let token = Option.get (csrf_token (body (get c "/"))) in
253
let _ = post c "/logout" [ ("dream.csrf", token) ] in
254
let refused =
255
sign_in c ~username:"lifter" ~password:"not-the-password"
256
in
257
Alcotest.(check int) "rejected" 401 (status refused);
258
(* The rejected attempt establishes no session. *)
259
Alcotest.(check int)
260
"still unauthenticated" 303
261
(status (get c "/")) );
262
( "an unknown username is refused like a wrong password",
263
`Quick,
264
fun () ->
265
let c = client () in
266
let refused = sign_in c ~username:"nobody" ~password:"heavyduty1" in
267
Alcotest.(check int) "rejected" 401 (status refused);
268
Alcotest.(check int)
269
"no session established" 303
270
(status (get c "/")) );
271
( "an already-authenticated visit to the sign-in page redirects Home",
272
`Quick,
273
fun () ->
274
let c = client () in
275
let _ = sign_in_new c in
276
let response = get c "/login" in
277
Alcotest.(check int) "redirects" 303 (status response);
278
Alcotest.(check bool)
279
"sends an authenticated visitor Home" true
280
(List.mem "/" (Dream.headers response "Location")) );
281
( "an already-authenticated visit to the register page redirects Home",
282
`Quick,
283
fun () ->
284
let c = client () in
285
let _ = sign_in_new c in
286
let response = get c "/register" in
287
Alcotest.(check int) "redirects" 303 (status response);
288
Alcotest.(check bool)
289
"sends an authenticated visitor Home" true
290
(List.mem "/" (Dream.headers response "Location")) );
291
( "signing out returns the trainee to the sign-in page",
292
`Quick,
293
fun () ->
294
let c = client () in
295
let _ = sign_in_new c in
296
let page = body (get c "/") in
297
let token = Option.get (csrf_token page) in
298
let out = post c "/logout" [ ("dream.csrf", token) ] in
299
Alcotest.(check int) "logout redirect" 303 (status out);
300
let after = get c "/" in
301
Alcotest.(check int) "overview now redirects" 303 (status after);
302
Alcotest.(check string) "to /login" "/login" (location after) );
303
] );
304
]
305