actix_cloud/session/
middleware.rs

1use std::{borrow::Cow, collections::HashMap, fmt, future::Future, pin::Pin, rc::Rc, sync::Arc};
2
3use actix_utils::future::{ready, Ready};
4use actix_web::{
5    body::MessageBody,
6    cookie::{time::Duration, Cookie, CookieJar, Key},
7    dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform},
8    http::header::{HeaderValue, SET_COOKIE},
9    HttpResponse,
10};
11
12use super::{
13    config::{
14        self, Configuration, CookieConfiguration, CookieContentSecurity, SessionMiddlewareBuilder,
15        TtlExtensionPolicy,
16    },
17    storage::{SessionKey, SessionStore},
18    Session, SessionStatus,
19};
20use crate::{memorydb::MemoryDB, Result};
21
22/// A middleware for session management in Actix Web applications.
23///
24/// [`SessionMiddleware`] takes care of a few jobs:
25///
26/// - Instructs the session storage backend to create/update/delete/retrieve the state attached to
27///   a session according to its status and the operations that have been performed against it;
28/// - Set/remove a cookie, on the client side, to enable a user to be consistently associated with
29///   the same session across multiple HTTP requests.
30///
31/// Use [`SessionMiddleware::new`] to initialize the session framework using the default parameters.
32/// To create a new instance of [`SessionMiddleware`] you need to provide:
33///
34/// - an instance of the session storage backend you wish to use (i.e. an implementation of SessionStore);
35/// - a secret key, to sign or encrypt the content of client-side session cookie.
36///
37/// # How did we choose defaults?
38/// You should not regret adding `actix-session` to your dependencies and going to production using
39/// the default configuration. That is why, when in doubt, we opt to use the most secure option for
40/// each configuration parameter.
41///
42/// We expose knobs to change the default to suit your needs—i.e., if you know what you are doing,
43/// we will not stop you. But being a subject-matter expert should not be a requirement to deploy
44/// reasonably secure implementation of sessions.
45#[derive(Clone)]
46pub struct SessionMiddleware {
47    storage_backend: Rc<SessionStore>,
48    configuration: Rc<Configuration>,
49}
50
51impl SessionMiddleware {
52    /// Use [`SessionMiddleware::new`] to initialize the session framework using the default
53    /// parameters.
54    ///
55    /// To create a new instance of [`SessionMiddleware`] you need to provide:
56    /// - an instance of the session storage backend you wish to use (i.e. an implementation of SessionStore);
57    /// - a secret key, to sign or encrypt the content of client-side session cookie.
58    pub fn new(client: Arc<dyn MemoryDB>, key: Key) -> Self {
59        Self::builder(client, key).build()
60    }
61
62    /// A fluent API to configure [`SessionMiddleware`].
63    ///
64    /// It takes as input the two required inputs to create a new instance of [`SessionMiddleware`]:
65    /// - an instance of the session storage backend you wish to use (i.e. an implementation of SessionStore);
66    /// - a secret key, to sign or encrypt the content of client-side session cookie.
67    pub fn builder(client: Arc<dyn MemoryDB>, key: Key) -> SessionMiddlewareBuilder {
68        SessionMiddlewareBuilder::new(client, config::default_configuration(key))
69    }
70
71    pub(crate) fn from_parts(store: SessionStore, configuration: Configuration) -> Self {
72        Self {
73            storage_backend: Rc::new(store),
74            configuration: Rc::new(configuration),
75        }
76    }
77}
78
79impl<S, B> Transform<S, ServiceRequest> for SessionMiddleware
80where
81    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
82    S::Future: 'static,
83    B: MessageBody + 'static,
84{
85    type Response = ServiceResponse<B>;
86    type Error = actix_web::Error;
87    type Transform = InnerSessionMiddleware<S>;
88    type InitError = ();
89    type Future = Ready<Result<Self::Transform, Self::InitError>>;
90
91    fn new_transform(&self, service: S) -> Self::Future {
92        ready(Ok(InnerSessionMiddleware {
93            service: Rc::new(service),
94            configuration: Rc::clone(&self.configuration),
95            storage_backend: Rc::clone(&self.storage_backend),
96        }))
97    }
98}
99
100/// Short-hand to create an `actix_web::Error` instance that will result in an `Internal Server
101/// Error` response while preserving the error root cause (e.g. in logs).
102fn e500<E: fmt::Debug + fmt::Display + 'static>(err: E) -> actix_web::Error {
103    // We do not use `actix_web::error::ErrorInternalServerError` because we do not want to
104    // leak internal implementation details to the caller.
105    //
106    // `actix_web::error::ErrorInternalServerError` includes the error Display representation
107    // as body of the error responses, leading to messages like "There was an issue persisting
108    // the session state" reaching API clients. We don't want that, we want opaque 500s.
109    actix_web::error::InternalError::from_response(
110        err,
111        HttpResponse::InternalServerError().finish(),
112    )
113    .into()
114}
115
116#[doc(hidden)]
117#[non_exhaustive]
118pub struct InnerSessionMiddleware<S> {
119    service: Rc<S>,
120    configuration: Rc<Configuration>,
121    storage_backend: Rc<SessionStore>,
122}
123
124impl<S, B> Service<ServiceRequest> for InnerSessionMiddleware<S>
125where
126    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
127    S::Future: 'static,
128{
129    type Response = ServiceResponse<B>;
130    type Error = actix_web::Error;
131    #[allow(clippy::type_complexity)]
132    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
133
134    forward_ready!(service);
135
136    fn call(&self, mut req: ServiceRequest) -> Self::Future {
137        let service = Rc::clone(&self.service);
138        let storage_backend = Rc::clone(&self.storage_backend);
139        let configuration = Rc::clone(&self.configuration);
140
141        Box::pin(async move {
142            let session_key = extract_session_key(&req, &configuration.cookie);
143            let (session_key, session_state) =
144                load_session_state(session_key, storage_backend.as_ref()).await?;
145
146            Session::set_session(&mut req, session_state);
147
148            let mut res = service.call(req).await?;
149            let (status, session_state) = Session::get_changes(&mut res);
150
151            let mut ttl = configuration.session.state_ttl;
152            let mut cookie = Cow::Borrowed(&configuration.cookie);
153            if let Some(x) = session_state.get("_ttl") {
154                if let Ok(x) = x.parse() {
155                    ttl = Duration::seconds(x);
156                    let mut tmp = cookie.into_owned();
157                    tmp.max_age = Some(ttl);
158                    cookie = Cow::Owned(tmp);
159                }
160            }
161            let id = session_state
162                .get("_id")
163                .map(|x| x.trim_matches('"').to_owned());
164
165            match session_key {
166                None => {
167                    // we do not create an entry in the session store if there is no state attached
168                    // to a fresh session
169                    if !session_state.is_empty() {
170                        let session_key = storage_backend
171                            .save(session_state, &id, &ttl)
172                            .await
173                            .map_err(e500)?;
174
175                        set_session_cookie(res.response_mut().head_mut(), session_key, &cookie)
176                            .map_err(e500)?;
177                    }
178                }
179
180                Some(session_key) => {
181                    match status {
182                        SessionStatus::Changed => {
183                            let session_key = storage_backend
184                                .update(session_key, session_state, &id, &ttl)
185                                .await
186                                .map_err(e500)?;
187
188                            set_session_cookie(res.response_mut().head_mut(), session_key, &cookie)
189                                .map_err(e500)?;
190                        }
191
192                        SessionStatus::Purged => {
193                            storage_backend
194                                .delete(&session_key, &id)
195                                .await
196                                .map_err(e500)?;
197
198                            delete_session_cookie(res.response_mut().head_mut(), &cookie)
199                                .map_err(e500)?;
200                        }
201
202                        SessionStatus::Renewed => {
203                            storage_backend
204                                .delete(&session_key, &id)
205                                .await
206                                .map_err(e500)?;
207
208                            let session_key = storage_backend
209                                .save(session_state, &id, &ttl)
210                                .await
211                                .map_err(e500)?;
212
213                            set_session_cookie(res.response_mut().head_mut(), session_key, &cookie)
214                                .map_err(e500)?;
215                        }
216
217                        SessionStatus::Unchanged => {
218                            if matches!(
219                                configuration.ttl_extension_policy,
220                                TtlExtensionPolicy::OnEveryRequest
221                            ) {
222                                storage_backend
223                                    .update_ttl(&session_key, &id, &ttl)
224                                    .await
225                                    .map_err(e500)?;
226
227                                if configuration.cookie.max_age.is_some() {
228                                    set_session_cookie(
229                                        res.response_mut().head_mut(),
230                                        session_key,
231                                        &cookie,
232                                    )
233                                    .map_err(e500)?;
234                                }
235                            }
236                        }
237                    };
238                }
239            }
240
241            Ok(res)
242        })
243    }
244}
245
246/// Examines the session cookie attached to the incoming request, if there is one, and tries
247/// to extract the session key.
248///
249/// It returns `None` if there is no session cookie or if the session cookie is considered invalid
250/// (e.g., when failing a signature check).
251fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option<SessionKey> {
252    let cookies = req.cookies().ok()?;
253    let session_cookie = cookies
254        .iter()
255        .find(|&cookie| cookie.name() == config.name)?;
256
257    let mut jar = CookieJar::new();
258    jar.add_original(session_cookie.clone());
259
260    let verification_result = match config.content_security {
261        CookieContentSecurity::Signed => jar.signed(&config.key).get(&config.name),
262        CookieContentSecurity::Private => jar.private(&config.key).get(&config.name),
263    };
264
265    verification_result?.value().to_owned().try_into().ok()
266}
267
268async fn load_session_state(
269    session_key: Option<SessionKey>,
270    storage_backend: &SessionStore,
271) -> Result<(Option<SessionKey>, HashMap<String, String>), actix_web::Error> {
272    if let Some(session_key) = session_key {
273        match storage_backend.load(&session_key).await {
274            Ok(state) => {
275                if let Some(state) = state {
276                    Ok((Some(session_key), state))
277                } else {
278                    // We discard the existing session key given that the state attached to it can
279                    // no longer be found (e.g. it expired or we suffered some data loss in the
280                    // storage). Regenerating the session key will trigger the `save` workflow
281                    // instead of the `update` workflow if the session state is modified during the
282                    // lifecycle of the current request.
283
284                    Ok((None, HashMap::new()))
285                }
286            }
287
288            Err(err) => Err(e500(err)),
289        }
290    } else {
291        Ok((None, HashMap::new()))
292    }
293}
294
295fn set_session_cookie(
296    response: &mut ResponseHead,
297    session_key: SessionKey,
298    config: &CookieConfiguration,
299) -> Result<()> {
300    let value: String = session_key.into();
301    let mut cookie = Cookie::new(config.name.clone(), value);
302
303    cookie.set_secure(config.secure);
304    cookie.set_http_only(config.http_only);
305    cookie.set_same_site(config.same_site);
306    cookie.set_path(config.path.clone());
307
308    if let Some(max_age) = config.max_age {
309        cookie.set_max_age(max_age);
310    }
311
312    if let Some(ref domain) = config.domain {
313        cookie.set_domain(domain.clone());
314    }
315
316    let mut jar = CookieJar::new();
317    match config.content_security {
318        CookieContentSecurity::Signed => jar.signed_mut(&config.key).add(cookie),
319        CookieContentSecurity::Private => jar.private_mut(&config.key).add(cookie),
320    }
321
322    // set cookie
323    let cookie = jar.delta().next().unwrap();
324    let val = HeaderValue::from_str(&cookie.encoded().to_string())?;
325
326    response.headers_mut().append(SET_COOKIE, val);
327
328    Ok(())
329}
330
331fn delete_session_cookie(response: &mut ResponseHead, config: &CookieConfiguration) -> Result<()> {
332    let removal_cookie = Cookie::build(config.name.clone(), "")
333        .path(config.path.clone())
334        .secure(config.secure)
335        .http_only(config.http_only)
336        .same_site(config.same_site);
337
338    let mut removal_cookie = if let Some(ref domain) = config.domain {
339        removal_cookie.domain(domain)
340    } else {
341        removal_cookie
342    }
343    .finish();
344
345    removal_cookie.make_removal();
346
347    let val = HeaderValue::from_str(&removal_cookie.to_string())?;
348    response.headers_mut().append(SET_COOKIE, val);
349
350    Ok(())
351}