krabka_admin_ui/
session.rs1use 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
49pub const CSRF_HEADER_NAME: &str = "x-krabka-csrf";
55
56pub const CSRF_FIELD_NAME: &str = "csrf_token";
58
59#[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 #[must_use]
78 pub fn expose_for_form(&self) -> &str {
79 &self.0
80 }
81
82 #[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 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 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 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 #[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}