Skip to main content

krabka_admin_ui/
session.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    time::{Duration, Instant},
5};
6
7use parking_lot::RwLock;
8use uuid::Uuid;
9
10use crate::permissions::Capabilities;
11
12#[derive(Clone, PartialEq, Eq, Hash)]
13pub struct SessionId(String);
14
15impl SessionId {
16    #[must_use]
17    pub fn new() -> Self {
18        Self(Uuid::new_v4().to_string())
19    }
20
21    #[must_use]
22    pub fn expose_for_cookie(&self) -> &str {
23        &self.0
24    }
25}
26
27impl fmt::Debug for SessionId {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        formatter.write_str("SessionId(<redacted>)")
30    }
31}
32
33impl Default for SessionId {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl TryFrom<&str> for SessionId {
40    type Error = uuid::Error;
41
42    fn try_from(value: &str) -> Result<Self, Self::Error> {
43        let parsed_uuid = Uuid::parse_str(value)?;
44
45        Ok(Self(parsed_uuid.to_string()))
46    }
47}
48
49/// The header a JSON mutation carries its CSRF token in.
50///
51/// A browser cannot set this header on a cross-origin request without a
52/// preflight that this server never answers, and a page on another origin
53/// cannot read the token, so only the UI itself can send it.
54pub const CSRF_HEADER_NAME: &str = "x-krabka-csrf";
55
56/// The form field a rendered mutation form carries its CSRF token in.
57pub const CSRF_FIELD_NAME: &str = "csrf_token";
58
59/// The per-session secret that a mutation request must carry.
60///
61/// The session cookie alone does not prove that the operator asked for the
62/// mutation: a browser sends the cookie with a cross-origin form post too. Each
63/// rendered mutation form carries this token in a hidden field, and each JSON
64/// mutation carries it in the `x-krabka-csrf` header. A page on another origin
65/// cannot read the token, so it cannot forge the request.
66#[derive(Clone, PartialEq, Eq)]
67pub struct CsrfToken(String);
68
69impl CsrfToken {
70    #[must_use]
71    pub fn new() -> Self {
72        Self(Uuid::new_v4().to_string())
73    }
74
75    /// The token value to write into a rendered form or to compare a header
76    /// against.
77    #[must_use]
78    pub fn expose_for_form(&self) -> &str {
79        &self.0
80    }
81
82    /// Compares the token with a request-supplied value in constant time for
83    /// the value length, so a wrong token leaks no position information.
84    #[must_use]
85    pub fn matches(&self, candidate: &str) -> bool {
86        let expected = self.0.as_bytes();
87        let supplied = candidate.as_bytes();
88
89        if expected.len() != supplied.len() {
90            return false;
91        }
92
93        expected
94            .iter()
95            .zip(supplied)
96            .fold(0_u8, |difference, (expected, supplied)| {
97                difference | (expected ^ supplied)
98            })
99            == 0
100    }
101}
102
103impl Default for CsrfToken {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl fmt::Debug for CsrfToken {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter.write_str("CsrfToken(<redacted>)")
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct SessionUser {
117    pub username: String,
118    pub principal: String,
119}
120
121#[derive(Clone, PartialEq, Eq)]
122pub struct SessionCredentials {
123    password: String,
124}
125
126impl SessionCredentials {
127    #[must_use]
128    pub fn scram_sha512(password: String) -> Self {
129        Self { password }
130    }
131
132    #[must_use]
133    pub fn password(&self) -> &str {
134        &self.password
135    }
136}
137
138impl fmt::Debug for SessionCredentials {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        formatter.write_str("SessionCredentials(<redacted>)")
141    }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct SessionRecord {
146    pub user: SessionUser,
147    pub credentials: Option<SessionCredentials>,
148    /// What the operator's ACLs permit, derived once at login.
149    pub capabilities: Capabilities,
150    pub csrf_token: CsrfToken,
151    pub expires_at: Instant,
152}
153
154impl SessionRecord {
155    #[must_use]
156    pub fn is_expired(&self, now: Instant) -> bool {
157        now >= self.expires_at
158    }
159}
160
161pub struct SessionStore {
162    ttl: Duration,
163    sessions: RwLock<HashMap<SessionId, SessionRecord>>,
164}
165
166impl fmt::Debug for SessionStore {
167    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168        formatter
169            .debug_struct("SessionStore")
170            .field("ttl", &self.ttl)
171            .field("session_count", &self.sessions.read().len())
172            .finish()
173    }
174}
175
176impl SessionStore {
177    #[must_use]
178    pub fn new(ttl: Duration) -> Self {
179        Self {
180            ttl,
181            sessions: RwLock::new(HashMap::new()),
182        }
183    }
184
185    #[must_use]
186    pub const fn ttl(&self) -> Duration {
187        self.ttl
188    }
189
190    /// Creates a session that holds no broker credentials.
191    ///
192    /// Such a session cannot open a broker seam, so it reaches no broker data.
193    /// It carries every capability: no ACL read stands behind it, and the
194    /// broker refuses any operation it would reach anyway.
195    pub fn create(&self, user: SessionUser) -> SessionId {
196        self.create_record(user, None, Capabilities::all())
197    }
198
199    pub fn create_user(&self, username: &str, principal: &str) -> SessionId {
200        self.create(SessionUser {
201            username: username.to_string(),
202            principal: principal.to_string(),
203        })
204    }
205
206    pub fn create_authenticated(
207        &self,
208        user: SessionUser,
209        credentials: SessionCredentials,
210        capabilities: Capabilities,
211    ) -> SessionId {
212        self.create_record(user, Some(credentials), capabilities)
213    }
214
215    fn create_record(
216        &self,
217        user: SessionUser,
218        credentials: Option<SessionCredentials>,
219        capabilities: Capabilities,
220    ) -> SessionId {
221        let session_id = SessionId::new();
222        let now = Instant::now();
223        let session_record = SessionRecord {
224            user,
225            credentials,
226            capabilities,
227            csrf_token: CsrfToken::new(),
228            expires_at: now.checked_add(self.ttl).unwrap_or(now),
229        };
230
231        let mut sessions = self.sessions.write();
232        // An operator who signs in again leaves the older record behind. Drop
233        // every expired record here so that no abandoned session holds its
234        // password past the TTL.
235        sessions.retain(|_, record| !record.is_expired(now));
236        sessions.insert(session_id.clone(), session_record);
237
238        session_id
239    }
240
241    #[must_use]
242    pub fn get(&self, id: &SessionId) -> Option<SessionRecord> {
243        let now = Instant::now();
244        let record = self.sessions.read().get(id).cloned()?;
245
246        if !record.is_expired(now) {
247            return Some(record);
248        }
249
250        self.sessions.write().remove(id);
251        None
252    }
253
254    /// The number of records the store holds, expired ones included.
255    #[must_use]
256    pub fn len(&self) -> usize {
257        self.sessions.read().len()
258    }
259
260    #[must_use]
261    pub fn is_empty(&self) -> bool {
262        self.len() == 0
263    }
264
265    pub fn remove(&self, id: &SessionId) -> bool {
266        self.sessions.write().remove(id).is_some()
267    }
268}