use actix_session::SessionExt;
use actix_web::body::EitherBody;
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpResponse,
};
use futures_util::future::LocalBoxFuture;
use std::future::{ready, Ready};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::helpers::session::get_session_user;
pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900;
const SESSION_KEY_LAST_ACTIVE: &str = "last_active_at";
#[derive(Debug, Clone, Copy)]
pub struct IdleTimeoutMiddleware {
pub seconds: u64,
}
impl IdleTimeoutMiddleware {
pub fn new(seconds: u64) -> Self {
Self { seconds }
}
pub fn from_env() -> Self {
let secs = std::env::var("SESSION_IDLE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS);
Self::new(secs)
}
}
impl Default for IdleTimeoutMiddleware {
fn default() -> Self {
Self::new(DEFAULT_IDLE_TIMEOUT_SECS)
}
}
impl<S, B> Transform<S, ServiceRequest> for IdleTimeoutMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type InitError = ();
type Transform = IdleTimeoutCheck<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(IdleTimeoutCheck {
service,
seconds: self.seconds,
}))
}
}
pub struct IdleTimeoutCheck<S> {
service: S,
seconds: u64,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl<S, B> Service<ServiceRequest> for IdleTimeoutCheck<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let session = req.get_session();
let limit = self.seconds;
let user_present = get_session_user(&session).is_some();
if !user_present {
let fut = self.service.call(req);
return Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) });
}
let now = now_secs();
let last_active: Option<u64> = session.get(SESSION_KEY_LAST_ACTIVE).ok().flatten();
match last_active {
None => {
let _ = session.insert(SESSION_KEY_LAST_ACTIVE, now);
}
Some(prev) if now.saturating_sub(prev) > limit => {
tracing::info!(
idle_for_secs = now.saturating_sub(prev),
limit_secs = limit,
"session exceeded idle timeout โ purging"
);
session.purge();
return Box::pin(async move {
Ok(req.into_response(
HttpResponse::Found()
.insert_header(("Location", "/signin?reason=idle"))
.finish()
.map_into_right_body(),
))
});
}
Some(_) => {
let _ = session.insert(SESSION_KEY_LAST_ACTIVE, now);
}
}
let fut = self.service.call(req);
Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_session::storage::CookieSessionStore;
use actix_session::{Session, SessionMiddleware};
use actix_web::cookie::{Cookie, Key};
use actix_web::{http, test, web, App, HttpResponse};
fn key() -> Key {
Key::from(&[0u8; 64])
}
#[actix_web::test]
async fn test_no_session_passes_through() {
let app = test::init_service(
App::new()
.wrap(IdleTimeoutMiddleware::new(60))
.wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
.route(
"/p",
web::get().to(|| async { HttpResponse::Ok().finish() }),
),
)
.await;
let req = test::TestRequest::get().uri("/p").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
}
#[actix_web::test]
async fn test_idle_session_redirects_to_signin() {
let app = test::init_service(
App::new()
.wrap(IdleTimeoutMiddleware::new(1))
.wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
.route(
"/seed",
web::get().to(|s: Session| async move {
crate::helpers::session::set_session_user(
&s,
&crate::helpers::session::SessionUser {
id: "42".to_string(),
name: "Ada".to_string(),
email: "ada@example.test".to_string(),
},
);
s.insert(SESSION_KEY_LAST_ACTIVE, now_secs().saturating_sub(100))
.unwrap();
HttpResponse::Ok().finish()
}),
)
.route(
"/protected",
web::get().to(|| async { HttpResponse::Ok().finish() }),
),
)
.await;
let seed = test::TestRequest::get().uri("/seed").to_request();
let seed_resp = test::call_service(&app, seed).await;
assert_eq!(seed_resp.status(), http::StatusCode::OK);
let cookie = Cookie::parse_encoded(
seed_resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap(),
)
.unwrap();
let req = test::TestRequest::get()
.cookie(cookie)
.uri("/protected")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), http::StatusCode::FOUND);
assert!(
resp.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|l| l.contains("/signin?reason=idle"))
.unwrap_or(false),
"expected redirect to /signin?reason=idle"
);
}
#[actix_web::test]
async fn test_active_session_refreshes_last_active() {
let app = test::init_service(
App::new()
.wrap(IdleTimeoutMiddleware::new(3600))
.wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
.route(
"/seed",
web::get().to(|s: Session| async move {
crate::helpers::session::set_session_user(
&s,
&crate::helpers::session::SessionUser {
id: "42".to_string(),
name: "Ada".to_string(),
email: "ada@example.test".to_string(),
},
);
s.insert(SESSION_KEY_LAST_ACTIVE, now_secs().saturating_sub(10))
.unwrap();
HttpResponse::Ok().finish()
}),
)
.route(
"/protected",
web::get().to(|| async { HttpResponse::Ok().finish() }),
),
)
.await;
let seed = test::TestRequest::get().uri("/seed").to_request();
let seed_resp = test::call_service(&app, seed).await;
let cookie = Cookie::parse_encoded(
seed_resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap(),
)
.unwrap();
let req = test::TestRequest::get()
.cookie(cookie)
.uri("/protected")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), http::StatusCode::OK);
}
}
#[cfg(test)]
mod env_tests {
use super::{IdleTimeoutMiddleware, DEFAULT_IDLE_TIMEOUT_SECS};
use serial_test::serial;
use std::env;
#[test]
#[serial]
fn test_from_env_uses_default_when_unset() {
env::remove_var("SESSION_IDLE_TIMEOUT_SECS");
assert_eq!(
IdleTimeoutMiddleware::from_env().seconds,
DEFAULT_IDLE_TIMEOUT_SECS
);
}
#[test]
#[serial]
fn test_from_env_parses_value() {
env::set_var("SESSION_IDLE_TIMEOUT_SECS", "300");
assert_eq!(IdleTimeoutMiddleware::from_env().seconds, 300);
env::remove_var("SESSION_IDLE_TIMEOUT_SECS");
}
}