Skip to content

Commit 6ea6d55

Browse files
author
Josef Erben
committed
Implement cookie-based session
1 parent 6b61ddf commit 6ea6d55

File tree

7 files changed

+429
-1
lines changed

7 files changed

+429
-1
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Added
44

5+
- New `Session` module to fetch and set session values
6+
- New `cookie_session` middleware that persists session data in cookie
57
- New `Auth` module to work with `Authorization` header (#238)
68
- New `basic_auth` middleware to protect handlers with a `Basic` authentication method (#238)
79

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
let log_src = Logs.Src.create ~doc:"middleware for cookie-based sessions" "opium.session"
2+
3+
module Logs = (val Logs.src_log log_src : Logs.LOG)
4+
module Map = Map.Make (String)
5+
6+
module Session = struct
7+
type t =
8+
{ data : string Map.t
9+
; should_set_cookie : bool
10+
}
11+
12+
let create should_set_cookie = { data = Map.empty; should_set_cookie }
13+
14+
let of_yojson yojson =
15+
let open Yojson.Safe.Util in
16+
let session_list =
17+
try Some (yojson |> to_assoc |> List.map (fun (k, v) -> k, to_string v)) with
18+
| _ -> None
19+
in
20+
session_list
21+
|> Option.map List.to_seq
22+
|> Option.map Map.of_seq
23+
|> Option.map (fun data -> { data; should_set_cookie = false })
24+
;;
25+
26+
let to_yojson { data = session; _ } =
27+
`Assoc (session |> Map.to_seq |> List.of_seq |> List.map (fun (k, v) -> k, `String v))
28+
;;
29+
30+
let of_json json =
31+
try of_yojson (Yojson.Safe.from_string json) with
32+
| _ -> None
33+
;;
34+
35+
let to_json session = session |> to_yojson |> Yojson.Safe.to_string
36+
37+
let to_sexp session =
38+
let open Sexplib0.Sexp_conv in
39+
let open Sexplib0.Sexp in
40+
let data =
41+
session.data
42+
|> Map.to_seq
43+
|> List.of_seq
44+
|> sexp_of_list (sexp_of_pair sexp_of_string sexp_of_string)
45+
in
46+
List
47+
[ List [ Atom "data"; data ]
48+
; List [ Atom "should_set_cookie"; sexp_of_bool session.should_set_cookie ]
49+
]
50+
;;
51+
end
52+
53+
module SessionChange = struct
54+
type t = string option Map.t
55+
56+
let empty = Map.empty
57+
58+
let merge Session.{ data = session; should_set_cookie } t =
59+
let data =
60+
Map.merge
61+
(fun _ session change ->
62+
match session, change with
63+
| _, Some (Some change) -> Some change
64+
| _, Some None -> None
65+
| Some session, None -> Some session
66+
| None, None -> None)
67+
session
68+
t
69+
in
70+
Session.{ data; should_set_cookie }
71+
;;
72+
73+
let to_sexp t =
74+
t
75+
|> Map.to_seq
76+
|> List.of_seq
77+
|> Sexplib0.Sexp_conv.(
78+
sexp_of_list (sexp_of_pair sexp_of_string (sexp_of_option sexp_of_string)))
79+
;;
80+
end
81+
82+
module Env = struct
83+
let key : Session.t Context.key = Context.Key.create ("session", Session.to_sexp)
84+
85+
let key_session_change : SessionChange.t Context.key =
86+
Context.Key.create ("session change", SessionChange.to_sexp)
87+
;;
88+
end
89+
90+
exception Session_not_found
91+
92+
let find key req =
93+
let session =
94+
try Context.find_exn Env.key req.Request.env with
95+
| _ ->
96+
Logs.err (fun m -> m "No session found");
97+
Logs.info (fun m -> m "Have you applied the session middleware?");
98+
raise @@ Session_not_found
99+
in
100+
Map.find_opt key session.data
101+
;;
102+
103+
let set (key, value) resp =
104+
let change =
105+
match Context.find Env.key_session_change resp.Response.env with
106+
| Some change -> Map.add key value change
107+
| None -> SessionChange.empty |> Map.add key value
108+
in
109+
let env = resp.Response.env in
110+
let env = Context.add Env.key_session_change change env in
111+
{ resp with env }
112+
;;
113+
114+
let decode_session cookie_key signed_with req =
115+
match Request.cookie ~signed_with cookie_key req with
116+
| None -> Session.create true
117+
| Some cookie_value ->
118+
(match Session.of_json cookie_value with
119+
| None ->
120+
Logs.err (fun m ->
121+
m
122+
"Failed to parse value found in session cookie '%s': '%s'"
123+
cookie_key
124+
cookie_value);
125+
Logs.info (fun m ->
126+
m
127+
"Maybe the cookie key '%s' collides with a cookie issued by someone else. \
128+
Try to change the cookie key."
129+
cookie_key);
130+
Session.create true
131+
| Some session -> session)
132+
;;
133+
134+
let persist_session current_session signed_with cookie_key resp =
135+
let session_change = Context.find Env.key_session_change resp.Response.env in
136+
let cookie =
137+
match current_session.Session.should_set_cookie, session_change with
138+
| true, Some session_change ->
139+
let session = SessionChange.merge current_session session_change in
140+
let cookie_value = Session.to_json session in
141+
Some (cookie_key, cookie_value)
142+
| true, None ->
143+
let cookie_value = Session.to_json (Session.create true) in
144+
Some (cookie_key, cookie_value)
145+
| false, Some session_change ->
146+
let session = SessionChange.merge current_session session_change in
147+
let cookie_value = Session.to_json session in
148+
Some (cookie_key, cookie_value)
149+
| false, None -> None
150+
in
151+
match cookie with
152+
| None -> resp
153+
| Some cookie -> Response.add_cookie_or_replace ~sign_with:signed_with cookie resp
154+
;;
155+
156+
let m ?(cookie_key = "_session") signed_with =
157+
let open Lwt.Syntax in
158+
let filter handler req =
159+
let session = decode_session cookie_key signed_with req in
160+
let env = req.Request.env in
161+
let env = Context.add Env.key session env in
162+
let req = { req with env } in
163+
let* resp = handler req in
164+
Lwt.return @@ persist_session session signed_with cookie_key resp
165+
in
166+
Rock.Middleware.create ~name:"session" ~filter
167+
;;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
exception Session_not_found
2+
3+
val find : string -> Request.t -> string option
4+
val set : string * string option -> Response.t -> Response.t
5+
val m : ?cookie_key:string -> Cookie.Signer.t -> Rock.Middleware.t

