use actix_web::body::MessageBody; use actix_web::cookie::time::Duration; use actix_web::cookie::{Cookie, SameSite as CookieSameSite}; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}; use actix_web::http::header::{HeaderName, HeaderValue, USER_AGENT}; use actix_web::{HttpMessage, HttpRequest, HttpResponse}; use config::identity::session::{SameSite, SessionConfig}; use std::fmt; use std::future::{Ready, ready}; use std::pin::Pin; use std::rc::Rc; use crate::error::SessionError; use crate::extract::{REQUEST_ID_HEADER, RequestId}; use crate::key::SessionKey; use crate::state::{ SESSION_CLIENT_ID, SESSION_CLIENT_IP, SESSION_USER_AGENT, SESSION_USER_ID, Session, SessionStatus, }; use crate::store::{SessionState, SessionStore}; const CLIENT_IP_MAX_LEN: usize = 64; const USER_AGENT_MAX_LEN: usize = 512; pub struct SessionMiddleware where Store: SessionStore, { config: Rc, store: Rc, } impl SessionMiddleware where Store: SessionStore, { pub fn new(config: SessionConfig, store: Store) -> Self { Self { config: Rc::new(config), store: Rc::new(store), } } } 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), config: Rc::clone(&self.config), storage_backend: Rc::clone(&self.store), })) } } fn e500(err: E) -> actix_web::Error { actix_web::error::InternalError::from_response( err, HttpResponse::InternalServerError().finish(), ) .into() } pub struct InnerSessionMiddleware { service: Rc, config: Rc, storage_backend: Rc, } impl Service for InnerSessionMiddleware where S: Service, Error = actix_web::Error> + 'static, S::Future: 'static, Store: SessionStore + 'static, { type Response = ServiceResponse; type Error = actix_web::Error; #[allow(clippy::type_complexity)] type Future = Pin>>>; forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { let service = Rc::clone(&self.service); let storage_backend = Rc::clone(&self.storage_backend); let configuration = Rc::clone(&self.config); Box::pin(async move { let session_key = req .cookie(&configuration.cookie_name) .and_then(|cookie| SessionKey::try_from(cookie.value().to_owned()).ok()); let (session_key, session_state) = load_session_state(session_key, storage_backend.as_ref()).await?; let session = Session::from_state(session_state); req.extensions_mut().insert(session.clone()); record_client(&session, req.request(), configuration.trust_proxy); let request_id = req .extensions() .get::() .cloned() .unwrap_or_else(|| { RequestId::from_header( req.headers() .get(REQUEST_ID_HEADER) .and_then(|value| value.to_str().ok()), ) }); req.extensions_mut().insert(request_id.clone()); let mut res = service.call(req).await?; if let Ok(value) = HeaderValue::from_str(request_id.as_str()) { res.headers_mut() .insert(HeaderName::from_static(REQUEST_ID_HEADER), value); } let session = res .request() .extensions() .get::() .cloned() .unwrap_or(session); let (status, state) = session.snapshot(); let ttl = Duration::seconds(session_ttl(&configuration, &session)); match session_key { None => { if has_identity(&state) { let session_key = storage_backend.save(state, &ttl).await.map_err(e500)?; set_session_cookie(res.response_mut(), &configuration, session_key, ttl)?; } } Some(session_key) => match status { SessionStatus::Changed => { match storage_backend .update(session_key, session.diff(), &ttl) .await { Ok(session_key) => { set_session_cookie( res.response_mut(), &configuration, session_key, ttl, )?; } Err(SessionError::Purged) => {} Err(error) => return Err(e500(error)), } } SessionStatus::Renewed => { match storage_backend .rotate(session_key, session.diff(), &ttl) .await { Ok(Some(new_session_key)) => { set_session_cookie( res.response_mut(), &configuration, new_session_key, ttl, )?; } Ok(None) => { clear_session_cookie(res.response_mut(), &configuration, ttl)?; } Err(SessionError::Purged) => {} Err(error) => return Err(e500(error)), } } SessionStatus::Purged => { storage_backend.delete(&session_key).await.map_err(e500)?; clear_session_cookie(res.response_mut(), &configuration, ttl)?; } SessionStatus::UnChanged => {} }, } Ok(res) }) } } fn session_ttl(configuration: &SessionConfig, session: &Session) -> i64 { let max_age = configuration.max_age.max(1); if matches!(session.get::(SESSION_USER_ID), Ok(Some(_))) { return max_age; } configuration.anonymous_max_age.clamp(1, max_age) } fn record_client(session: &Session, req: &HttpRequest, trust_proxy: bool) { let connection = req.connection_info(); let ip_addr = if trust_proxy { connection.realip_remote_addr() } else { connection.peer_addr() }; if let Some(ip_addr) = ip_addr { let _ = session.set_client_ip(truncate(ip_addr, CLIENT_IP_MAX_LEN)); } let user_agent = req .headers() .get(USER_AGENT) .and_then(|user_agent| user_agent.to_str().ok()); if let Some(user_agent) = user_agent { let _ = session.set_user_agent(truncate(user_agent, USER_AGENT_MAX_LEN)); } } fn has_identity(state: &SessionState) -> bool { state.keys().any(|key| { !matches!( key.as_str(), SESSION_CLIENT_ID | SESSION_CLIENT_IP | SESSION_USER_AGENT ) }) } fn truncate(value: &str, max_len: usize) -> String { match value.char_indices().find(|(index, _)| *index >= max_len) { Some((index, _)) => value[..index].to_owned(), None => value.to_owned(), } } async fn load_session_state( session_key: Option, storage_backend: &Store, ) -> Result<(Option, SessionState), actix_web::Error> { match session_key { Some(session_key) => match storage_backend.load(&session_key).await.map_err(e500)? { Some(session_state) => Ok((Some(session_key), session_state)), None => Ok((None, SessionState::new())), }, None => Ok((None, SessionState::new())), } } fn set_session_cookie( response: &mut HttpResponse, configuration: &SessionConfig, session_key: SessionKey, max_age: Duration, ) -> Result<(), actix_web::Error> { response .add_cookie(&session_cookie(configuration, session_key.into(), max_age)) .map_err(e500) } fn clear_session_cookie( response: &mut HttpResponse, configuration: &SessionConfig, max_age: Duration, ) -> Result<(), actix_web::Error> { response .add_removal_cookie(&session_cookie(configuration, String::new(), max_age)) .map_err(e500) } fn session_cookie( configuration: &SessionConfig, value: String, max_age: Duration, ) -> Cookie<'static> { let mut cookie = Cookie::new(configuration.cookie_name.clone(), value); cookie.set_secure(configuration.secure || matches!(configuration.same_site, SameSite::None)); // 会话 cookie 恒为 HttpOnly:不给脚本读,XSS 拿不到会话 id。 cookie.set_http_only(true); cookie.set_same_site(match configuration.same_site { SameSite::Strict => CookieSameSite::Strict, SameSite::Lax => CookieSameSite::Lax, SameSite::None => CookieSameSite::None, }); cookie.set_max_age(max_age); if let Some(path) = &configuration.path { cookie.set_path(path.clone()); } if let Some(domain) = &configuration.domain { cookie.set_domain(domain.clone()); } cookie } #[cfg(test)] mod tests { use super::*; fn config(http_only: bool, secure: bool, same_site: SameSite) -> SessionConfig { SessionConfig { cookie_name: "session".into(), max_age: 3600, anonymous_max_age: 300, path: Some("/".into()), domain: None, secure, same_site, http_only, trust_proxy: false, } } #[test] fn session_cookie_is_always_httponly() { let cookie = session_cookie( &config(false, true, SameSite::Lax), "key".into(), Duration::days(1), ); assert_eq!(cookie.http_only(), Some(true)); } #[test] fn same_site_none_forces_secure() { let cookie = session_cookie( &config(true, false, SameSite::None), "key".into(), Duration::days(1), ); assert_eq!(cookie.secure(), Some(true)); // Lax 下 secure 跟随配置(这里为 false),强制只发生在 SameSite=None。 let cookie = session_cookie( &config(true, false, SameSite::Lax), "key".into(), Duration::days(1), ); assert_eq!(cookie.secure(), Some(false)); let cookie = session_cookie( &config(true, true, SameSite::Lax), "key".into(), Duration::days(1), ); assert_eq!(cookie.secure(), Some(true)); } }