1use std::collections::BTreeMap;
18
19use hmac::{Hmac, Mac};
20use sha2::Sha256;
21use sutegi_json::Json;
22use sutegi_web::{Request, Response};
23
24type HmacSha256 = Hmac<Sha256>;
25
26pub struct Sessions {
28 secret: Vec<u8>,
29 cookie: String,
30 secure: bool,
31 max_age: Option<u64>,
32}
33
34impl Sessions {
35 pub fn new(secret: &[u8]) -> Sessions {
37 Sessions {
38 secret: secret.to_vec(),
39 cookie: "sutegi_session".to_string(),
40 secure: true,
41 max_age: Some(86_400),
42 }
43 }
44
45 pub fn cookie_name(mut self, name: &str) -> Sessions {
46 self.cookie = name.to_string();
47 self
48 }
49
50 pub fn insecure(mut self) -> Sessions {
52 self.secure = false;
53 self
54 }
55
56 pub fn max_age(mut self, secs: Option<u64>) -> Sessions {
57 self.max_age = secs;
58 self
59 }
60
61 fn sign(&self, msg: &[u8]) -> String {
62 let mut mac =
63 HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts any key length");
64 mac.update(msg);
65 to_hex(&mac.finalize().into_bytes())
66 }
67
68 pub fn token(&self, value: &str) -> String {
71 let hex = to_hex(value.as_bytes());
72 format!("{}.{}", hex, self.sign(value.as_bytes()))
73 }
74
75 pub fn verify_token(&self, token: &str) -> Option<String> {
77 let (hex, sig) = token.split_once('.')?;
78 let bytes = from_hex(hex)?;
79 if constant_time_eq(self.sign(&bytes).as_bytes(), sig.as_bytes()) {
80 String::from_utf8(bytes).ok()
81 } else {
82 None
83 }
84 }
85
86 pub fn load(&self, req: &Request) -> Session {
89 if let Some(raw) = req.cookie(&self.cookie) {
90 if let Some((payload_hex, sig)) = raw.split_once('.') {
91 if let Some(bytes) = from_hex(payload_hex) {
92 if constant_time_eq(self.sign(&bytes).as_bytes(), sig.as_bytes()) {
93 if let Ok(s) = std::str::from_utf8(&bytes) {
94 if let Ok(Json::Obj(map)) = Json::parse(s) {
95 return Session {
96 data: map,
97 dirty: false,
98 };
99 }
100 }
101 }
102 }
103 }
104 }
105 Session {
106 data: BTreeMap::new(),
107 dirty: false,
108 }
109 }
110
111 pub fn save(&self, session: &Session, resp: Response) -> Response {
113 let payload = Json::Obj(session.data.clone()).to_string();
114 let payload_hex = to_hex(payload.as_bytes());
115 let sig = self.sign(payload.as_bytes());
116 let mut cookie = format!(
117 "{}={}.{}; Path=/; HttpOnly; SameSite=Lax",
118 self.cookie, payload_hex, sig
119 );
120 if self.secure {
121 cookie.push_str("; Secure");
122 }
123 if let Some(age) = self.max_age {
124 cookie.push_str(&format!("; Max-Age={}", age));
125 }
126 resp.with_header("set-cookie", &cookie)
127 }
128
129 pub fn clear(&self, resp: Response) -> Response {
131 resp.with_header(
132 "set-cookie",
133 &format!("{}=; Path=/; Max-Age=0", self.cookie),
134 )
135 }
136}
137
138pub struct Session {
140 data: BTreeMap<String, Json>,
141 dirty: bool,
142}
143
144impl Session {
145 pub fn get(&self, key: &str) -> Option<&Json> {
146 self.data.get(key)
147 }
148 pub fn get_str(&self, key: &str) -> Option<&str> {
149 self.data.get(key).and_then(|j| j.as_str())
150 }
151 pub fn set(&mut self, key: &str, value: Json) {
152 self.data.insert(key.to_string(), value);
153 self.dirty = true;
154 }
155 pub fn remove(&mut self, key: &str) {
156 self.data.remove(key);
157 self.dirty = true;
158 }
159 pub fn clear(&mut self) {
160 self.data.clear();
161 self.dirty = true;
162 }
163 pub fn is_empty(&self) -> bool {
164 self.data.is_empty()
165 }
166 pub fn is_dirty(&self) -> bool {
168 self.dirty
169 }
170}
171
172fn to_hex(bytes: &[u8]) -> String {
173 let mut s = String::with_capacity(bytes.len() * 2);
174 for b in bytes {
175 s.push_str(&format!("{:02x}", b));
176 }
177 s
178}
179
180fn from_hex(s: &str) -> Option<Vec<u8>> {
181 if s.len() % 2 != 0 {
182 return None;
183 }
184 (0..s.len())
185 .step_by(2)
186 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
187 .collect()
188}
189
190fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
191 if a.len() != b.len() {
192 return false;
193 }
194 let mut diff = 0u8;
195 for i in 0..a.len() {
196 diff |= a[i] ^ b[i];
197 }
198 diff == 0
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 fn req_with_cookie(name: &str, value: &str) -> Request {
206 Request {
207 method: sutegi_web::Method::Get,
208 path: "/".into(),
209 query: String::new(),
210 version: "HTTP/1.1".into(),
211 headers: vec![("Cookie".into(), format!("{}={}", name, value))],
212 body: vec![],
213 peer: None,
214 }
215 }
216
217 fn cookie_value(resp: &Response, _name: &str) -> String {
219 let header = resp
220 .headers
221 .iter()
222 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
223 .map(|(_, v)| v.clone())
224 .unwrap();
225 let kv = header.split(';').next().unwrap();
226 kv.split_once('=').unwrap().1.to_string()
227 }
228
229 #[test]
230 fn roundtrip_and_tamper() {
231 let s = Sessions::new(b"super-secret-key").insecure();
232 let mut sess = s.load(&req_with_cookie("x", "")); assert!(sess.is_empty());
234 sess.set("user_id", Json::int(42));
235
236 let resp = s.save(&sess, Response::new(200));
237 let cookie = cookie_value(&resp, "sutegi_session");
238
239 let reloaded = s.load(&req_with_cookie("sutegi_session", &cookie));
241 assert_eq!(reloaded.get("user_id").and_then(Json::as_i64), Some(42));
242
243 let tampered = cookie.replacen(|c: char| c.is_ascii_hexdigit(), "0", 1);
245 let after = s.load(&req_with_cookie("sutegi_session", &tampered));
246 assert!(after.is_empty() || after.get("user_id").and_then(Json::as_i64) != Some(42));
247 }
248
249 #[test]
250 fn token_sign_verify() {
251 let s = Sessions::new(b"k");
252 let t = s.token("user:42");
253 assert_eq!(s.verify_token(&t).as_deref(), Some("user:42"));
254 assert!(s.verify_token("deadbeef.badsig").is_none());
255 }
256
257 #[test]
258 fn save_sets_cookie_attributes() {
259 let s = Sessions::new(b"secret"); let mut sess = s.load(&req_with_cookie("x", ""));
261 sess.set("k", Json::int(1));
262 let header = s
263 .save(&sess, Response::new(200))
264 .headers
265 .iter()
266 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
267 .map(|(_, v)| v.clone())
268 .unwrap();
269 assert!(header.contains("HttpOnly"));
270 assert!(header.contains("SameSite=Lax"));
271 assert!(header.contains("Secure"));
272 assert!(header.contains("Max-Age=86400"));
273 assert!(header.starts_with("sutegi_session="));
274 }
275
276 #[test]
277 fn insecure_and_no_max_age_omit_attributes() {
278 let s = Sessions::new(b"secret").insecure().max_age(None);
279 let header = s
280 .save(
281 &Session {
282 data: BTreeMap::new(),
283 dirty: false,
284 },
285 Response::new(200),
286 )
287 .headers
288 .iter()
289 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
290 .map(|(_, v)| v.clone())
291 .unwrap();
292 assert!(!header.contains("Secure"));
293 assert!(!header.contains("Max-Age"));
294 }
295
296 #[test]
297 fn custom_cookie_name_roundtrips() {
298 let s = Sessions::new(b"k").cookie_name("sid").insecure();
299 let mut sess = s.load(&req_with_cookie("sid", ""));
300 sess.set("user", Json::str("eneko"));
301 let cookie = cookie_value(&s.save(&sess, Response::new(200)), "sid");
302 let reloaded = s.load(&req_with_cookie("sid", &cookie));
303 assert_eq!(reloaded.get_str("user"), Some("eneko"));
304 assert!(s
306 .load(&req_with_cookie("sutegi_session", &cookie))
307 .is_empty());
308 }
309
310 #[test]
311 fn clear_expires_cookie() {
312 let s = Sessions::new(b"k");
313 let header = s
314 .clear(Response::new(200))
315 .headers
316 .iter()
317 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
318 .map(|(_, v)| v.clone())
319 .unwrap();
320 assert!(header.contains("Max-Age=0"));
321 }
322
323 #[test]
324 fn session_mutation_tracks_dirty() {
325 let mut sess = Session {
326 data: BTreeMap::new(),
327 dirty: false,
328 };
329 assert!(!sess.is_dirty());
330 sess.set("a", Json::int(1));
331 assert!(sess.is_dirty());
332 assert_eq!(sess.get("a").and_then(Json::as_i64), Some(1));
333 sess.remove("a");
334 assert!(sess.get("a").is_none());
335 sess.set("b", Json::int(2));
336 sess.clear();
337 assert!(sess.is_empty());
338 }
339
340 #[test]
341 fn empty_payload_token_verification_fails_cleanly() {
342 let s = Sessions::new(b"k");
343 assert!(s.verify_token("nosig").is_none());
345 assert!(s.verify_token("abc.sig").is_none());
347 }
348}