1#![deny(missing_docs)]
48
49use async_trait::async_trait;
50use base64::engine::general_purpose::URL_SAFE_NO_PAD;
51use base64::Engine;
52use churust_core::{Error, SessionStore, SESSION_ID_KEY};
53use std::collections::BTreeMap;
54use std::sync::Arc;
55
56const ID_BYTES: usize = 32;
58const ID_CHARS: usize = 43;
60const DEFAULT_TTL: u64 = 24 * 60 * 60;
62const DEFAULT_PREFIX: &str = "churust:session:";
64
65#[async_trait]
71trait Backend: Send + Sync + 'static {
72 async fn get(&self, key: &str) -> Option<String>;
73 async fn set(&self, key: &str, value: &str, ttl: u64);
74 async fn touch(&self, key: &str, ttl: u64);
75 async fn del(&self, key: &str) -> bool;
78}
79
80#[derive(Clone)]
82pub struct RedisStore {
83 backend: Arc<dyn Backend>,
84 prefix: String,
85 ttl: u64,
86 sliding: bool,
87}
88
89impl std::fmt::Debug for RedisStore {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("RedisStore")
92 .field("prefix", &self.prefix)
93 .field("ttl", &self.ttl)
94 .field("sliding", &self.sliding)
95 .finish_non_exhaustive()
96 }
97}
98
99impl RedisStore {
100 pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
113 let client = redis::Client::open(url)?;
114 let connection = client.get_multiplexed_async_connection().await?;
117 Ok(Self::from_backend(RedisBackend {
118 client,
119 connection: tokio::sync::Mutex::new(Some(connection)),
120 }))
121 }
122
123 pub fn from_client(client: redis::Client) -> Self {
128 Self::from_backend(RedisBackend {
129 client,
130 connection: tokio::sync::Mutex::new(None),
131 })
132 }
133
134 fn from_backend(backend: impl Backend) -> Self {
135 Self {
136 backend: Arc::new(backend),
137 prefix: DEFAULT_PREFIX.to_string(),
138 ttl: DEFAULT_TTL,
139 sliding: true,
140 }
141 }
142
143 pub fn ttl(mut self, secs: u64) -> Self {
149 assert!(secs > 0, "session ttl must be at least one second");
150 self.ttl = secs;
151 self
152 }
153
154 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
156 self.prefix = prefix.into();
157 self
158 }
159
160 pub fn sliding(mut self, yes: bool) -> Self {
167 self.sliding = yes;
168 self
169 }
170
171 fn key(&self, id: &str) -> String {
172 format!("{}{}", self.prefix, id)
173 }
174}
175
176fn new_id() -> String {
184 let mut bytes = [0u8; ID_BYTES];
185 getrandom::fill(&mut bytes).expect("the OS must be able to supply randomness for a session id");
186 URL_SAFE_NO_PAD.encode(bytes)
187}
188
189fn is_well_formed(raw: &str) -> bool {
194 raw.len() == ID_CHARS
195 && raw
196 .bytes()
197 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
198}
199
200#[async_trait]
201impl SessionStore for RedisStore {
202 async fn load(&self, raw: &str) -> Option<BTreeMap<String, String>> {
203 if !is_well_formed(raw) {
204 return None;
205 }
206 let key = self.key(raw);
207 let stored = self.backend.get(&key).await?;
208 let mut data: BTreeMap<String, String> = serde_json::from_str(&stored).ok()?;
209
210 if self.sliding {
211 self.backend.touch(&key, self.ttl).await;
212 }
213
214 data.insert(SESSION_ID_KEY.to_string(), raw.to_string());
217 Some(data)
218 }
219
220 async fn store(
221 &self,
222 data: &BTreeMap<String, String>,
223 previous: Option<&str>,
224 ) -> Result<Option<String>, Error> {
225 if data.is_empty() {
229 if let Some(old) = previous.filter(|raw| is_well_formed(raw)) {
230 if !self.backend.del(&self.key(old)).await {
231 return Err(revocation_failed());
232 }
233 }
234 return Ok(None);
235 }
236
237 let carried = data.get(SESSION_ID_KEY).filter(|id| is_well_formed(id));
238 let id = match carried {
239 Some(id) => id.clone(),
240 None => new_id(),
241 };
242
243 if let Some(old) = previous.filter(|raw| is_well_formed(raw) && *raw != id) {
248 if !self.backend.del(&self.key(old)).await {
254 return Err(revocation_failed());
255 }
256 }
257
258 let mut payload = data.clone();
261 payload.remove(SESSION_ID_KEY);
262 let Ok(encoded) = serde_json::to_string(&payload) else {
263 return Ok(None);
264 };
265
266 self.backend.set(&self.key(&id), &encoded, self.ttl).await;
271 Ok(Some(id))
272 }
273}
274
275fn revocation_failed() -> Error {
282 Error::internal("the session could not be ended; please try again")
283}
284
285struct RedisBackend {
287 client: redis::Client,
288 connection: tokio::sync::Mutex<Option<redis::aio::MultiplexedConnection>>,
291}
292
293impl RedisBackend {
294 async fn run<T: redis::FromRedisValue>(&self, cmd: &redis::Cmd) -> Option<T> {
307 for attempt in 0..2 {
308 let mut guard = self.connection.lock().await;
309 if guard.is_none() {
310 match self.client.get_multiplexed_async_connection().await {
311 Ok(fresh) => *guard = Some(fresh),
312 Err(_) => return None,
313 }
314 }
315 let mut conn = guard.as_ref()?.clone();
318 drop(guard);
319
320 match cmd.query_async::<T>(&mut conn).await {
321 Ok(value) => return Some(value),
322 Err(_) if attempt == 0 => {
323 *self.connection.lock().await = None;
325 }
326 Err(_) => return None,
327 }
328 }
329 None
330 }
331}
332
333#[async_trait]
334impl Backend for RedisBackend {
335 async fn get(&self, key: &str) -> Option<String> {
336 self.run::<Option<String>>(redis::cmd("GET").arg(key))
337 .await
338 .flatten()
339 }
340
341 async fn set(&self, key: &str, value: &str, ttl: u64) {
342 let _ = self
345 .run::<()>(redis::cmd("SET").arg(key).arg(value).arg("EX").arg(ttl))
346 .await;
347 }
348
349 async fn touch(&self, key: &str, ttl: u64) {
350 let _ = self.run::<()>(redis::cmd("EXPIRE").arg(key).arg(ttl)).await;
351 }
352
353 async fn del(&self, key: &str) -> bool {
354 self.run::<()>(redis::cmd("DEL").arg(key)).await.is_some()
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use std::sync::Mutex;
367 use std::time::{Duration, Instant};
368
369 #[derive(Default)]
372 struct MemoryBackend {
373 entries: Mutex<BTreeMap<String, (String, Instant)>>,
374 }
375
376 impl MemoryBackend {
377 fn live(&self, key: &str) -> Option<String> {
378 let entries = self.entries.lock().unwrap();
379 let (value, expires) = entries.get(key)?;
380 (*expires > Instant::now()).then(|| value.clone())
381 }
382
383 fn len(&self) -> usize {
384 let now = Instant::now();
385 self.entries
386 .lock()
387 .unwrap()
388 .values()
389 .filter(|(_, expires)| *expires > now)
390 .count()
391 }
392 }
393
394 #[async_trait]
395 impl Backend for Arc<MemoryBackend> {
396 async fn get(&self, key: &str) -> Option<String> {
397 self.live(key)
398 }
399
400 async fn set(&self, key: &str, value: &str, ttl: u64) {
401 self.entries.lock().unwrap().insert(
402 key.to_string(),
403 (value.to_string(), Instant::now() + Duration::from_secs(ttl)),
404 );
405 }
406
407 async fn touch(&self, key: &str, ttl: u64) {
408 if let Some(entry) = self.entries.lock().unwrap().get_mut(key) {
409 entry.1 = Instant::now() + Duration::from_secs(ttl);
410 }
411 }
412
413 async fn del(&self, key: &str) -> bool {
414 self.entries.lock().unwrap().remove(key);
415 true
416 }
417 }
418
419 fn store() -> (RedisStore, Arc<MemoryBackend>) {
420 let backend = Arc::new(MemoryBackend::default());
421 let store = RedisStore {
422 backend: Arc::new(backend.clone()),
423 prefix: DEFAULT_PREFIX.to_string(),
424 ttl: DEFAULT_TTL,
425 sliding: true,
426 };
427 (store, backend)
428 }
429
430 struct RefusesDeletes(Arc<MemoryBackend>);
437
438 #[async_trait]
439 impl Backend for RefusesDeletes {
440 async fn get(&self, key: &str) -> Option<String> {
441 self.0.get(key).await
442 }
443
444 async fn set(&self, key: &str, value: &str, ttl: u64) {
445 self.0.set(key, value, ttl).await
446 }
447
448 async fn touch(&self, key: &str, ttl: u64) {
449 self.0.touch(key, ttl).await
450 }
451
452 async fn del(&self, _key: &str) -> bool {
453 false
454 }
455 }
456
457 fn refusing_store() -> (RedisStore, Arc<MemoryBackend>) {
458 let backend = Arc::new(MemoryBackend::default());
459 let store = RedisStore {
460 backend: Arc::new(RefusesDeletes(backend.clone())),
461 prefix: DEFAULT_PREFIX.to_string(),
462 ttl: DEFAULT_TTL,
463 sliding: true,
464 };
465 (store, backend)
466 }
467
468 fn data(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
469 pairs
470 .iter()
471 .map(|(k, v)| (k.to_string(), v.to_string()))
472 .collect()
473 }
474
475 #[tokio::test]
476 async fn a_session_round_trips_through_the_backend() {
477 let (store, _) = store();
478 let id = store
479 .store(&data(&[("user", "ana")]), None)
480 .await
481 .expect("the write must succeed")
482 .expect("a new session gets an id");
483
484 let back = store.load(&id).await.expect("it must load again");
485 assert_eq!(back.get("user").map(String::as_str), Some("ana"));
486 }
487
488 #[tokio::test]
489 async fn the_cookie_carries_only_an_identifier() {
490 let (store, backend) = store();
491 let id = store
492 .store(&data(&[("user", "ana"), ("secret", "hunter2")]), None)
493 .await
494 .unwrap()
495 .unwrap();
496
497 assert!(is_well_formed(&id));
498 assert!(
499 !id.contains("ana") && !id.contains("hunter2"),
500 "session contents must not travel in the cookie: {id}"
501 );
502 let raw = backend.live(&format!("{DEFAULT_PREFIX}{id}")).unwrap();
503 assert!(raw.contains("hunter2"), "the value lives server side");
504 }
505
506 #[tokio::test]
507 async fn identifiers_are_unpredictable_and_distinct() {
508 let (store, _) = store();
509 let mut seen = std::collections::HashSet::new();
510 for _ in 0..256 {
511 let id = store
512 .store(&data(&[("k", "v")]), None)
513 .await
514 .unwrap()
515 .unwrap();
516 assert_eq!(id.len(), ID_CHARS);
517 assert!(seen.insert(id), "a session id was reused");
518 }
519 }
520
521 #[tokio::test]
522 async fn logging_out_deletes_the_record() {
523 let (store, backend) = store();
524 let id = store
525 .store(&data(&[("user", "ana")]), None)
526 .await
527 .unwrap()
528 .unwrap();
529 assert_eq!(backend.len(), 1);
530
531 let reissued = store.store(&BTreeMap::new(), Some(&id)).await;
533 assert!(
534 matches!(reissued, Ok(None)),
535 "a completed logout has no new cookie to set"
536 );
537 assert_eq!(
538 backend.len(),
539 0,
540 "the record must be gone, not merely stale"
541 );
542 assert!(
543 store.load(&id).await.is_none(),
544 "a cookie copied before logout must stop working"
545 );
546 }
547
548 #[tokio::test]
549 async fn a_logout_the_backend_refused_is_not_reported_as_a_logout() {
550 let (store, backend) = refusing_store();
551 let id = store
552 .store(&data(&[("user", "ana")]), None)
553 .await
554 .unwrap()
555 .unwrap();
556
557 let outcome = store.store(&BTreeMap::new(), Some(&id)).await;
558
559 assert!(
560 store.load(&id).await.is_some(),
561 "the stand-in must have kept the record, or this proves nothing"
562 );
563 assert_eq!(backend.len(), 1, "the record survived the failed delete");
564 assert!(
565 outcome.is_err(),
566 "a session that is still valid must not be reported as withdrawn"
567 );
568 }
569
570 #[tokio::test]
571 async fn a_rotation_whose_withdrawal_failed_is_refused_too() {
572 let (store, _) = refusing_store();
577 let first = store
578 .store(&data(&[("cart", "3")]), None)
579 .await
580 .unwrap()
581 .unwrap();
582
583 let mut rotated = store.load(&first).await.unwrap();
584 rotated.remove(SESSION_ID_KEY);
585 rotated.insert("user".into(), "ana".into());
586
587 assert!(
588 store.store(&rotated, Some(&first)).await.is_err(),
589 "the pre-login identifier still resolves, so the rotation failed"
590 );
591 assert!(
592 store.load(&first).await.is_some(),
593 "the stand-in must have kept the record, or this proves nothing"
594 );
595 }
596
597 #[tokio::test]
598 async fn rotating_mints_a_new_id_and_withdraws_the_old_one() {
599 let (store, backend) = store();
600 let first = store
601 .store(&data(&[("cart", "3")]), None)
602 .await
603 .unwrap()
604 .unwrap();
605
606 let mut rotated = store.load(&first).await.unwrap();
608 rotated.remove(SESSION_ID_KEY);
609 rotated.insert("user".into(), "ana".into());
610
611 let second = store.store(&rotated, Some(&first)).await.unwrap().unwrap();
612 assert_ne!(first, second, "a rotated session must change identifier");
613 assert!(
614 store.load(&first).await.is_none(),
615 "the pre-login identifier must not still resolve"
616 );
617 let carried = store.load(&second).await.unwrap();
618 assert_eq!(carried.get("cart").map(String::as_str), Some("3"));
619 assert_eq!(carried.get("user").map(String::as_str), Some("ana"));
620 assert_eq!(backend.len(), 1, "the old record was not left behind");
621 }
622
623 #[tokio::test]
624 async fn an_unchanged_session_keeps_its_identifier() {
625 let (store, backend) = store();
626 let id = store
627 .store(&data(&[("user", "ana")]), None)
628 .await
629 .unwrap()
630 .unwrap();
631
632 let mut loaded = store.load(&id).await.unwrap();
633 loaded.insert("theme".into(), "dark".into());
634 let again = store.store(&loaded, Some(&id)).await.unwrap().unwrap();
635
636 assert_eq!(id, again, "an ordinary write must not rotate the session");
637 assert_eq!(backend.len(), 1);
638 }
639
640 #[tokio::test]
641 async fn a_malformed_identifier_is_refused_without_a_lookup() {
642 let (store, _) = store();
643 for hostile in [
644 "",
645 "short",
646 "../../etc/passwd",
647 "churust:session:*",
648 "a b",
649 &"x".repeat(4096),
650 ] {
651 assert!(!is_well_formed(hostile), "{hostile:?} should not be valid");
652 assert!(store.load(hostile).await.is_none());
653 }
654 }
655
656 #[tokio::test]
657 async fn an_unknown_identifier_loads_nothing() {
658 let (store, _) = store();
659 assert!(store.load(&new_id()).await.is_none());
660 }
661
662 #[tokio::test]
663 async fn the_stored_value_does_not_repeat_the_identifier() {
664 let (store, backend) = store();
665 let id = store
666 .store(&data(&[("user", "ana")]), None)
667 .await
668 .unwrap()
669 .unwrap();
670 let raw = backend.live(&format!("{DEFAULT_PREFIX}{id}")).unwrap();
671 assert!(
672 !raw.contains(SESSION_ID_KEY),
673 "the key is the identifier; storing it twice invites disagreement: {raw}"
674 );
675 }
676
677 #[tokio::test]
678 async fn expiry_removes_a_session() {
679 let (mut store, _) = store();
680 store.ttl = 1;
681 let id = store
682 .store(&data(&[("user", "ana")]), None)
683 .await
684 .unwrap()
685 .unwrap();
686 assert!(store.load(&id).await.is_some());
687
688 tokio::time::sleep(Duration::from_millis(1100)).await;
689 assert!(
690 store.load(&id).await.is_none(),
691 "a session past its ttl must not load"
692 );
693 }
694
695 #[tokio::test]
696 async fn sliding_expiry_extends_on_read() {
697 let (mut store, backend) = store();
698 store.ttl = 2;
699 let id = store
700 .store(&data(&[("user", "ana")]), None)
701 .await
702 .unwrap()
703 .unwrap();
704
705 for _ in 0..3 {
707 tokio::time::sleep(Duration::from_millis(800)).await;
708 assert!(store.load(&id).await.is_some());
709 }
710 assert_eq!(backend.len(), 1);
711 }
712
713 #[tokio::test]
714 async fn absolute_expiry_does_not_extend_on_read() {
715 let (mut store, _) = store();
716 store.ttl = 1;
717 store.sliding = false;
718 let id = store
719 .store(&data(&[("user", "ana")]), None)
720 .await
721 .unwrap()
722 .unwrap();
723
724 tokio::time::sleep(Duration::from_millis(600)).await;
725 assert!(store.load(&id).await.is_some());
726 tokio::time::sleep(Duration::from_millis(600)).await;
727 assert!(
728 store.load(&id).await.is_none(),
729 "reading must not have extended the deadline"
730 );
731 }
732
733 #[tokio::test]
734 async fn a_custom_prefix_is_applied() {
735 let (mut store, backend) = store();
736 store.prefix = "app:sess:".into();
737 let id = store
738 .store(&data(&[("user", "ana")]), None)
739 .await
740 .unwrap()
741 .unwrap();
742 assert!(backend.live(&format!("app:sess:{id}")).is_some());
743 }
744
745 #[test]
746 #[should_panic(expected = "at least one second")]
747 fn a_zero_ttl_is_refused() {
748 let (store, _) = store();
749 let _ = store.ttl(0);
750 }
751}