diff --git a/crates/scrybe-cache/src/lib.rs b/crates/scrybe-cache/src/lib.rs index 1557e6c..be7ee9c 100644 --- a/crates/scrybe-cache/src/lib.rs +++ b/crates/scrybe-cache/src/lib.rs @@ -23,10 +23,13 @@ pub mod client; /// Nonce validation for replay attack prevention. pub mod nonce; +/// Rate limiting with token bucket algorithm. +pub mod rate_limit; /// Session cache management. pub mod session; // Re-export main types pub use client::RedisClient; pub use nonce::NonceValidator; +pub use rate_limit::RateLimiter; pub use session::SessionCache; diff --git a/crates/scrybe-cache/src/rate_limit.rs b/crates/scrybe-cache/src/rate_limit.rs new file mode 100644 index 0000000..7db45aa --- /dev/null +++ b/crates/scrybe-cache/src/rate_limit.rs @@ -0,0 +1,116 @@ +//! Rate limiting using Redis token bucket algorithm. + +use crate::client::RedisClient; +use redis::AsyncCommands; +use scrybe_core::ScrybeError; + +/// Redis-backed rate limiter using token bucket algorithm. +pub struct RateLimiter { + client: RedisClient, + max_requests: usize, + window_seconds: usize, +} + +impl RateLimiter { + /// Create a new rate limiter. + /// + /// # Arguments + /// + /// * `client` - Redis client instance + /// * `max_requests` - Maximum requests allowed in the window + /// * `window_seconds` - Time window in seconds + /// + /// # Example + /// + /// ```no_run + /// # use scrybe_cache::{RedisClient, RateLimiter}; + /// # async fn example() -> Result<(), scrybe_core::ScrybeError> { + /// let client = RedisClient::new("redis://localhost", 10).await?; + /// let limiter = RateLimiter::new(client, 100, 60); // 100 requests per minute + /// # Ok(()) + /// # } + /// ``` + pub fn new(client: RedisClient, max_requests: usize, window_seconds: usize) -> Self { + Self { + client, + max_requests, + window_seconds, + } + } + + /// Check if a request is allowed for the given identifier. + /// + /// Returns `true` if the request is allowed, `false` if rate limit exceeded. + /// + /// # Arguments + /// + /// * `identifier` - Unique identifier (e.g., IP address, session ID) + /// + /// # Errors + /// + /// Returns `ScrybeError::CacheError` if Redis operation fails. + pub async fn check(&self, identifier: &str) -> Result { + let key = format!("ratelimit:{}", identifier); + + let mut conn = self.client.get_connection().await?; + + // Increment counter + let count: usize = conn + .incr(&key, 1) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("INCR failed: {}", e)))?; + + // Set expiration on first request + if count == 1 { + conn.expire::<_, ()>(&key, self.window_seconds as i64) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("EXPIRE failed: {}", e)))?; + } + + Ok(count <= self.max_requests) + } + + /// Get current request count for an identifier. + /// + /// # Errors + /// + /// Returns `ScrybeError::CacheError` if Redis operation fails. + pub async fn get_count(&self, identifier: &str) -> Result { + let key = format!("ratelimit:{}", identifier); + + let mut conn = self.client.get_connection().await?; + + let count: Option = conn + .get(&key) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("GET failed: {}", e)))?; + + Ok(count.unwrap_or(0)) + } + + /// Reset rate limit for an identifier. + /// + /// # Errors + /// + /// Returns `ScrybeError::CacheError` if Redis operation fails. + pub async fn reset(&self, identifier: &str) -> Result<(), ScrybeError> { + let key = format!("ratelimit:{}", identifier); + + let mut conn = self.client.get_connection().await?; + + conn.del::<_, ()>(&key) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("DEL failed: {}", e)))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn test_rate_limiter_compiles() { + // Placeholder test + assert!(true); + } +} diff --git a/crates/scrybe-cache/src/session.rs b/crates/scrybe-cache/src/session.rs index 61fe830..f3a02a3 100644 --- a/crates/scrybe-cache/src/session.rs +++ b/crates/scrybe-cache/src/session.rs @@ -1,21 +1,51 @@ //! Session cache management with Redis. +use crate::client::RedisClient; +use redis::AsyncCommands; use scrybe_core::{ types::{Session, SessionId}, ScrybeError, }; -/// Redis-backed session cache. -pub struct SessionCache; +/// Redis-backed session cache with TTL. +/// +/// Sessions are stored for 1 hour (3600 seconds) to minimize memory usage. +pub struct SessionCache { + client: RedisClient, + ttl_seconds: usize, +} impl SessionCache { + /// Create a new session cache. + /// + /// # Arguments + /// + /// * `client` - Redis client instance + /// * `ttl_seconds` - Time-to-live for sessions (default: 3600 = 1 hour) + pub fn new(client: RedisClient, ttl_seconds: Option) -> Self { + Self { + client, + ttl_seconds: ttl_seconds.unwrap_or(3600), + } + } + /// Store a session in the cache with TTL. /// /// # Errors /// /// Returns `ScrybeError::CacheError` if the operation fails. - pub async fn store(_session: &Session) -> Result<(), ScrybeError> { - // TODO: Implement Redis storage + pub async fn store(&self, session: &Session) -> Result<(), ScrybeError> { + let key = format!("session:{}", session.id); + let value = serde_json::to_string(session).map_err(|e| { + ScrybeError::cache_error("redis", format!("Serialization failed: {}", e)) + })?; + + let mut conn = self.client.get_connection().await?; + + conn.set_ex::<_, _, ()>(&key, &value, self.ttl_seconds as u64) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("SET failed: {}", e)))?; + Ok(()) } @@ -24,9 +54,25 @@ impl SessionCache { /// # Errors /// /// Returns `ScrybeError::CacheError` if the operation fails. - pub async fn get(_session_id: &SessionId) -> Result, ScrybeError> { - // TODO: Implement Redis retrieval - Ok(None) + pub async fn get(&self, session_id: &SessionId) -> Result, ScrybeError> { + let key = format!("session:{}", session_id); + + let mut conn = self.client.get_connection().await?; + + let value: Option = conn + .get(&key) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("GET failed: {}", e)))?; + + match value { + Some(json) => { + let session = serde_json::from_str(&json).map_err(|e| { + ScrybeError::cache_error("redis", format!("Deserialization failed: {}", e)) + })?; + Ok(Some(session)) + } + None => Ok(None), + } } /// Delete a session from the cache. @@ -34,10 +80,35 @@ impl SessionCache { /// # Errors /// /// Returns `ScrybeError::CacheError` if the operation fails. - pub async fn delete(_session_id: &SessionId) -> Result<(), ScrybeError> { - // TODO: Implement Redis deletion + pub async fn delete(&self, session_id: &SessionId) -> Result<(), ScrybeError> { + let key = format!("session:{}", session_id); + + let mut conn = self.client.get_connection().await?; + + conn.del::<_, ()>(&key) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("DEL failed: {}", e)))?; + Ok(()) } + + /// Check if a session exists in the cache. + /// + /// # Errors + /// + /// Returns `ScrybeError::CacheError` if the operation fails. + pub async fn exists(&self, session_id: &SessionId) -> Result { + let key = format!("session:{}", session_id); + + let mut conn = self.client.get_connection().await?; + + let exists: bool = conn + .exists(&key) + .await + .map_err(|e| ScrybeError::cache_error("redis", format!("EXISTS failed: {}", e)))?; + + Ok(exists) + } } #[cfg(test)]