git-ents.gitmain
⌘K
foforge
session.rs220 lines · 8.6 KB · rusthistorycomment on this file
1//! Hosted web sessions (`roots.web-session`): held only in this server's
2//! own process memory, never a session database or token table -- the
3//! same ban `model.account` states for authentication state generally.
4//!
5//! [`SessionStore`] is a plain `Mutex<HashMap<..>>`; there is no on-disk or
6//! external-database code path anywhere in this module for a session to
7//! reach, so "memory only" is a structural property of the type, not a
8//! configuration choice. A restarted process starts a new, empty
9//! [`SessionStore`], which is exactly why every state-changing request
10//! must additionally carry a per-session CSRF token: a stale cookie from a
11//! previous process names a session this one has never heard of, and is
12//! rejected as [`crate::Error::NoSession`] rather than silently trusted.
13
14use std::collections::HashMap;
15use std::sync::Mutex;
16
17/// The cookie name a browser carries a session id in.
18pub const COOKIE_NAME: &str = "ents_session";
19
20/// The form field (or header, for a JSON-style client) a state-changing
21/// request carries its CSRF token in.
22pub const CSRF_FIELD: &str = "csrf";
23
24/// The member a session proved control of a key for
25/// (`roots.web-signin`): nothing but the username and the public key —
26/// no secret is ever transmitted or stored.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SessionMember {
29 /// The enrolled member's username, as resolved at sign-in.
30 pub username: String,
31 /// The member's OpenSSH public key line, re-checked against the live
32 /// member list at every mutation (`roots.web-signin`).
33 pub key: String,
34}
35
36/// The current request's session id, inserted alongside [`Session`] by
37/// the session middleware — a login page needs it to bind a challenge to
38/// exactly this browser (`roots.web-signin`), and nothing else does: the
39/// id is the cookie's secret, so handlers receive it in this deliberate
40/// newtype rather than a bare `String` any format call could leak.
41#[derive(Debug, Clone)]
42pub struct SessionId(pub String);
43
44/// One held session: the CSRF token it was issued, plus — once the
45/// browser completes a sign-in (`roots.web-signin`) — the member it
46/// authenticated as. Under a `Trusted` access policy
47/// ([`crate::state::AccessPolicy`], the local root) `member` stays
48/// `None` for every session and grants nothing either way: the injected
49/// identity is the whole story there, so a session's only job is letting
50/// this server recognize "the same browser that fetched the form is the
51/// one submitting it," which the CSRF token proves.
52// @relation(roots.web-session, roots.web-signin, scope=file)
53#[derive(Debug, Clone)]
54pub struct Session {
55 /// The token a state-changing request must echo back.
56 pub csrf: String,
57 /// The member this session signed in as, if any.
58 pub member: Option<SessionMember>,
59}
60
61/// Server-memory-only session storage (`roots.web-session`).
62///
63/// # Examples
64///
65/// ```
66/// use ents_web::session::SessionStore;
67///
68/// let store = SessionStore::default();
69/// let (id, session) = store.create();
70/// assert_eq!(store.get(&id).expect("just created").csrf, session.csrf);
71/// assert!(store.get("no-such-id").is_none());
72/// ```
73// @relation(roots.web-session, scope=file)
74#[derive(Default)]
75pub struct SessionStore {
76 sessions: Mutex<HashMap<String, Session>>,
77}
78
79impl SessionStore {
80 /// Mint a new session with a fresh random id and CSRF token, and hold
81 /// it in memory.
82 ///
83 /// # Panics
84 ///
85 /// Never in practice: [`getrandom::fill`] only fails if the platform's
86 /// randomness source itself is unavailable, which every supported
87 /// target has.
88 #[must_use]
89 pub fn create(&self) -> (String, Session) {
90 let id = random_token();
91 let session = Session {
92 csrf: random_token(),
93 member: None,
94 };
95 #[expect(
96 clippy::unwrap_used,
97 reason = "a poisoned mutex means an earlier panic already unwound this process; \
98 there is no meaningful recovery for a session store, only a fresh restart"
99 )]
100 self.sessions
101 .lock()
102 .unwrap()
103 .insert(id.clone(), session.clone());
104 (id, session)
105 }
106
107 /// Look up a held session by id.
108 #[must_use]
109 pub fn get(&self, id: &str) -> Option<Session> {
110 #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
111 self.sessions.lock().unwrap().get(id).cloned()
112 }
113
114 /// Mark `id`'s session as signed in as `member`
115 /// (`roots.web-signin`), returning whether the session existed — a
116 /// consumed challenge naming a session this store has never held (a
117 /// restart between page load and sign-in) authenticates nothing.
118 pub fn authenticate(&self, id: &str, member: SessionMember) -> bool {
119 #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
120 let mut sessions = self.sessions.lock().unwrap();
121 match sessions.get_mut(id) {
122 Some(session) => {
123 session.member = Some(member);
124 true
125 }
126 None => false,
127 }
128 }
129
130 /// Drop `id`'s signed-in member, keeping the session itself — the
131 /// logout action, and the auth middleware's response to a member
132 /// revoked mid-session (`roots.web-signin`).
133 pub fn clear_member(&self, id: &str) {
134 #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
135 let mut sessions = self.sessions.lock().unwrap();
136 if let Some(session) = sessions.get_mut(id) {
137 session.member = None;
138 }
139 }
140}
141
142/// A random, URL-safe token: 32 hex characters from 16 random bytes.
143fn random_token() -> String {
144 let mut bytes = [0u8; 16];
145 #[expect(
146 clippy::expect_used,
147 reason = "getrandom only fails when the platform has no randomness source at all, which \
148 every target this crate ships to provides"
149 )]
150 getrandom::fill(&mut bytes).expect("platform randomness source is available");
151 bytes.iter().map(|b| format!("{b:02x}")).collect()
152}
153
154/// Parse `Cookie:` header bytes for [`COOKIE_NAME`]'s value.
155#[must_use]
156pub fn session_id_from_cookie_header(header: &str) -> Option<&str> {
157 header.split(';').find_map(|pair| {
158 let (name, value) = pair.trim().split_once('=')?;
159 (name == COOKIE_NAME).then_some(value)
160 })
161}
162
163/// Render a `Set-Cookie` header value for `id` -- `HttpOnly` and
164/// `SameSite=Strict` since this cookie is never read by page script and
165/// only ever needs to accompany same-site requests (`roots.web-session`'s
166/// CSRF requirement is the belt to this cookie's suspenders, not a
167/// replacement for it: `SameSite=Strict` alone would already block a
168/// cross-site POST, but a network intermediary or a future relaxation of
169/// that attribute must not silently remove the protection).
170///
171/// `secure` adds the `Secure` attribute — set by the hosted deployment,
172/// which only ever serves behind HTTPS, and never by local plain-HTTP
173/// loopback serving, where the attribute would make the browser drop the
174/// cookie entirely. Policy-driven, not deployment-sniffed: the caller
175/// passes what its [`crate::state::AccessPolicy`] implies.
176#[must_use]
177pub fn set_cookie_header(id: &str, secure: bool) -> String {
178 let secure = if secure { "; Secure" } else { "" };
179 format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict{secure}")
180}
181
182#[cfg(test)]
183mod tests {
184 #![allow(clippy::expect_used, reason = "unit test")]
185
186 use rstest::rstest;
187
188 use super::*;
189
190 #[rstest]
191 // @relation(roots.web-session, scope=function, role=Verifies)
192 fn a_fresh_store_never_recognizes_a_foreign_session_id() {
193 let a = SessionStore::default();
194 let b = SessionStore::default();
195 let (id, _) = a.create();
196 assert!(
197 b.get(&id).is_none(),
198 "a session minted by one store must not be recognized by another -- there is no \
199 shared backing store for either to consult"
200 );
201 }
202
203 #[rstest]
204 // @relation(roots.web-session, scope=function, role=Verifies)
205 fn cookie_header_round_trips_the_session_id() {
206 let header = set_cookie_header("abc123", false);
207 assert!(header.contains("HttpOnly"));
208 let raw_cookie = header.split(';').next().expect("at least one segment");
209 assert_eq!(session_id_from_cookie_header(raw_cookie), Some("abc123"));
210 }
211
212 #[rstest]
213 // @relation(roots.web-session, scope=function, role=Verifies)
214 fn two_sessions_never_share_a_csrf_token() {
215 let store = SessionStore::default();
216 let (_, first) = store.create();
217 let (_, second) = store.create();
218 assert_ne!(first.csrf, second.csrf);
219 }
220}