Skip to content

Commit 935400d

Browse files
committed
Experimental feature to timeout inactive sessions
1 parent 2ae36b4 commit 935400d

File tree

11 files changed

+181
-5
lines changed

11 files changed

+181
-5
lines changed

crates/cli/src/commands/server.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ impl Options {
166166
&mailer,
167167
homeserver_connection.clone(),
168168
url_builder.clone(),
169+
&site_config,
169170
shutdown.soft_shutdown_token(),
170171
shutdown.task_tracker(),
171172
)

crates/cli/src/commands/worker.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ impl Options {
7373
&mailer,
7474
conn,
7575
url_builder,
76+
&site_config,
7677
shutdown.soft_shutdown_token(),
7778
shutdown.task_tracker(),
7879
)

crates/cli/src/util.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use mas_config::{
1212
EmailTransportKind, ExperimentalConfig, MatrixConfig, PasswordsConfig, PolicyConfig,
1313
TemplatesConfig,
1414
};
15-
use mas_data_model::SiteConfig;
15+
use mas_data_model::{SessionExpirationConfig, SiteConfig};
1616
use mas_email::{MailTransport, Mailer};
1717
use mas_handlers::passwords::PasswordManager;
1818
use mas_policy::PolicyFactory;
@@ -180,6 +180,15 @@ pub fn site_config_from_config(
180180
captcha_config: &CaptchaConfig,
181181
) -> Result<SiteConfig, anyhow::Error> {
182182
let captcha = captcha_config_from_config(captcha_config)?;
183+
let session_expiration = experimental_config
184+
.inactive_session_expiration
185+
.as_ref()
186+
.map(|c| SessionExpirationConfig {
187+
oauth_session_inactivity_ttl: c.expire_oauth_sessions.then_some(c.ttl),
188+
compat_session_inactivity_ttl: c.expire_compat_sessions.then_some(c.ttl),
189+
user_session_inactivity_ttl: c.expire_user_sessions.then_some(c.ttl),
190+
});
191+
183192
Ok(SiteConfig {
184193
access_token_ttl: experimental_config.access_token_ttl,
185194
compat_token_ttl: experimental_config.compat_token_ttl,
@@ -198,6 +207,7 @@ pub fn site_config_from_config(
198207
&& account_config.password_recovery_enabled,
199208
captcha,
200209
minimum_password_complexity: password_config.minimum_complexity(),
210+
session_expiration,
201211
})
202212
}
203213

crates/config/src/sections/experimental.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ use serde_with::serde_as;
1111

1212
use crate::ConfigurationSection;
1313

14+
fn default_true() -> bool {
15+
true
16+
}
17+
1418
fn default_token_ttl() -> Duration {
1519
Duration::microseconds(5 * 60 * 1000 * 1000)
1620
}
@@ -19,11 +23,32 @@ fn is_default_token_ttl(value: &Duration) -> bool {
1923
*value == default_token_ttl()
2024
}
2125

26+
/// Configuration options for the inactive session expiration feature
27+
#[serde_as]
28+
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
29+
pub struct InactiveSessionExpirationConfig {
30+
/// Time after which an inactive session is automatically finished
31+
#[schemars(with = "u64", range(min = 600, max = 7_776_000))]
32+
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
33+
pub ttl: Duration,
34+
35+
/// Should compatibility sessions expire after inactivity
36+
#[serde(default = "default_true")]
37+
pub expire_compat_sessions: bool,
38+
39+
/// Should OAuth 2.0 sessions expire after inactivity
40+
#[serde(default = "default_true")]
41+
pub expire_oauth_sessions: bool,
42+
43+
/// Should user sessions expire after inactivity
44+
#[serde(default = "default_true")]
45+
pub expire_user_sessions: bool,
46+
}
47+
2248
/// Configuration sections for experimental options
2349
///
2450
/// Do not change these options unless you know what you are doing.
2551
#[serde_as]
26-
#[allow(clippy::struct_excessive_bools)]
2752
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
2853
pub struct ExperimentalConfig {
2954
/// Time-to-live of access tokens in seconds. Defaults to 5 minutes.
@@ -44,20 +69,29 @@ pub struct ExperimentalConfig {
4469
)]
4570
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
4671
pub compat_token_ttl: Duration,
72+
73+
/// Experimetal feature to automatically expire inactive sessions
74+
///
75+
/// Disabled by default
76+
#[serde(skip_serializing_if = "Option::is_none")]
77+
pub inactive_session_expiration: Option<InactiveSessionExpirationConfig>,
4778
}
4879

