Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading