arc_web/http/middlewares/
idle_timeout_middleware.rs1use actix_session::SessionExt;
27use actix_web::body::EitherBody;
28use actix_web::{
29 dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
30 Error, HttpResponse,
31};
32use futures_util::future::LocalBoxFuture;
33use std::future::{ready, Ready};
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use crate::helpers::session::get_session_user;
37
38pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900;
40
41const SESSION_KEY_LAST_ACTIVE: &str = "last_active_at";
42
43#[derive(Debug, Clone, Copy)]
45pub struct IdleTimeoutMiddleware {
46 pub seconds: u64,
47}
48
49impl IdleTimeoutMiddleware {
50 pub fn new(seconds: u64) -> Self {
51 Self { seconds }
52 }
53
54 pub fn from_env() -> Self {
56 let secs = std::env::var("SESSION_IDLE_TIMEOUT_SECS")
57 .ok()
58 .and_then(|v| v.parse::<u64>().ok())
59 .unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS);
60 Self::new(secs)
61 }
62}
63
64impl Default for IdleTimeoutMiddleware {
65 fn default() -> Self {
66 Self::new(DEFAULT_IDLE_TIMEOUT_SECS)
67 }
68}
69
70impl<S, B> Transform<S, ServiceRequest> for IdleTimeoutMiddleware
71where
72 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
73 S::Future: 'static,
74 B: 'static,
75{
76 type Response = ServiceResponse<EitherBody<B>>;
77 type Error = Error;
78 type InitError = ();
79 type Transform = IdleTimeoutCheck<S>;
80 type Future = Ready<Result<Self::Transform, Self::InitError>>;
81
82 fn new_transform(&self, service: S) -> Self::Future {
83 ready(Ok(IdleTimeoutCheck {
84 service,
85 seconds: self.seconds,
86 }))
87 }
88}
89
90pub struct IdleTimeoutCheck<S> {
92 service: S,
93 seconds: u64,
94}
95
96fn now_secs() -> u64 {
97 SystemTime::now()
98 .duration_since(UNIX_EPOCH)
99 .map(|d| d.as_secs())
100 .unwrap_or(0)
101}
102
103impl<S, B> Service<ServiceRequest> for IdleTimeoutCheck<S>
104where
105 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
106 S::Future: 'static,
107 B: 'static,
108{
109 type Response = ServiceResponse<EitherBody<B>>;
110 type Error = Error;
111 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
112
113 forward_ready!(service);
114
115 fn call(&self, req: ServiceRequest) -> Self::Future {
116 let session = req.get_session();
117 let limit = self.seconds;
118
119 let user_present = get_session_user(&session).is_some();
121 if !user_present {
122 let fut = self.service.call(req);
123 return Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) });
124 }
125
126 let now = now_secs();
127 let last_active: Option<u64> = session.get(SESSION_KEY_LAST_ACTIVE).ok().flatten();
128
129 match last_active {
130 None => {
131 let _ = session.insert(SESSION_KEY_LAST_ACTIVE, now);
132 }
133 Some(prev) if now.saturating_sub(prev) > limit => {
134 tracing::info!(
135 idle_for_secs = now.saturating_sub(prev),
136 limit_secs = limit,
137 "session exceeded idle timeout — purging"
138 );
139 session.purge();
140 return Box::pin(async move {
141 Ok(req.into_response(
142 HttpResponse::Found()
143 .insert_header(("Location", "/signin?reason=idle"))
144 .finish()
145 .map_into_right_body(),
146 ))
147 });
148 }
149 Some(_) => {
150 let _ = session.insert(SESSION_KEY_LAST_ACTIVE, now);
151 }
152 }
153
154 let fut = self.service.call(req);
155 Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use actix_session::storage::CookieSessionStore;
163 use actix_session::{Session, SessionMiddleware};
164 use actix_web::cookie::{Cookie, Key};
165 use actix_web::{http, test, web, App, HttpResponse};
166
167 fn key() -> Key {
168 Key::from(&[0u8; 64])
169 }
170
171 #[actix_web::test]
172 async fn test_no_session_passes_through() {
173 let app = test::init_service(
174 App::new()
175 .wrap(IdleTimeoutMiddleware::new(60))
176 .wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
177 .route(
178 "/p",
179 web::get().to(|| async { HttpResponse::Ok().finish() }),
180 ),
181 )
182 .await;
183 let req = test::TestRequest::get().uri("/p").to_request();
184 let resp = test::call_service(&app, req).await;
185 assert_eq!(resp.status(), http::StatusCode::OK);
186 }
187
188 #[actix_web::test]
189 async fn test_idle_session_redirects_to_signin() {
190 let app = test::init_service(
191 App::new()
192 .wrap(IdleTimeoutMiddleware::new(1))
193 .wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
194 .route(
195 "/seed",
196 web::get().to(|s: Session| async move {
197 crate::helpers::session::set_session_user(
198 &s,
199 &crate::helpers::session::SessionUser {
200 id: "42".to_string(),
201 name: "Ada".to_string(),
202 email: "ada@example.test".to_string(),
203 },
204 );
205 s.insert(SESSION_KEY_LAST_ACTIVE, now_secs().saturating_sub(100))
207 .unwrap();
208 HttpResponse::Ok().finish()
209 }),
210 )
211 .route(
212 "/protected",
213 web::get().to(|| async { HttpResponse::Ok().finish() }),
214 ),
215 )
216 .await;
217
218 let seed = test::TestRequest::get().uri("/seed").to_request();
219 let seed_resp = test::call_service(&app, seed).await;
220 assert_eq!(seed_resp.status(), http::StatusCode::OK);
221 let cookie = Cookie::parse_encoded(
222 seed_resp
223 .headers()
224 .get("set-cookie")
225 .unwrap()
226 .to_str()
227 .unwrap(),
228 )
229 .unwrap();
230
231 let req = test::TestRequest::get()
232 .cookie(cookie)
233 .uri("/protected")
234 .to_request();
235 let resp = test::call_service(&app, req).await;
236 assert_eq!(resp.status(), http::StatusCode::FOUND);
237 assert!(
238 resp.headers()
239 .get("location")
240 .and_then(|v| v.to_str().ok())
241 .map(|l| l.contains("/signin?reason=idle"))
242 .unwrap_or(false),
243 "expected redirect to /signin?reason=idle"
244 );
245 }
246
247 #[actix_web::test]
248 async fn test_active_session_refreshes_last_active() {
249 let app = test::init_service(
250 App::new()
251 .wrap(IdleTimeoutMiddleware::new(3600))
252 .wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
253 .route(
254 "/seed",
255 web::get().to(|s: Session| async move {
256 crate::helpers::session::set_session_user(
257 &s,
258 &crate::helpers::session::SessionUser {
259 id: "42".to_string(),
260 name: "Ada".to_string(),
261 email: "ada@example.test".to_string(),
262 },
263 );
264 s.insert(SESSION_KEY_LAST_ACTIVE, now_secs().saturating_sub(10))
265 .unwrap();
266 HttpResponse::Ok().finish()
267 }),
268 )
269 .route(
270 "/protected",
271 web::get().to(|| async { HttpResponse::Ok().finish() }),
272 ),
273 )
274 .await;
275
276 let seed = test::TestRequest::get().uri("/seed").to_request();
277 let seed_resp = test::call_service(&app, seed).await;
278 let cookie = Cookie::parse_encoded(
279 seed_resp
280 .headers()
281 .get("set-cookie")
282 .unwrap()
283 .to_str()
284 .unwrap(),
285 )
286 .unwrap();
287
288 let req = test::TestRequest::get()
289 .cookie(cookie)
290 .uri("/protected")
291 .to_request();
292 let resp = test::call_service(&app, req).await;
293 assert_eq!(resp.status(), http::StatusCode::OK);
294 }
295}
296
297#[cfg(test)]
298mod env_tests {
299 use super::{IdleTimeoutMiddleware, DEFAULT_IDLE_TIMEOUT_SECS};
300 use serial_test::serial;
301 use std::env;
302
303 #[test]
304 #[serial]
305 fn test_from_env_uses_default_when_unset() {
306 env::remove_var("SESSION_IDLE_TIMEOUT_SECS");
307 assert_eq!(
308 IdleTimeoutMiddleware::from_env().seconds,
309 DEFAULT_IDLE_TIMEOUT_SECS
310 );
311 }
312
313 #[test]
314 #[serial]
315 fn test_from_env_parses_value() {
316 env::set_var("SESSION_IDLE_TIMEOUT_SECS", "300");
317 assert_eq!(IdleTimeoutMiddleware::from_env().seconds, 300);
318 env::remove_var("SESSION_IDLE_TIMEOUT_SECS");
319 }
320}