4980
impl Default for ExperimentalConfig {
5081
fn default() -> Self {
5182
Self {
5283
access_token_ttl: default_token_ttl(),
5384
compat_token_ttl: default_token_ttl(),
85+
inactive_session_expiration: None,
5486
}
5587
}
5688
}
5789

5890
impl ExperimentalConfig {
5991
pub(crate) fn is_default(&self) -> bool {
60-
is_default_token_ttl(&self.access_token_ttl) && is_default_token_ttl(&self.compat_token_ttl)
92+
is_default_token_ttl(&self.access_token_ttl)
93+
&& is_default_token_ttl(&self.compat_token_ttl)
94+
&& self.inactive_session_expiration.is_none()
6195
}
6296
}
6397

crates/data-model/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub use self::{
3232
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant,
3333
DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState,
3434
},
35-
site_config::{CaptchaConfig, CaptchaService, SiteConfig},
35+
site_config::{CaptchaConfig, CaptchaService, SessionExpirationConfig, SiteConfig},
3636
tokens::{
3737
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
3838
},

crates/data-model/src/site_config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ pub struct CaptchaConfig {
2828
pub secret_key: String,
2929
}
3030

31+
/// Automatic session expiration configuration
32+
#[derive(Debug, Clone)]
33+
pub struct SessionExpirationConfig {
34+
pub user_session_inactivity_ttl: Option<Duration>,
35+
pub oauth_session_inactivity_ttl: Option<Duration>,
36+
pub compat_session_inactivity_ttl: Option<Duration>,
37+
}
38+
3139
/// Random site configuration we want accessible in various places.
3240
#[allow(clippy::struct_excessive_bools)]
3341
#[derive(Debug, Clone)]
@@ -74,4 +82,6 @@ pub struct SiteConfig {
7482
/// Minimum password complexity, between 0 and 4.
7583
/// This is a score from zxcvbn.
7684
pub minimum_password_complexity: u8,
85+
86+
pub session_expiration: Option<SessionExpirationConfig>,
7787
}

crates/handlers/src/test_utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ pub fn test_site_config() -> SiteConfig {
139139
account_recovery_allowed: true,
140140
captcha: None,
141141
minimum_password_complexity: 1,
142+
session_expiration: None,
142143
}
143144
}
144145

crates/storage/src/queue/tasks.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,17 @@ impl InsertableJob for CleanupExpiredTokensJob {
325325
const QUEUE_NAME: &'static str = "cleanup-expired-tokens";
326326
}
327327

