ri/auth/session.rs
1//! Copyright © 2025 Wenze Wei. All Rights Reserved.
2//!
3//! This file is part of Ri.
4//! The Ri project belongs to the Dunimd Team.
5//!
6//! Licensed under the Apache License, Version 2.0 (the "License");
7//! You may not use this file except in compliance with the License.
8//! You may obtain a copy of the License at
9//!
10//! http://www.apache.org/licenses/LICENSE-2.0
11//!
12//! Unless required by applicable law or agreed to in writing, software
13//! distributed under the License is distributed on an "AS IS" BASIS,
14//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15//! See the License for the specific language governing permissions and
16//! limitations under the License.
17
18//! Session management implementation for Ri.
19//!
20//! This module provides session management functionality, including:
21//! - Session creation and validation
22//! - Session expiration tracking
23//! - User session management
24//! - Session data storage
25//! - Expired session cleanup
26//!
27//! # Design Principles
28//! - **Security**: Session IDs are generated using UUID v4 for uniqueness
29//! - **Performance**: Efficient session lookup with hash maps
30//! - **Flexibility**: Supports custom session timeout and data storage
31//! - **Scalability**: Limits sessions per user to prevent resource exhaustion
32//! - **Convenience**: Automatically cleans up expired sessions
33//! - **Thread Safety**: Uses RwLock for concurrent access to session storage
34//!
35//! # Usage Examples
36//! ```rust
37//! // Create a session manager with 30-minute timeout
38//! let session_manager = RiSessionManager::new(1800);
39//!
40//! // Create a new session for a user
41//! let session_id = session_manager.create_session(
42//! "user123".to_string(),
43//! Some("192.168.1.1".to_string()),
44//! Some("Mozilla/5.0".to_string())
45//! ).await?;
46//!
47//! // Get session data
48//! let session = session_manager.get_session(&session_id).await?;
49//!
50//! // Update session data
51//! let mut data = FxHashMap::default();
52//! data.insert("theme".to_string(), "dark".to_string());
53//! session_manager.update_session(&session_id, data).await?;
54//!
55//! // Destroy a session
56//! session_manager.destroy_session(&session_id).await?;
57//!
58//! // Cleanup expired sessions
59//! let cleaned_count = session_manager.cleanup_expired().await?;
60//! ```
61
62#![allow(non_snake_case)]
63
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap as FxHashMap;
66use std::time::{SystemTime, UNIX_EPOCH};
67use uuid::Uuid;
68use crate::core::concurrent::RiShardedLock;
69
70#[cfg(feature = "pyo3")]
71use pyo3::PyResult;
72
73/// Maximum number of data entries in a session
74const MAX_SESSION_DATA_ENTRIES: usize = 100;
75
76/// Maximum length of a session data key
77const MAX_SESSION_DATA_KEY_LENGTH: usize = 256;
78
79/// Maximum length of a session data value
80const MAX_SESSION_DATA_VALUE_LENGTH: usize = 4096;
81
82/// Maximum length of user agent string
83#[allow(dead_code)]
84const MAX_USER_AGENT_LENGTH: usize = 1024;
85
86/// Session structure for tracking user sessions.
87///
88/// This struct represents a user session with metadata, expiration tracking,
89/// and custom data storage. Sessions are uniquely identified by UUIDs.
90#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass(get_all, set_all))]
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct RiSession {
93 /// Unique session identifier generated using UUID v4
94 pub id: String,
95 /// User ID associated with this session
96 pub user_id: String,
97 /// Session creation time as Unix timestamp
98 pub created_at: u64,
99 /// Last access time as Unix timestamp (updated on each access)
100 pub last_accessed: u64,
101 /// Session expiration time as Unix timestamp
102 pub expires_at: u64,
103 /// Custom key-value data associated with the session
104 pub data: FxHashMap<String, String>,
105 /// Client IP address from which the session was created
106 pub ip_address: Option<String>,
107 /// Client user agent string from which the session was created
108 pub user_agent: Option<String>,
109}
110
111#[cfg(feature = "pyo3")]
112#[pyo3::prelude::pymethods]
113impl RiSession {
114 #[new]
115 fn py_new(
116 id: Option<String>,
117 user_id: String,
118 created_at: Option<u64>,
119 last_accessed: Option<u64>,
120 expires_at: Option<u64>,
121 data: Option<FxHashMap<String, String>>,
122 ip_address: Option<String>,
123 user_agent: Option<String>,
124 ) -> Self {
125 let now = SystemTime::now()
126 .duration_since(UNIX_EPOCH)
127 .map_or(0, |d| d.as_secs());
128
129 Self {
130 id: id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
131 user_id,
132 created_at: created_at.unwrap_or(now),
133 last_accessed: last_accessed.unwrap_or(now),
134 expires_at: expires_at.unwrap_or(now + 28800),
135 data: data.unwrap_or_default(),
136 ip_address,
137 user_agent,
138 }
139 }
140}
141
142impl RiSession {
143 /// Creates a new session for a user.
144 ///
145 /// # Parameters
146 /// - `user_id`: ID of the user to create the session for
147 /// - `timeout_secs`: Session timeout in seconds
148 /// - `ip_address`: Optional client IP address
149 /// - `user_agent`: Optional client user agent
150 ///
151 /// # Returns
152 /// A new instance of `RiSession`
153 pub fn new(user_id: String, timeout_secs: u64, ip_address: Option<String>, user_agent: Option<String>) -> Self {
154 let now = SystemTime::now()
155 .duration_since(UNIX_EPOCH)
156 .map_or(0, |d| d.as_secs());
157
158 Self {
159 id: Uuid::new_v4().to_string(),
160 user_id,
161 created_at: now,
162 last_accessed: now,
163 expires_at: now + timeout_secs,
164 data: FxHashMap::default(),
165 ip_address,
166 user_agent,
167 }
168 }
169
170 /// Checks if the session has expired.
171 ///
172 /// # Returns
173 /// `true` if the session has expired, otherwise `false`
174 pub fn is_expired(&self) -> bool {
175 let now = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .map_or(0, |d| d.as_secs());
178 now > self.expires_at
179 }
180
181 /// Updates the last accessed time of the session.
182 ///
183 /// This method is called when a session is accessed to update its
184 /// last accessed timestamp, which can be used for session activity tracking.
185 pub fn touch(&mut self) {
186 let now = SystemTime::now()
187 .duration_since(UNIX_EPOCH)
188 .map_or(0, |d| d.as_secs());
189 self.last_accessed = now;
190 }
191
192 /// Extends the session expiration time.
193 ///
194 /// # Parameters
195 /// - `timeout_secs`: New timeout in seconds from the current time
196 pub fn extend(&mut self, timeout_secs: u64) {
197 let now = SystemTime::now()
198 .duration_since(UNIX_EPOCH)
199 .map_or(0, |d| d.as_secs());
200 self.expires_at = now + timeout_secs;
201 }
202
203 /// Gets a value from the session data.
204 ///
205 /// # Parameters
206 /// - `key`: Key to look up in the session data
207 ///
208 /// # Returns
209 /// `Some(&String)` if the key exists, otherwise `None`
210 pub fn get_data(&self, key: &str) -> Option<&String> {
211 self.data.get(key)
212 }
213
214 /// Sets a value in the session data.
215 ///
216 /// # Security
217 ///
218 /// This method validates:
219 /// - Maximum number of data entries (100)
220 /// - Maximum key length (256 characters)
221 /// - Maximum value length (4096 characters)
222 ///
223 /// # Parameters
224 /// - `key`: Key to set in the session data
225 /// - `value`: Value to associate with the key
226 ///
227 /// # Returns
228 /// `true` if the value was set, `false` if limits exceeded
229 pub fn set_data(&mut self, key: String, value: String) -> bool {
230 // Security: Check key length
231 if key.len() > MAX_SESSION_DATA_KEY_LENGTH {
232 log::warn!(
233 "[Ri.Session] Data key too long: {} chars (max {})",
234 key.len(), MAX_SESSION_DATA_KEY_LENGTH
235 );
236 return false;
237 }
238
239 // Security: Check value length
240 let safe_value = if value.len() > MAX_SESSION_DATA_VALUE_LENGTH {
241 log::warn!(
242 "[Ri.Session] Data value truncated: {} chars (max {})",
243 value.len(), MAX_SESSION_DATA_VALUE_LENGTH
244 );
245 value[..MAX_SESSION_DATA_VALUE_LENGTH].to_string()
246 } else {
247 value
248 };
249
250 // Security: Check maximum entries (only for new keys)
251 if !self.data.contains_key(&key) && self.data.len() >= MAX_SESSION_DATA_ENTRIES {
252 log::warn!(
253 "[Ri.Session] Maximum data entries reached: {} (max {})",
254 self.data.len(), MAX_SESSION_DATA_ENTRIES
255 );
256 return false;
257 }
258
259 self.data.insert(key, safe_value);
260 true
261 }
262
263 /// Removes a value from the session data.
264 ///
265 /// # Parameters
266 /// - `key`: Key to remove from the session data
267 ///
268 /// # Returns
269 /// `Some(String)` if the key existed and was removed, otherwise `None`
270 pub fn remove_data(&mut self, key: &str) -> Option<String> {
271 self.data.remove(key)
272 }
273}
274
275/// Session manager for handling user sessions.
276///
277/// This struct manages session creation, validation, and cleanup. It limits
278/// the number of sessions per user and automatically cleans up expired sessions.
279#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
280pub struct RiSessionManager {
281 sessions: RiShardedLock<String, RiSession>,
282 timeout_secs: u64,
283 max_sessions_per_user: usize,
284}
285
286impl RiSessionManager {
287 /// Creates a new session manager with the specified timeout.
288 ///
289 /// # Parameters
290 ///
291 /// - `timeout_secs`: Default session timeout in seconds
292 ///
293 /// # Returns
294 ///
295 /// A new instance of `RiSessionManager`
296 ///
297 /// # Notes
298 ///
299 /// Default maximum sessions per user is 5
300 pub fn new(timeout_secs: u64) -> Self {
301 Self {
302 sessions: RiShardedLock::with_default_shards(),
303 timeout_secs,
304 max_sessions_per_user: 5,
305 }
306 }
307
308 /// Creates a new session for a user.
309 ///
310 /// # Parameters
311 /// - `user_id`: ID of the user to create the session for
312 /// - `ip_address`: Optional client IP address
313 /// - `user_agent`: Optional client user agent
314 ///
315 /// # Returns
316 /// The ID of the newly created session
317 ///
318 /// # Notes
319 /// - If the user has reached the maximum number of sessions, the oldest session is removed
320 /// - Session ID is generated using UUID v4 for security
321 pub async fn create_session(&self, user_id: String, ip_address: Option<String>, user_agent: Option<String>) -> crate::core::RiResult<String> {
322 let user_sessions: Vec<(String, u64)> = self.sessions.collect_where(|_, s| s.user_id == user_id && !s.is_expired()).await
323 .into_iter()
324 .map(|s| (s.id.clone(), s.created_at))
325 .collect();
326
327 if user_sessions.len() >= self.max_sessions_per_user {
328 let mut sessions_with_time = user_sessions;
329 sessions_with_time.sort_by_key(|(_, t)| *t);
330
331 if let Some((oldest_id, _)) = sessions_with_time.first() {
332 self.sessions.remove(oldest_id).await;
333 }
334 }
335
336 let session = RiSession::new(user_id, self.timeout_secs, ip_address, user_agent);
337 let session_id = session.id.clone();
338 self.sessions.insert(session_id.clone(), session).await;
339
340 Ok(session_id)
341 }
342
343 /// Validates session security by checking IP and User-Agent.
344 ///
345 /// # Security
346 ///
347 /// This method helps prevent session hijacking by verifying that
348 /// the client's IP address and User-Agent match the original values.
349 ///
350 /// # Parameters
351 /// - `session_id`: ID of the session to validate
352 /// - `current_ip`: Current client IP address
353 /// - `current_user_agent`: Current client User-Agent
354 ///
355 /// # Returns
356 /// `true` if the session is valid, `false` if there's a mismatch
357 pub async fn validate_session_security(
358 &self,
359 session_id: &str,
360 current_ip: Option<&str>,
361 current_user_agent: Option<&str>,
362 ) -> crate::core::RiResult<bool> {
363 let session = self.sessions.get(session_id).await;
364
365 match session {
366 Some(s) => {
367 if s.is_expired() {
368 self.sessions.remove(session_id).await;
369 return Ok(false);
370 }
371
372 // Check IP address match (if both are set)
373 if let (Some(stored_ip), Some(current)) = (&s.ip_address, current_ip) {
374 if stored_ip != current {
375 log::warn!(
376 "[Ri.Session] IP address mismatch for session {}: expected {}, got {}",
377 session_id, stored_ip, current
378 );
379 return Ok(false);
380 }
381 }
382
383 // Check User-Agent match (if both are set)
384 if let (Some(stored_ua), Some(current)) = (&s.user_agent, current_user_agent) {
385 if stored_ua != current {
386 log::warn!(
387 "[Ri.Session] User-Agent mismatch for session {}: expected {}, got {}",
388 session_id, stored_ua, current
389 );
390 return Ok(false);
391 }
392 }
393
394 Ok(true)
395 }
396 None => Ok(false),
397 }
398 }
399
400 /// Regenerates a session ID to prevent session fixation attacks.
401 ///
402 /// # Security
403 ///
404 /// This method should be called after successful authentication to
405 /// prevent session fixation attacks. It creates a new session ID
406 /// while preserving the session data.
407 ///
408 /// # Parameters
409 /// - `old_session_id`: The current session ID
410 ///
411 /// # Returns
412 /// The new session ID, or None if the old session doesn't exist
413 pub async fn regenerate_session_id(&self, old_session_id: &str) -> crate::core::RiResult<Option<String>> {
414 let old_session = self.sessions.get(old_session_id).await;
415
416 match old_session {
417 Some(mut s) => {
418 if s.is_expired() {
419 self.sessions.remove(old_session_id).await;
420 return Ok(None);
421 }
422
423 // Remove old session
424 self.sessions.remove(old_session_id).await;
425
426 // Generate new session ID
427 let new_session_id = Uuid::new_v4().to_string();
428
429 // Update session with new ID
430 s.id = new_session_id.clone();
431 s.touch();
432
433 // Insert with new ID
434 self.sessions.insert(new_session_id.clone(), s).await;
435
436 log::info!(
437 "[Ri.Session] Regenerated session ID: {} -> {}",
438 old_session_id, new_session_id
439 );
440
441 Ok(Some(new_session_id))
442 }
443 None => Ok(None),
444 }
445 }
446
447 /// Gets a session by ID.
448 ///
449 /// # Parameters
450 /// - `session_id`: ID of the session to retrieve
451 ///
452 /// # Returns
453 /// `Some(RiSession)` if the session exists and is not expired, otherwise `None`
454 ///
455 /// # Notes
456 /// - Expired sessions are automatically removed and return `None`
457 /// - The session's last accessed time is updated when retrieved
458 pub async fn get_session(&self, session_id: &str) -> crate::core::RiResult<Option<RiSession>> {
459 let session = self.sessions.get(session_id).await;
460
461 match session {
462 Some(mut s) => {
463 if s.is_expired() {
464 self.sessions.remove(session_id).await;
465 Ok(None)
466 } else {
467 s.touch();
468 self.sessions.insert(session_id.to_string(), s.clone()).await;
469 Ok(Some(s))
470 }
471 }
472 None => Ok(None),
473 }
474 }
475
476 /// Updates a session's data.
477 ///
478 /// # Parameters
479 /// - `session_id`: ID of the session to update
480 /// - `data`: HashMap of key-value pairs to update in the session
481 ///
482 /// # Returns
483 /// `true` if the session was updated successfully, `false` if the session doesn't exist or is expired
484 ///
485 /// # Notes
486 /// - The session's last accessed time is updated when modified
487 pub async fn update_session(&self, session_id: &str, data: FxHashMap<String, String>) -> crate::core::RiResult<bool> {
488 let session = self.sessions.get(session_id).await;
489
490 match session {
491 Some(mut s) => {
492 if s.is_expired() {
493 self.sessions.remove(session_id).await;
494 Ok(false)
495 } else {
496 for (key, value) in data {
497 s.set_data(key, value);
498 }
499 s.touch();
500 self.sessions.insert(session_id.to_string(), s).await;
501 Ok(true)
502 }
503 }
504 None => Ok(false),
505 }
506 }
507
508 /// Extends a session's expiration time.
509 ///
510 /// # Parameters
511 /// - `session_id`: ID of the session to extend
512 ///
513 /// # Returns
514 /// `true` if the session was extended successfully, `false` if the session doesn't exist or is expired
515 pub async fn extend_session(&self, session_id: &str) -> crate::core::RiResult<bool> {
516 let session = self.sessions.get(session_id).await;
517
518 match session {
519 Some(mut s) => {
520 if s.is_expired() {
521 self.sessions.remove(session_id).await;
522 Ok(false)
523 } else {
524 s.extend(self.timeout_secs);
525 self.sessions.insert(session_id.to_string(), s).await;
526 Ok(true)
527 }
528 }
529 None => Ok(false),
530 }
531 }
532
533 /// Destroys a session by ID.
534 ///
535 /// # Parameters
536 /// - `session_id`: ID of the session to destroy
537 ///
538 /// # Returns
539 /// `true` if the session was destroyed successfully, `false` if the session doesn't exist
540 pub async fn destroy_session(&self, session_id: &str) -> crate::core::RiResult<bool> {
541 Ok(self.sessions.remove(session_id).await.is_some())
542 }
543
544 /// Destroys all sessions for a user.
545 ///
546 /// # Parameters
547 /// - `user_id`: ID of the user whose sessions to destroy
548 ///
549 /// # Returns
550 /// The number of sessions destroyed
551 pub async fn destroy_user_sessions(&self, user_id: &str) -> crate::core::RiResult<usize> {
552 let count = self.sessions.remove_where(|_, s| s.user_id == user_id).await;
553 Ok(count)
554 }
555
556 /// Gets all active sessions for a user.
557 ///
558 /// # Parameters
559 /// - `user_id`: ID of the user whose sessions to retrieve
560 ///
561 /// # Returns
562 /// A vector of active sessions for the user
563 pub async fn get_user_sessions(&self, user_id: &str) -> crate::core::RiResult<Vec<RiSession>> {
564 let user_sessions = self.sessions.collect_where(|_, s| s.user_id == user_id && !s.is_expired()).await;
565 Ok(user_sessions)
566 }
567
568 /// Cleans up all expired sessions.
569 ///
570 /// # Returns
571 /// The number of expired sessions cleaned up
572 pub async fn cleanup_expired(&self) -> crate::core::RiResult<usize> {
573 let count = self.sessions.remove_where(|_, s| s.is_expired()).await;
574 Ok(count)
575 }
576
577 /// Cleans up all sessions.
578 ///
579 /// This method removes all sessions, regardless of their expiration status.
580 pub async fn cleanup_all(&self) -> crate::core::RiResult<()> {
581 self.sessions.clear().await;
582 Ok(())
583 }
584
585 /// Gets the default session timeout.
586 ///
587 /// # Returns
588 /// The default session timeout in seconds
589 pub fn get_timeout(&self) -> u64 {
590 self.timeout_secs
591 }
592
593 /// Sets the default session timeout.
594 ///
595 /// # Parameters
596 /// - `timeout_secs`: New default session timeout in seconds
597 pub fn set_timeout(&mut self, timeout_secs: u64) {
598 self.timeout_secs = timeout_secs;
599 }
600}
601
602#[cfg(feature = "pyo3")]
603/// Python bindings for the Session Manager.
604///
605/// This module provides Python interface to Ri session management functionality,
606/// enabling Python applications to manage user sessions with expiration and data storage.
607///
608/// ## Supported Operations
609///
610/// - Session creation with user ID, IP address, and user agent tracking
611/// - Session retrieval and validation
612/// - Session data storage with key-value pairs
613/// - Session expiration management
614/// - Session cleanup for expired sessions
615///
616/// ## Python Usage Example
617///
618/// ```python
619/// from ri import RiSessionManager
620///
621/// # Create session manager with 30-minute timeout
622/// session_manager = RiSessionManager(1800)
623///
624/// # Create a new session
625/// session_id = session_manager.create_session(
626/// "user123",
627/// "192.168.1.1",
628/// "Mozilla/5.0"
629/// )
630///
631/// # Get session data
632/// session = session_manager.get_session(session_id)
633/// if session:
634/// print(f"Session created at: {session.created_at}")
635/// print(f"Session expires at: {session.expires_at}")
636///
637/// # Update session data
638/// session_manager.update_session(session_id, {"theme": "dark"})
639///
640/// # Extend session
641/// session_manager.extend_session(session_id)
642///
643/// # Destroy session when done
644/// session_manager.destroy_session(session_id)
645/// ```
646///
647/// ## Limitations
648///
649/// The current Python bindings do not support async session operations.
650/// For async scenarios, use the Rust API directly or implement async wrappers
651/// using Python's asyncio library.
652#[pyo3::prelude::pymethods]
653impl RiSessionManager {
654 #[new]
655 fn py_new(timeout_secs: u64) -> PyResult<Self> {
656 Ok(Self::new(timeout_secs))
657 }
658
659 #[pyo3(name = "create_session")]
660 fn create_session_impl(&self, user_id: String, ip_address: Option<String>, user_agent: Option<String>) -> PyResult<String> {
661 let rt = tokio::runtime::Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
662 rt.block_on(async {
663 self.create_session(user_id, ip_address, user_agent).await
664 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
665 })
666 }
667
668 #[pyo3(name = "get_session")]
669 fn get_session_impl(&self, session_id: String) -> PyResult<Option<RiSession>> {
670 let rt = tokio::runtime::Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
671 rt.block_on(async {
672 self.get_session(&session_id).await
673 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
674 })
675 }
676
677 #[pyo3(name = "update_session")]
678 fn update_session_impl(&self, session_id: String, data: FxHashMap<String, String>) -> PyResult<bool> {
679 let rt = tokio::runtime::Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
680 rt.block_on(async {
681 self.update_session(&session_id, data).await
682 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
683 })
684 }
685
686 #[pyo3(name = "destroy_session")]
687 fn destroy_session_impl(&self, session_id: String) -> PyResult<bool> {
688 let rt = tokio::runtime::Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
689 rt.block_on(async {
690 self.destroy_session(&session_id).await
691 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
692 })
693 }
694
695 #[pyo3(name = "extend_session")]
696 fn extend_session_impl(&self, session_id: String) -> PyResult<bool> {
697 let rt = tokio::runtime::Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
698 rt.block_on(async {
699 self.extend_session(&session_id).await
700 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
701 })
702 }
703
704 #[pyo3(name = "cleanup_expired")]
705 fn cleanup_expired_impl(&self) -> PyResult<usize> {
706 let rt = tokio::runtime::Runtime::new().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
707 rt.block_on(async {
708 self.cleanup_expired().await
709 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
710 })
711 }
712
713 #[pyo3(name = "get_timeout")]
714 fn get_timeout_impl(&self) -> u64 {
715 self.get_timeout()
716 }
717}