Skip to content

Commit

Permalink
Experimental feature to automatically expire inactive sessions (#4022)
Browse files Browse the repository at this point in the history
Fixes #1875 

This adds an experimental feature which allows expiring sessions that
are inactive for a certain amount of time.

It runs as a scheduled task every 15 minutes, checking for the 'last
activity' on each session type.
It processes sessions by batches of 100 at a time, to avoid overloading
Synapse when syncing back the database.

It expires:

 - all user (browser) sessions
 - all compatibility sessions
 - oauth sessions which are:
   - for a user
   - using a 'dynamic' client (so the sessions started from clients defined
      in the config are excluded)
  • Loading branch information
sandhose authored Feb 13, 2025
2 parents 871000f + 7bfb1a1 commit f2ef058
Show file tree
Hide file tree
Showing 18 changed files with 725 additions and 7 deletions.
1 change: 1 addition & 0 deletions crates/cli/src/commands/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ impl Options {
&mailer,
homeserver_connection.clone(),
url_builder.clone(),
&site_config,
shutdown.soft_shutdown_token(),
shutdown.task_tracker(),
)
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ impl Options {
&mailer,
conn,
url_builder,
&site_config,
shutdown.soft_shutdown_token(),
shutdown.task_tracker(),
)
Expand Down
12 changes: 11 additions & 1 deletion crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use mas_config::{
EmailTransportKind, ExperimentalConfig, MatrixConfig, PasswordsConfig, PolicyConfig,
TemplatesConfig,
};
use mas_data_model::SiteConfig;
use mas_data_model::{SessionExpirationConfig, SiteConfig};
use mas_email::{MailTransport, Mailer};
use mas_handlers::passwords::PasswordManager;
use mas_policy::PolicyFactory;
Expand Down Expand Up @@ -180,6 +180,15 @@ pub fn site_config_from_config(
captcha_config: &CaptchaConfig,
) -> Result<SiteConfig, anyhow::Error> {
let captcha = captcha_config_from_config(captcha_config)?;
let session_expiration = experimental_config
.inactive_session_expiration
.as_ref()
.map(|c| SessionExpirationConfig {
oauth_session_inactivity_ttl: c.expire_oauth_sessions.then_some(c.ttl),
compat_session_inactivity_ttl: c.expire_compat_sessions.then_some(c.ttl),
user_session_inactivity_ttl: c.expire_user_sessions.then_some(c.ttl),
});

Ok(SiteConfig {
access_token_ttl: experimental_config.access_token_ttl,
compat_token_ttl: experimental_config.compat_token_ttl,
Expand All @@ -198,6 +207,7 @@ pub fn site_config_from_config(
&& account_config.password_recovery_enabled,
captcha,
minimum_password_complexity: password_config.minimum_complexity(),
session_expiration,
})
}

Expand Down
38 changes: 36 additions & 2 deletions crates/config/src/sections/experimental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ use serde_with::serde_as;

use crate::ConfigurationSection;

fn default_true() -> bool {
true
}

fn default_token_ttl() -> Duration {
Duration::microseconds(5 * 60 * 1000 * 1000)
}
Expand All @@ -19,11 +23,32 @@ fn is_default_token_ttl(value: &Duration) -> bool {
*value == default_token_ttl()
}

/// Configuration options for the inactive session expiration feature
#[serde_as]
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
pub struct InactiveSessionExpirationConfig {
/// Time after which an inactive session is automatically finished
#[schemars(with = "u64", range(min = 600, max = 7_776_000))]
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub ttl: Duration,

/// Should compatibility sessions expire after inactivity
#[serde(default = "default_true")]
pub expire_compat_sessions: bool,

/// Should OAuth 2.0 sessions expire after inactivity
#[serde(default = "default_true")]
pub expire_oauth_sessions: bool,

/// Should user sessions expire after inactivity
#[serde(default = "default_true")]
pub expire_user_sessions: bool,
}

/// Configuration sections for experimental options
///
/// Do not change these options unless you know what you are doing.
#[serde_as]
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
pub struct ExperimentalConfig {
/// Time-to-live of access tokens in seconds. Defaults to 5 minutes.
Expand All @@ -44,20 +69,29 @@ pub struct ExperimentalConfig {
)]
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub compat_token_ttl: Duration,

/// Experimetal feature to automatically expire inactive sessions
///
/// Disabled by default
#[serde(skip_serializing_if = "Option::is_none")]
pub inactive_session_expiration: Option<InactiveSessionExpirationConfig>,
}

impl Default for ExperimentalConfig {
fn default() -> Self {
Self {
access_token_ttl: default_token_ttl(),
compat_token_ttl: default_token_ttl(),
inactive_session_expiration: None,
}
}
}

impl ExperimentalConfig {
pub(crate) fn is_default(&self) -> bool {
is_default_token_ttl(&self.access_token_ttl) && is_default_token_ttl(&self.compat_token_ttl)
is_default_token_ttl(&self.access_token_ttl)
&& is_default_token_ttl(&self.compat_token_ttl)
&& self.inactive_session_expiration.is_none()
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/data-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub use self::{
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant,
DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState,
},
site_config::{CaptchaConfig, CaptchaService, SiteConfig},
site_config::{CaptchaConfig, CaptchaService, SessionExpirationConfig, SiteConfig},
tokens::{
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
},
Expand Down
10 changes: 10 additions & 0 deletions crates/data-model/src/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ pub struct CaptchaConfig {
pub secret_key: String,
}

/// Automatic session expiration configuration
#[derive(Debug, Clone)]
pub struct SessionExpirationConfig {
pub user_session_inactivity_ttl: Option<Duration>,
pub oauth_session_inactivity_ttl: Option<Duration>,
pub compat_session_inactivity_ttl: Option<Duration>,
}

/// Random site configuration we want accessible in various places.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -74,4 +82,6 @@ pub struct SiteConfig {
/// Minimum password complexity, between 0 and 4.
/// This is a score from zxcvbn.
pub minimum_password_complexity: u8,

pub session_expiration: Option<SessionExpirationConfig>,
}
31 changes: 31 additions & 0 deletions crates/handlers/src/admin/v1/oauth2_sessions/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ impl std::fmt::Display for OAuth2SessionStatus {
}
}

#[derive(Deserialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "snake_case")]
enum OAuth2ClientKind {
Dynamic,
Static,
}

impl std::fmt::Display for OAuth2ClientKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Dynamic => write!(f, "dynamic"),
Self::Static => write!(f, "static"),
}
}
}

