actix_session/
middleware.rs

1use std::{collections::HashMap, fmt, future::Future, pin::Pin, rc::Rc};
2
3use actix_utils::future::{ready, Ready};
4use actix_web::{
5    body::MessageBody,
6    cookie::{Cookie, CookieJar, Key},
7    dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform},
8    http::header::{HeaderValue, SET_COOKIE},
9    HttpResponse,
10};
11use anyhow::Context;
12
13use crate::{
14    config::{
15        self, Configuration, CookieConfiguration, CookieContentSecurity, SessionMiddlewareBuilder,
16        TtlExtensionPolicy,
17    },
18    storage::{LoadError, SessionKey, SessionStore},
19    Session, SessionStatus,
20};
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
35///   [`SessionStore`]);
36/// - a secret key, to sign or encrypt the content of client-side session cookie.
37///
38/// # How did we choose defaults?
39/// You should not regret adding `actix-session` to your dependencies and going to production using
40/// the default configuration. That is why, when in doubt, we opt to use the most secure option for
41/// each configuration parameter.
42///
43/// We expose knobs to change the default to suit your needs—i.e., if you know what you are doing,
44/// we will not stop you. But being a subject-matter expert should not be a requirement to deploy
45/// reasonably secure implementation of sessions.
46///
47/// # Examples
48/// ```no_run
49/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
50/// use actix_session::{Session, SessionMiddleware, storage::RedisSessionStore};
51/// use actix_web::cookie::Key;
52///
53/// // The secret key would usually be read from a configuration file/environment variables.
54/// fn get_secret_key() -> Key {
55///     # todo!()
56///     // [...]
57/// }
58///
59/// #[actix_web::main]
60/// async fn main() -> std::io::Result<()> {
61///     let secret_key = get_secret_key();
62///     let storage = RedisSessionStore::new("127.0.0.1:6379").await.unwrap();
63///
64///     HttpServer::new(move || {
65///         App::new()
66///             // Add session management to your application using Redis as storage
67///             .wrap(SessionMiddleware::new(
68///                 storage.clone(),
69///                 secret_key.clone(),
70///             ))
71///             .default_service(web::to(|| HttpResponse::Ok()))
72///     })
73///     .bind(("127.0.0.1", 8080))?
74///     .run()
75///     .await
76/// }
77/// ```
78///
79/// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new):
80///
81/// ```no_run
82/// use actix_web::{App, cookie::{Key, time}, Error, HttpResponse, HttpServer, web};
83/// use actix_session::{Session, SessionMiddleware, storage::RedisSessionStore};
84/// use actix_session::config::PersistentSession;
85///
86/// // The secret key would usually be read from a configuration file/environment variables.
87/// fn get_secret_key() -> Key {
88///     # todo!()
89///     // [...]
90/// }
91///
92/// #[actix_web::main]
93/// async fn main() -> std::io::Result<()> {
94///     let secret_key = get_secret_key();
95///     let storage = RedisSessionStore::new("127.0.0.1:6379").await.unwrap();
96///
97///     HttpServer::new(move || {
98///         App::new()
99///             // Customise session length!
100///             .wrap(
101///                 SessionMiddleware::builder(storage.clone(), secret_key.clone())
102///                     .session_lifecycle(
103///                         PersistentSession::default().session_ttl(time::Duration::days(5)),
104///                     )
105///                     .build(),
106///             )
107///             .default_service(web::to(|| HttpResponse::Ok()))
108///     })
109///     .bind(("127.0.0.1", 8080))?
110///     .run()
111///     .await
112/// }
113/// ```
114#[derive(Clone)]
115pub struct SessionMiddleware<Store: SessionStore> {
116    storage_backend: Rc<Store>,
117    configuration: Rc<Configuration>,
118}
119
120impl<Store: SessionStore> SessionMiddleware<Store> {
121    /// Use [`SessionMiddleware::new`] to initialize the session framework using the default
122    /// parameters.
123    ///
124    /// To create a new instance of [`SessionMiddleware`] you need to provide:
125    /// - an instance of the session storage backend you wish to use (i.e. an implementation of
126    ///   [`SessionStore`]);
127    /// - a secret key, to sign or encrypt the content of client-side session cookie.
128    pub fn new(store: Store, key: Key) -> Self {
129        Self::builder(store, key).build()
130    }
131
132    /// A fluent API to configure [`SessionMiddleware`].
133    ///
134    /// It takes as input the two required inputs to create a new instance of [`SessionMiddleware`]:
135    /// - an instance of the session storage backend you wish to use (i.e. an implementation of
136    ///   [`SessionStore`]);
137    /// - a secret key, to sign or encrypt the content of client-side session cookie.
138    pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder<Store> {
139        SessionMiddlewareBuilder::new(store, config::default_configuration(key))
140    }
141
142    pub(crate) fn from_parts(store: Store, configuration: Configuration) -> Self {
143        Self {
144            storage_backend: Rc::new(store),
145            configuration: Rc::new(configuration),
146        }
147    }
148}
149
150impl<S, B, Store> Transform<S, ServiceRequest> for SessionMiddleware<Store>
151where
152    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
153    S::Future: 'static,
154    B: MessageBody + 'static,
155    Store: SessionStore + 'static,
156{
157    type Response = ServiceResponse<B>;
158    type Error = actix_web::Error;
159    type Transform = InnerSessionMiddleware<S, Store>;
160    type InitError = ();
161    type Future = Ready<Result<Self::Transform, Self::InitError>>;
162
163    fn new_transform(&self, service: S) -> Self::Future {
164        ready(Ok(InnerSessionMiddleware {
165            service: Rc::new(service),
166            configuration: Rc::clone(&self.configuration),
167            storage_backend: Rc::clone(&self.storage_backend),
168        }))
169    }
170}
171
172/// Short-hand to create an `actix_web::Error` instance that will result in an `Internal Server
173/// Error` response while preserving the error root cause (e.g. in logs).
174fn e500<E: fmt::Debug + fmt::Display + 'static>(err: E) -> actix_web::Error {
175    // We do not use `actix_web::error::ErrorInternalServerError` because we do not want to
176    // leak internal implementation details to the caller.
177    //
178    // `actix_web::error::ErrorInternalServerError` includes the error Display representation
179    // as body of the error responses, leading to messages like "There was an issue persisting
180    // the session state" reaching API clients. We don't want that, we want opaque 500s.
181    actix_web::error::InternalError::from_response(
182        err,
183        HttpResponse::InternalServerError().finish(),
184    )
185    .into()
186}
187
188#[doc(hidden)]
189#[non_exhaustive]
190pub struct InnerSessionMiddleware<S, Store: SessionStore + 'static> {
191    service: Rc<S>,
192    configuration: Rc<Configuration>,
193    storage_backend: Rc<Store>,
194}
195
196impl<S, B, Store> Service<ServiceRequest> for InnerSessionMiddleware<S, Store>
197where
198    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
199    S::Future: 'static,
200    Store: SessionStore + 'static,
201{
202    type Response = ServiceResponse<B>;
203    type Error = actix_web::Error;
204    #[allow(clippy::type_complexity)]
205    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
206
207    forward_ready!(service);
208
209    fn call(&self, mut req: ServiceRequest) -> Self::Future {
210        let service = Rc::clone(&self.service);
211        let storage_backend = Rc::clone(&self.storage_backend);
212        let configuration = Rc::clone(&self.configuration);
213
214        Box::pin(async move {
215            let session_key = extract_session_key(&req, &configuration.cookie);
216            let (session_key, session_state) =
217                load_session_state(session_key, storage_backend.as_ref()).await?;
218
219            Session::set_session(&mut req, session_state);
220
221            let mut res = service.call(req).await?;
222            let (status, session_state) = Session::get_changes(&mut res);
223
224            match session_key {
225                None => {
226                    // we do not create an entry in the session store if there is no state attached
227                    // to a fresh session
228                    if !session_state.is_empty() {
229                        let session_key = storage_backend
230                            .save(session_state, &configuration.session.state_ttl)
231                            .await
232                            .map_err(e500)?;
233
234                        set_session_cookie(
235                            res.response_mut().head_mut(),
236                            session_key,
237                            &configuration.cookie,
238                        )
239                        .map_err(e500)?;
240                    }
241                }
242
243                Some(session_key) => {
244                    match status {
245                        SessionStatus::Changed => {
246                            let session_key = storage_backend
247                                .update(
248                                    session_key,
249                                    session_state,
250                                    &configuration.session.state_ttl,
251                                )
252                                .await
253                                .map_err(e500)?;
254
255                            set_session_cookie(
256                                res.response_mut().head_mut(),
257                                session_key,
258                                &configuration.cookie,
259                            )
260                            .map_err(e500)?;
261                        }
262
263                        SessionStatus::Purged => {
264                            storage_backend.delete(&session_key).await.map_err(e500)?;
265
266                            delete_session_cookie(
267                                res.response_mut().head_mut(),
268                                &configuration.cookie,
269                            )
270                            .map_err(e500)?;
271                        }
272
273                        SessionStatus::Renewed => {
274                            storage_backend.delete(&session_key).await.map_err(e500)?;
275
276                            let session_key = storage_backend
277                                .save(session_state, &configuration.session.state_ttl)
278                                .await
279                                .map_err(e500)?;
280
281                            set_session_cookie(
282                                res.response_mut().head_mut(),
283                                session_key,
284                                &configuration.cookie,
285                            )
286                            .map_err(e500)?;
287                        }
288
289                        SessionStatus::Unchanged => {
290                            if matches!(
291                                configuration.ttl_extension_policy,
292                                TtlExtensionPolicy::OnEveryRequest
293                            ) {
294                                storage_backend
295                                    .update_ttl(&session_key, &configuration.session.state_ttl)
296                                    .await
297                                    .map_err(e500)?;
298
299                                if configuration.cookie.max_age.is_some() {
300                                    set_session_cookie(
301                                        res.response_mut().head_mut(),
302                                        session_key,
303                                        &configuration.cookie,
304                                    )
305                                    .map_err(e500)?;
306                                }
307                            }
308                        }
309                    };
310                }
311            }
312
313            Ok(res)
314        })
315    }
316}
317
318/// Examines the session cookie attached to the incoming request, if there is one, and tries
319/// to extract the session key.
320///
321/// It returns `None` if there is no session cookie or if the session cookie is considered invalid
322/// (e.g., when failing a signature check).
323fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option<SessionKey> {
324    let cookies = req.cookies().ok()?;
325    let session_cookie = cookies
326        .iter()
327        .find(|&cookie| cookie.name() == config.name)?;
328
329    let mut jar = CookieJar::new();
330    jar.add_original(session_cookie.clone());
331
332    let verification_result = match config.content_security {
333        CookieContentSecurity::Signed => jar.signed(&config.key).get(&config.name),
334        CookieContentSecurity::Private => jar.private(&config.key).get(&config.name),
335    };
336
337    if verification_result.is_none() {
338        tracing::warn!(
339            "The session cookie attached to the incoming request failed to pass cryptographic \
340            checks (signature verification/decryption)."
341        );
342    }
343
344    match verification_result?.value().to_owned().try_into() {
345        Ok(session_key) => Some(session_key),
346        Err(err) => {
347            tracing::warn!(
348                error.message = %err,
349                error.cause_chain = ?err,
350                "Invalid session key, ignoring."
351            );
352
353            None
354        }
355    }
356}
357
358async fn load_session_state<Store: SessionStore>(
359    session_key: Option<SessionKey>,
360    storage_backend: &Store,
361) -> Result<(Option<SessionKey>, HashMap<String, String>), actix_web::Error> {
362    if let Some(session_key) = session_key {
363        match storage_backend.load(&session_key).await {
364            Ok(state) => {
365                if let Some(state) = state {
366                    Ok((Some(session_key), state))
367                } else {
368                    // We discard the existing session key given that the state attached to it can
369                    // no longer be found (e.g. it expired or we suffered some data loss in the
370                    // storage). Regenerating the session key will trigger the `save` workflow
371                    // instead of the `update` workflow if the session state is modified during the
372                    // lifecycle of the current request.
373
374                    tracing::info!(
375                        "No session state has been found for a valid session key, creating a new \
376                        empty session."
377                    );
378
379                    Ok((None, HashMap::new()))
380                }
381            }
382
383            Err(err) => match err {
384                LoadError::Deserialization(err) => {
385                    tracing::warn!(
386                        error.message = %err,
387                        error.cause_chain = ?err,
388                        "Invalid session state, creating a new empty session."
389                    );
390
391                    Ok((Some(session_key), HashMap::new()))
392                }
393
394                LoadError::Other(err) => Err(e500(err)),
395            },
396        }
397    } else {
398        Ok((None, HashMap::new()))
399    }
400}
401
402fn set_session_cookie(
403    response: &mut ResponseHead,
404    session_key: SessionKey,
405    config: &CookieConfiguration,
406) -> Result<(), anyhow::Error> {
407    let value: String = session_key.into();
408    let mut cookie = Cookie::new(config.name.clone(), value);
409
410    cookie.set_secure(config.secure);
411    cookie.set_http_only(config.http_only);
412    cookie.set_same_site(config.same_site);
413    cookie.set_path(config.path.clone());
414
415    if let Some(max_age) = config.max_age {
416        cookie.set_max_age(max_age);
417    }
418
419    if let Some(ref domain) = config.domain {
420        cookie.set_domain(domain.clone());
421    }
422
423    let mut jar = CookieJar::new();
424    match config.content_security {
425        CookieContentSecurity::Signed => jar.signed_mut(&config.key).add(cookie),
426        CookieContentSecurity::Private => jar.private_mut(&config.key).add(cookie),
427    }
428
429    // set cookie
430    let cookie = jar.delta().next().unwrap();
431    let val = HeaderValue::from_str(&cookie.encoded().to_string())
432        .context("Failed to attach a session cookie to the outgoing response")?;
433
434    response.headers_mut().append(SET_COOKIE, val);
435
436    Ok(())
437}
438
439fn delete_session_cookie(
440    response: &mut ResponseHead,
441    config: &CookieConfiguration,
442) -> Result<(), anyhow::Error> {
443    let removal_cookie = Cookie::build(config.name.clone(), "")
444        .path(config.path.clone())
445        .secure(config.secure)
446        .http_only(config.http_only)
447        .same_site(config.same_site);
448
449    let mut removal_cookie = if let Some(ref domain) = config.domain {
450        removal_cookie.domain(domain)
451    } else {
452        removal_cookie
453    }
454    .finish();
455
456    removal_cookie.make_removal();
457
458    let val = HeaderValue::from_str(&removal_cookie.to_string())
459        .context("Failed to attach a session removal cookie to the outgoing response")?;
460    response.headers_mut().append(SET_COOKIE, val);
461
462    Ok(())
463}