328+
/// Scheduled job to expire inactive sessions
329+
///
330+
/// This job will trigger jobs to expire inactive compat, oauth and user
331+
/// sessions.
332+
#[derive(Serialize, Deserialize, Debug, Clone)]
333+
pub struct ExpireInactiveSessionsJob;
334+
335+
impl InsertableJob for ExpireInactiveSessionsJob {
336+
const QUEUE_NAME: &'static str = "expire-inactive-sessions";
337+
}
338+
328339
/// Expire inactive OAuth 2.0 sessions
329340
#[derive(Serialize, Deserialize, Debug, Clone)]
330341
pub struct ExpireInactiveOAuthSessionsJob {

crates/tasks/src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use std::sync::{Arc, LazyLock};
88

9+
use mas_data_model::SiteConfig;
910
use mas_email::Mailer;
1011
use mas_matrix::HomeserverConnection;
1112
use mas_router::UrlBuilder;
@@ -41,6 +42,7 @@ struct State {
4142
clock: SystemClock,
4243
homeserver: Arc<dyn HomeserverConnection<Error = anyhow::Error>>,
4344
url_builder: UrlBuilder,
45+
site_config: SiteConfig,
4446
}
4547

4648
impl State {
@@ -50,13 +52,15 @@ impl State {
5052
mailer: Mailer,
5153
homeserver: impl HomeserverConnection<Error = anyhow::Error> + 'static,
5254
url_builder: UrlBuilder,
55+
site_config: SiteConfig,
5356
) -> Self {
5457
Self {
5558
pool,
5659
mailer,
5760
clock,
5861
homeserver: Arc::new(homeserver),
5962
url_builder,
63+
site_config,
6064
}
6165
}
6266

@@ -94,6 +98,10 @@ impl State {
9498
pub fn url_builder(&self) -> &UrlBuilder {
9599
&self.url_builder
96100
}
101+
102+
pub fn site_config(&self) -> &SiteConfig {
103+
&self.site_config
104+
}
97105
}
98106

99107
/// Initialise the workers.
@@ -106,6 +114,7 @@ pub async fn init(
106114
mailer: &Mailer,
107115
homeserver: impl HomeserverConnection<Error = anyhow::Error> + 'static,
108116
url_builder: UrlBuilder,
117+
site_config: &SiteConfig,
109118
cancellation_token: CancellationToken,
110119
task_tracker: &TaskTracker,
111120
) -> Result<(), QueueRunnerError> {
@@ -115,6 +124,7 @@ pub async fn init(
115124
mailer.clone(),
116125
homeserver,
117126
url_builder,
127+
site_config.clone(),
118128
);
119129
let mut worker = self::new_queue::QueueWorker::new(state, cancellation_token).await?;
120130

@@ -129,13 +139,20 @@ pub async fn init(
129139
.register_handler::<mas_storage::queue::SendEmailAuthenticationCodeJob>()
130140
.register_handler::<mas_storage::queue::SyncDevicesJob>()
131141
.register_handler::<mas_storage::queue::VerifyEmailJob>()
142+
.register_handler::<mas_storage::queue::ExpireInactiveSessionsJob>()
132143
.register_handler::<mas_storage::queue::ExpireInactiveCompatSessionsJob>()
133144
.register_handler::<mas_storage::queue::ExpireInactiveOAuthSessionsJob>()
134145
.register_handler::<mas_storage::queue::ExpireInactiveUserSessionsJob>()
135146
.add_schedule(
136147
"cleanup-expired-tokens",
137148
"0 0 * * * *".parse()?,
138149
mas_storage::queue::CleanupExpiredTokensJob,
150+
)
151+
.add_schedule(
152+
"expire-inactive-sessions",
153+
// Run this job every 15 minutes
154+
"30 */15 * * * *".parse()?,
155+
mas_storage::queue::ExpireInactiveSessionsJob,
139156
);
140157

141158
task_tracker.spawn(worker.run());

crates/tasks/src/sessions.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use mas_storage::{
1111
compat::CompatSessionFilter,
1212
oauth2::OAuth2SessionFilter,
1313
queue::{
14-
ExpireInactiveCompatSessionsJob, ExpireInactiveOAuthSessionsJob,
14+
ExpireInactiveCompatSessionsJob, ExpireInactiveOAuthSessionsJob, ExpireInactiveSessionsJob,
1515
ExpireInactiveUserSessionsJob, QueueJobRepositoryExt, SyncDevicesJob,
1616
},
1717
user::BrowserSessionFilter,
@@ -22,6 +22,58 @@ use crate::{
2222
State,
2323
};
2424

25+
#[async_trait]
26+
impl RunnableJob for ExpireInactiveSessionsJob {
27+
async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> {
28+
let Some(config) = state.site_config().session_expiration.as_ref() else {
29+
// Automatic session expiration is disabled
30+
return Ok(());
31+
};
32+
33+
let clock = state.clock();
34+
let mut rng = state.rng();
35+
let now = clock.now();
36+
let mut repo = state.repository().await.map_err(JobError::retry)?;
37+
38+
if let Some(ttl) = config.oauth_session_inactivity_ttl {
39+
repo.queue_job()
40+
.schedule_job(
41+
&mut rng,
42+
&clock,
43+
ExpireInactiveOAuthSessionsJob::new(now - ttl),
44+
)
45+
.await
46+
.map_err(JobError::retry)?;
47+
}
48+
49+
if let Some(ttl) = config.compat_session_inactivity_ttl {
50+
repo.queue_job()
51+
.schedule_job(
52+
&mut rng,
53+
&clock,
54+
ExpireInactiveCompatSessionsJob::new(now - ttl),
55+
)
56+
.await
57+
.map_err(JobError::retry)?;
58+
}
59+
60+
if let Some(ttl) = config.user_session_inactivity_ttl {
61+
repo.queue_job()
62+
.schedule_job(
63+
&mut rng,
64+
&clock,
65+
ExpireInactiveUserSessionsJob::new(now - ttl),
66+
)
67+
.await
68+
.map_err(JobError::retry)?;
69+
}
70+
71+
repo.save().await.map_err(JobError::retry)?;
72+
73+
Ok(())
74+
}
75+
}
76+
2577
#[async_trait]
2678
impl RunnableJob for ExpireInactiveOAuthSessionsJob {
2779
async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> {

0 commit comments

Comments
 (0)