#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
#[serde(rename = "OAuth2SessionFilter")]
#[aide(input_with = "Query<FilterParams>")]
Expand All @@ -61,6 +77,10 @@ pub struct FilterParams {
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
client: Option<Ulid>,

/// Retrieve the items only for a specific client kind
#[serde(rename = "filter[client-kind]")]
client_kind: Option<OAuth2ClientKind>,

/// Retrieve the items started from the given browser session
#[serde(rename = "filter[user-session]")]
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
Expand Down Expand Up @@ -95,6 +115,11 @@ impl std::fmt::Display for FilterParams {
sep = '&';
}

if let Some(client_kind) = self.client_kind {
write!(f, "{sep}filter[client-kind]={client_kind}")?;
sep = '&';
}

if let Some(user_session) = self.user_session {
write!(f, "{sep}filter[user-session]={user_session}")?;
sep = '&';
Expand Down Expand Up @@ -232,6 +257,12 @@ pub async fn handler(
None => filter,
};

let filter = match params.client_kind {
Some(OAuth2ClientKind::Dynamic) => filter.only_dynamic_clients(),
Some(OAuth2ClientKind::Static) => filter.only_static_clients(),
None => filter,
};

let user_session = if let Some(user_session_id) = params.user_session {
let user_session = repo
.browser_session()
Expand Down
1 change: 1 addition & 0 deletions crates/handlers/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ pub fn test_site_config() -> SiteConfig {
account_recovery_allowed: true,
captcha: None,
minimum_password_complexity: 1,
session_expiration: None,
}
}

Expand Down
9 changes: 9 additions & 0 deletions crates/storage-pg/src/iden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ pub enum OAuth2Sessions {
LastActiveIp,
}

#[derive(sea_query::Iden)]
#[iden = "oauth2_clients"]
pub enum OAuth2Clients {
Table,
#[iden = "oauth2_client_id"]
OAuth2ClientId,
IsStatic,
}

#[derive(sea_query::Iden)]
#[iden = "upstream_oauth_providers"]
pub enum UpstreamOAuthProviders {
Expand Down
2 changes: 1 addition & 1 deletion crates/storage-pg/src/oauth2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ mod tests {
let pagination = Pagination::first(10);

// First, list all the sessions
let filter = OAuth2SessionFilter::new();
let filter = OAuth2SessionFilter::new().for_any_user();
let list = repo
.oauth2_session()
.list(filter, pagination)
Expand Down
29 changes: 28 additions & 1 deletion crates/storage-pg/src/oauth2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use uuid::Uuid;

use crate::{
filter::{Filter, StatementExt},
iden::OAuth2Sessions,
iden::{OAuth2Clients, OAuth2Sessions},
pagination::QueryBuilderExt,
tracing::ExecuteExt,
DatabaseError, DatabaseInconsistencyError,
Expand Down Expand Up @@ -104,6 +104,26 @@ impl Filter for OAuth2SessionFilter<'_> {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
.eq(Uuid::from(client.id))
}))
.add_option(self.client_kind().map(|client_kind| {
// This builds either a:
// `WHERE oauth2_client_id = ANY(...)`
// or a `WHERE oauth2_client_id <> ALL(...)`
let static_clients = Query::select()
.expr(Expr::col((
OAuth2Clients::Table,
OAuth2Clients::OAuth2ClientId,
)))
.and_where(Expr::col((OAuth2Clients::Table, OAuth2Clients::IsStatic)).into())
.from(OAuth2Clients::Table)
.take();
if client_kind.is_static() {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
.eq(Expr::any(static_clients))
} else {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
.ne(Expr::all(static_clients))
}
}))
.add_option(self.device().map(|device| {
Expr::val(device.to_scope_token().to_string()).eq(PgFunc::any(Expr::col((
OAuth2Sessions::Table,
Expand All @@ -125,6 +145,13 @@ impl Filter for OAuth2SessionFilter<'_> {
let scope: Vec<String> = scope.iter().map(|s| s.as_str().to_owned()).collect();
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::ScopeList)).contains(scope)
}))
.add_option(self.any_user().map(|any_user| {
if any_user {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId)).is_not_null()
} else {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId)).is_null()
}
}))
.add_option(self.last_active_after().map(|last_active_after| {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveAt))
.gt(last_active_after)
Expand Down
58 changes: 58 additions & 0 deletions crates/storage/src/oauth2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,27 @@ impl OAuth2SessionState {
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ClientKind {
Static,
Dynamic,
}

impl ClientKind {
pub fn is_static(self) -> bool {
matches!(self, Self::Static)
}
}

/// Filter parameters for listing OAuth 2.0 sessions
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct OAuth2SessionFilter<'a> {
user: Option<&'a User>,
any_user: Option<bool>,
browser_session: Option<&'a BrowserSession>,
device: Option<&'a Device>,
client: Option<&'a Client>,
client_kind: Option<ClientKind>,
state: Option<OAuth2SessionState>,
scope: Option<&'a Scope>,
last_active_before: Option<DateTime<Utc>>,
Expand Down Expand Up @@ -66,6 +80,28 @@ impl<'a> OAuth2SessionFilter<'a> {
self.user
}

/// List sessions which belong to any user
#[must_use]
pub fn for_any_user(mut self) -> Self {
self.any_user = Some(true);
self
}

/// List sessions which belong to no user
#[must_use]
pub fn for_no_user(mut self) -> Self {
self.any_user = Some(false);
self
}

/// Get the 'any user' filter
///
/// Returns [`None`] if no 'any user' filter was set
#[must_use]
pub fn any_user(&self) -> Option<bool> {
self.any_user
}

/// List sessions started by a specific browser session
#[must_use]
pub fn for_browser_session(mut self, browser_session: &'a BrowserSession) -> Self {
Expand Down Expand Up @@ -96,6 +132,28 @@ impl<'a> OAuth2SessionFilter<'a> {
self.client
}

/// List only static clients
#[must_use]
pub fn only_static_clients(mut self) -> Self {
self.client_kind = Some(ClientKind::Static);
self
}

/// List only dynamic clients
#[must_use]
pub fn only_dynamic_clients(mut self) -> Self {
self.client_kind = Some(ClientKind::Dynamic);
self
}

/// Get the client kind filter
///
/// Returns [`None`] if no client kind filter was set
#[must_use]
pub fn client_kind(&self) -> Option<ClientKind> {
self.client_kind
}

/// Only return sessions with a last active time before the given time
#[must_use]
pub fn with_last_active_before(mut self, last_active_before: DateTime<Utc>) -> Self {
Expand Down
Loading

0 comments on commit f2ef058

Please sign in to comment.