opium/src/opium.ml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ module Route = Route
1212
module Auth = Auth
1313
module Router = Middleware_router
1414

15+
module Session = struct
16+
exception Session_not_found = Middleware_cookie_session.Session_not_found
17+
18+
let find = Middleware_cookie_session.find
19+
let set = Middleware_cookie_session.set
20+
end
21+
1522
module Handler = struct
1623
let serve = Handler_serve.h
1724
end
@@ -29,4 +36,5 @@ module Middleware = struct
2936
let method_required = Middleware_method_required.m
3037
let head = Middleware_head.m
3138
let basic_auth = Middleware_basic_auth.m
39+
let cookie_session = Middleware_cookie_session.m
3240
end

opium/src/opium.mli

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ module Router : sig
2020
val splat : Request.t -> string list
2121
end
2222

23+
module Session : sig
24+
exception Session_not_found
25+
26+
(** [find key req] returns the session value associated with the key [key] in the
27+
current session. *)
28+
val find : string -> Request.t -> string option
29+
30+
(** [set session resp] returns a response [resp] that has a session value [session]
31+
associated to it. Session is a tuple [(key, value)] where the value is optional. Use
32+
[None] for the value to remove session values. *)
33+
val set : string * string option -> Response.t -> Response.t
34+
end
35+
2336
(** Collection of handlers commonly used to build Opium applications *)
2437
module Handler : sig
2538
(** [serve ?mime_type ?etag ?headers read] returns a handler that will serve the result
@@ -240,4 +253,19 @@ module Middleware : sig
240253
-> auth_callback:(username:string -> password:string -> 'a option Lwt.t)
241254
-> unit
242255
-> Rock.Middleware.t
256+
257+
(** {3 [cookie_session]} *)
258+
259+
(** [cookie_session ?cookie_key signed_with] creates a middleware for handling sessions
260+
where the actual session is stored in a signed cookie where the [cookie_key] is set
261+
to "_session" by default. You have to provide cookie signer as [signed_with]
262+
argument that is used by the middleware to sign and verify cookies.
263+
264+
The session data is stored in the session cookie. The cookie is only signed,
265+
therefore the data is readable by the client.
266+
267+
The data size is very limited (up to 4 KB). In order to associate more data with a
268+
session, store a reference in the cookie with this middleware and take care of
269+
persisting the actual data with a cache or persistence service. *)
270+
val cookie_session : ?cookie_key:string -> Cookie.Signer.t -> Rock.Middleware.t
243271
end

opium/test/dune

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
(tests
2-
(names middleware_allow_cors request response route)
2+
(names middleware_allow_cors middleware_cookie_session request response route)
33
(libraries alcotest alcotest-lwt lwt opium)
44
(package opium))

0 commit comments

Comments
 (0)