diff --git a/session/middleware.rs b/session/middleware.rs new file mode 100644 index 0000000..fcdb444 --- /dev/null +++ b/session/middleware.rs @@ -0,0 +1,506 @@ +use std::fmt; +use std::future::{Future, Ready, ready}; +use std::pin::Pin; +use std::rc::Rc; + +use actix_web::HttpResponse; +use actix_web::body::MessageBody; +use actix_web::cookie::time::Duration; +use actix_web::cookie::{Cookie, CookieJar, Key, SameSite}; +use actix_web::dev::{ + ResponseHead, Service, ServiceRequest, ServiceResponse, Transform, forward_ready, +}; +use actix_web::http::header::{HeaderValue, SET_COOKIE}; + +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; +use crate::session::storage::{SessionKey, SessionStore}; +use crate::session::{Session, SessionState, SessionStatus}; + +/// Controls when the TTL of server-side session state is refreshed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TtlExtensionPolicy { + /// Refresh TTL on every request associated with a valid session key. + OnEveryRequest, + /// Refresh TTL only when state changes or the session key is renewed. + OnStateChanges, +} + +/// Controls how the session cookie value (the server-side session key) is protected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CookieContentSecurity { + /// Encrypt and authenticate the cookie value. + Private, + /// Sign the cookie value without encrypting it. + Signed, +} + +/// Actix Web middleware that loads, persists and expires `Session` state. +/// +/// The cookie only stores a signed/encrypted session key; the full state is kept +/// in the configured `SessionStore` implementation (Redis in production). +#[derive(Clone)] +pub struct SessionMiddleware { + storage_backend: Rc, + configuration: Rc, +} + +impl SessionMiddleware { + pub fn new(store: Store, key: Key) -> Self { + Self::builder(store, key).build() + } + + pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder { + SessionMiddlewareBuilder::new(store, default_configuration(key)) + } + + pub fn from_app_config(store: Store, key: Key, app_config: &AppConfig) -> AppResult { + let ttl_secs = app_config.session_ttl_secs()?; + if ttl_secs == 0 { + return Err(AppError::Config( + "APP_SESSION_TTL_SECS must be greater than 0".into(), + )); + } + + Ok(Self::builder(store, key) + .cookie_name(app_config.session_cookie_name()?) + .cookie_secure(app_config.session_cookie_secure()?) + .cookie_http_only(app_config.session_cookie_http_only()?) + .cookie_same_site(parse_same_site(&app_config.session_cookie_same_site()?)?) + .cookie_path(app_config.session_cookie_path()?) + .cookie_domain(app_config.session_cookie_domain()?) + .cookie_max_age_secs(app_config.session_max_age_secs()?) + .state_ttl_secs(ttl_secs) + .build()) + } + + pub(crate) fn from_parts(store: Store, configuration: Configuration) -> Self { + Self { + storage_backend: Rc::new(store), + configuration: Rc::new(configuration), + } + } +} + +impl Transform for SessionMiddleware +where + S: Service, Error = actix_web::Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, + Store: SessionStore + 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = InnerSessionMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(InnerSessionMiddleware { + service: Rc::new(service), + configuration: Rc::clone(&self.configuration), + storage_backend: Rc::clone(&self.storage_backend), + })) + } +} + +#[doc(hidden)] +pub struct InnerSessionMiddleware { + service: Rc, + configuration: Rc, + storage_backend: Rc, +} + +impl Service for InnerSessionMiddleware +where + S: Service, Error = actix_web::Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, + Store: SessionStore + 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = Pin>>>; + + forward_ready!(service); + + fn call(&self, mut req: ServiceRequest) -> Self::Future { + let service = Rc::clone(&self.service); + let storage_backend = Rc::clone(&self.storage_backend); + let configuration = Rc::clone(&self.configuration); + + Box::pin(async move { + let session_key = extract_session_key(&req, &configuration.cookie); + let (session_key, session_state) = + load_session_state(session_key, storage_backend.as_ref()).await?; + + Session::set_session(&mut req, session_state); + let mut res = service.call(req).await?; + let (status, session_state) = Session::get_changes(&mut res); + + persist_session_changes( + res.response_mut().head_mut(), + session_key, + status, + session_state, + storage_backend.as_ref(), + &configuration, + ) + .await?; + + Ok(res) + }) + } +} + +/// Fluent builder for `SessionMiddleware`. +pub struct SessionMiddlewareBuilder { + storage_backend: Store, + configuration: Configuration, +} + +impl SessionMiddlewareBuilder { + fn new(store: Store, configuration: Configuration) -> Self { + Self { + storage_backend: store, + configuration, + } + } + + pub fn cookie_name(mut self, name: String) -> Self { + self.configuration.cookie.name = name; + self + } + + pub fn cookie_secure(mut self, secure: bool) -> Self { + self.configuration.cookie.secure = secure; + self + } + + pub fn cookie_http_only(mut self, http_only: bool) -> Self { + self.configuration.cookie.http_only = http_only; + self + } + + pub fn cookie_same_site(mut self, same_site: SameSite) -> Self { + self.configuration.cookie.same_site = same_site; + self + } + + pub fn cookie_path(mut self, path: String) -> Self { + self.configuration.cookie.path = path; + self + } + + pub fn cookie_domain(mut self, domain: Option) -> Self { + self.configuration.cookie.domain = domain; + self + } + + pub fn cookie_max_age_secs(mut self, max_age_secs: Option) -> Self { + self.configuration.cookie.max_age = max_age_secs.map(duration_from_secs); + self + } + + pub fn state_ttl_secs(mut self, ttl_secs: u64) -> Self { + self.configuration.session.state_ttl_secs = ttl_secs; + self + } + + pub fn ttl_extension_policy(mut self, policy: TtlExtensionPolicy) -> Self { + self.configuration.ttl_extension_policy = policy; + self + } + + pub fn cookie_content_security(mut self, security: CookieContentSecurity) -> Self { + self.configuration.cookie.content_security = security; + self + } + + pub fn build(self) -> SessionMiddleware { + SessionMiddleware::from_parts(self.storage_backend, self.configuration) + } +} + +#[derive(Clone)] +pub(crate) struct Configuration { + cookie: CookieConfiguration, + session: SessionConfiguration, + ttl_extension_policy: TtlExtensionPolicy, +} + +#[derive(Clone)] +pub(crate) struct SessionConfiguration { + state_ttl_secs: u64, +} + +#[derive(Clone)] +pub(crate) struct CookieConfiguration { + secure: bool, + http_only: bool, + name: String, + same_site: SameSite, + path: String, + domain: Option, + max_age: Option, + content_security: CookieContentSecurity, + key: Key, +} + +fn default_configuration(key: Key) -> Configuration { + Configuration { + cookie: CookieConfiguration { + secure: true, + http_only: true, + name: "sid".into(), + same_site: SameSite::Lax, + path: "/".into(), + domain: None, + max_age: None, + content_security: CookieContentSecurity::Private, + key, + }, + session: SessionConfiguration { + state_ttl_secs: 86_400, + }, + ttl_extension_policy: TtlExtensionPolicy::OnStateChanges, + } +} + +async fn persist_session_changes( + response: &mut ResponseHead, + session_key: Option, + status: SessionStatus, + session_state: SessionState, + storage_backend: &Store, + configuration: &Configuration, +) -> Result<(), actix_web::Error> { + match session_key { + None => persist_new_session(response, session_state, storage_backend, configuration).await, + Some(session_key) => { + persist_existing_session( + response, + session_key, + status, + session_state, + storage_backend, + configuration, + ) + .await + } + } +} + +async fn persist_new_session( + response: &mut ResponseHead, + session_state: SessionState, + storage_backend: &Store, + configuration: &Configuration, +) -> Result<(), actix_web::Error> { + if session_state.is_empty() { + return Ok(()); + } + + let session_key = storage_backend + .save(session_state, configuration.session.state_ttl_secs) + .await + .map_err(e500)?; + set_session_cookie(response, session_key, &configuration.cookie).map_err(e500) +} + +async fn persist_existing_session( + response: &mut ResponseHead, + session_key: SessionKey, + status: SessionStatus, + session_state: SessionState, + storage_backend: &Store, + configuration: &Configuration, +) -> Result<(), actix_web::Error> { + match status { + SessionStatus::Changed => { + let session_key = storage_backend + .update( + session_key, + session_state, + configuration.session.state_ttl_secs, + ) + .await + .map_err(e500)?; + set_session_cookie(response, session_key, &configuration.cookie).map_err(e500) + } + SessionStatus::Purged => { + storage_backend.delete(&session_key).await.map_err(e500)?; + delete_session_cookie(response, &configuration.cookie).map_err(e500) + } + SessionStatus::Renewed => { + storage_backend.delete(&session_key).await.map_err(e500)?; + let session_key = storage_backend + .save(session_state, configuration.session.state_ttl_secs) + .await + .map_err(e500)?; + set_session_cookie(response, session_key, &configuration.cookie).map_err(e500) + } + SessionStatus::Unchanged => { + refresh_ttl_if_needed(response, session_key, storage_backend, configuration).await + } + } +} + +async fn refresh_ttl_if_needed( + response: &mut ResponseHead, + session_key: SessionKey, + storage_backend: &Store, + configuration: &Configuration, +) -> Result<(), actix_web::Error> { + if configuration.ttl_extension_policy != TtlExtensionPolicy::OnEveryRequest { + return Ok(()); + } + + storage_backend + .update_ttl(&session_key, configuration.session.state_ttl_secs) + .await + .map_err(e500)?; + + if configuration.cookie.max_age.is_some() { + set_session_cookie(response, session_key, &configuration.cookie).map_err(e500)?; + } + + Ok(()) +} + +fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option { + let cookies = req.cookies().ok()?; + let session_cookie = cookies.iter().find(|cookie| cookie.name() == config.name)?; + + let mut jar = CookieJar::new(); + jar.add_original(session_cookie.clone()); + + let verified = match config.content_security { + CookieContentSecurity::Signed => jar.signed(&config.key).get(&config.name), + CookieContentSecurity::Private => jar.private(&config.key).get(&config.name), + }; + + if verified.is_none() { + tracing::warn!("session cookie failed signature verification/decryption"); + } + + match verified?.value().to_owned().try_into() { + Ok(session_key) => Some(session_key), + Err(err) => { + tracing::warn!(error = %err, "invalid session key, ignoring cookie"); + None + } + } +} + +async fn load_session_state( + session_key: Option, + storage_backend: &Store, +) -> Result<(Option, SessionState), actix_web::Error> { + let Some(session_key) = session_key else { + return Ok((None, SessionState::new())); + }; + + match storage_backend.load(&session_key).await { + Ok(Some(state)) => Ok((Some(session_key), state)), + Ok(None) => { + tracing::info!("session key is valid but state is missing; starting a fresh session"); + Ok((None, SessionState::new())) + } + Err(err) if is_invalid_state_error(&err) => { + tracing::warn!(error = %err, "invalid session state; using an empty state"); + Ok((Some(session_key), SessionState::new())) + } + Err(err) => Err(e500(err)), + } +} + +fn set_session_cookie( + response: &mut ResponseHead, + session_key: SessionKey, + config: &CookieConfiguration, +) -> AppResult<()> { + let mut cookie = Cookie::new(config.name.clone(), String::from(session_key)); + cookie.set_secure(config.secure); + cookie.set_http_only(config.http_only); + cookie.set_same_site(config.same_site); + cookie.set_path(config.path.clone()); + + if let Some(max_age) = config.max_age { + cookie.set_max_age(max_age); + } + if let Some(domain) = &config.domain { + cookie.set_domain(domain.clone()); + } + + let mut jar = CookieJar::new(); + match config.content_security { + CookieContentSecurity::Signed => jar.signed_mut(&config.key).add(cookie), + CookieContentSecurity::Private => jar.private_mut(&config.key).add(cookie), + } + + let cookie = jar + .delta() + .next() + .ok_or_else(|| AppError::InternalServerError("failed to build session cookie".into()))?; + append_set_cookie(response, cookie.encoded().to_string()) +} + +fn delete_session_cookie( + response: &mut ResponseHead, + config: &CookieConfiguration, +) -> AppResult<()> { + let mut removal_cookie = Cookie::build(config.name.clone(), "") + .path(config.path.clone()) + .secure(config.secure) + .http_only(config.http_only) + .same_site(config.same_site) + .finish(); + + if let Some(domain) = &config.domain { + removal_cookie.set_domain(domain.clone()); + } + + removal_cookie.make_removal(); + append_set_cookie(response, removal_cookie.to_string()) +} + +fn append_set_cookie(response: &mut ResponseHead, value: String) -> AppResult<()> { + let value = HeaderValue::from_str(&value).map_err(|err| { + AppError::InternalServerError(format!("invalid Set-Cookie header value: {err}")) + })?; + response.headers_mut().append(SET_COOKIE, value); + Ok(()) +} + +fn parse_same_site(value: &str) -> AppResult { + match value.trim().to_ascii_lowercase().as_str() { + "strict" => Ok(SameSite::Strict), + "lax" => Ok(SameSite::Lax), + "none" => Ok(SameSite::None), + other => Err(AppError::Config(format!( + "invalid APP_SESSION_COOKIE_SAME_SITE value: {other}" + ))), + } +} + +fn duration_from_secs(secs: u64) -> Duration { + let secs = i64::try_from(secs).unwrap_or(i64::MAX); + Duration::seconds(secs) +} + +fn is_invalid_state_error(err: &AppError) -> bool { + matches!(err, AppError::Json(_) | AppError::Config(_)) +} + +fn e500(err: E) -> actix_web::Error +where + E: fmt::Debug + fmt::Display + 'static, +{ + tracing::error!(error = %err, "session middleware internal error"); + actix_web::error::InternalError::from_response( + err, + HttpResponse::InternalServerError().finish(), + ) + .into() +} diff --git a/session/storage/redis.rs b/session/storage/redis.rs index 0b5fcfa..591a015 100644 --- a/session/storage/redis.rs +++ b/session/storage/redis.rs @@ -21,9 +21,7 @@ impl SessionStore for RedisSessionStore { let mut conn = self.redis.get_connection()?; let value: Option = redis::cmd("GET") .arg(session_key.as_ref()) - .query(&mut *conn.inner_mut()) - .ok() - .flatten(); + .query(&mut *conn.inner_mut())?; match value { None => Ok(None),