diff --git a/crates/axum-utils/src/session.rs b/crates/axum-utils/src/session.rs index 332ad3c4f..98cbd4865 100644 --- a/crates/axum-utils/src/session.rs +++ b/crates/axum-utils/src/session.rs @@ -5,7 +5,7 @@ // Please see LICENSE in the repository root for full details. use mas_data_model::BrowserSession; -use mas_storage::{RepositoryAccess, user::BrowserSessionRepository}; +use mas_storage::RepositoryAccess; use serde::{Deserialize, Serialize}; use ulid::Ulid; @@ -33,13 +33,12 @@ impl SessionInfo { self } - /// Load the [`BrowserSession`] from database + /// Load the active [`BrowserSession`] from database /// /// # Errors /// - /// Returns an error if the session is not found or if the session is not - /// active anymore - pub async fn load_session( + /// Returns an error if the underlying repository fails to load the session. + pub async fn load_active_session( &self, repo: &mut impl RepositoryAccess, ) -> Result, E> { @@ -56,6 +55,12 @@ impl SessionInfo { Ok(maybe_session) } + + /// Get the current session ID, if any + #[must_use] + pub fn current_session_id(&self) -> Option { + self.current + } } pub trait SessionInfoExt { diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index c020fa720..41b6c4f70 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -21,14 +21,15 @@ pub struct User { pub sub: String, pub created_at: DateTime, pub locked_at: Option>, + pub deactivated_at: Option>, pub can_request_admin: bool, } impl User { - /// Returns `true` unless the user is locked. + /// Returns `true` unless the user is locked or deactivated. #[must_use] pub fn is_valid(&self) -> bool { - self.locked_at.is_none() + self.locked_at.is_none() && self.deactivated_at.is_none() } } @@ -42,6 +43,7 @@ impl User { sub: "123-456".to_owned(), created_at: now, locked_at: None, + deactivated_at: None, can_request_admin: false, }] } diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index f5fe6432f..856d5356b 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -13,7 +13,7 @@ use axum::{ }; use chrono::Duration; use mas_axum_utils::{ - FancyError, SessionInfoExt, + FancyError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -28,7 +28,10 @@ use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates}; use serde::{Deserialize, Serialize}; use ulid::Ulid; -use crate::PreferredLanguage; +use crate::{ + PreferredLanguage, + session::{SessionOrFallback, load_session_or_fallback}, +}; #[derive(Serialize)] struct AllParams<'s> { @@ -61,10 +64,20 @@ pub async fn get( Path(id): Path, Query(params): Query, ) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; - let maybe_session = session_info.load_session(&mut repo).await?; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let Some(session) = maybe_session else { // If there is no session, redirect to the login or register screen @@ -126,10 +139,20 @@ pub async fn post( Query(params): Query, Form(form): Form>, ) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - cookie_jar.verify_form(&clock, form)?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; - let maybe_session = session_info.load_session(&mut repo).await?; + cookie_jar.verify_form(&clock, form)?; let Some(session) = maybe_session else { // If there is no session, redirect to the login or register screen diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 013a37c54..abf8d7c4b 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -288,7 +288,7 @@ async fn get_requester( RequestingEntity::OAuth2Session(Box::new((session, user))) } else { - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if let Some(session) = maybe_session.as_ref() { activity_tracker diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 3a43fee42..3b7f15c02 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -64,6 +64,7 @@ mod activity_tracker; mod captcha; mod preferred_language; mod rate_limit; +mod session; #[cfg(test)] mod test_utils; diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs index c5aab02cb..bfd07531b 100644 --- a/crates/handlers/src/oauth2/authorization/complete.rs +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -97,7 +97,7 @@ pub(crate) async fn get( ) -> Result { let (session_info, cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string()); diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index bca78ec47..54d0641e3 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -176,7 +176,7 @@ pub(crate) async fn get( let callback_destination = callback_destination.clone(); let locale = locale.clone(); async move { - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; let prompt = params.auth.prompt.as_deref().unwrap_or_default(); // Check if the request/request_uri/registration params are used. If so, reply diff --git a/crates/handlers/src/oauth2/consent.rs b/crates/handlers/src/oauth2/consent.rs index 264a00583..599ba080d 100644 --- a/crates/handlers/src/oauth2/consent.rs +++ b/crates/handlers/src/oauth2/consent.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -11,7 +11,6 @@ use axum::{ use axum_extra::TypedHeader; use hyper::StatusCode; use mas_axum_utils::{ - SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, sentry::SentryEventID, @@ -27,7 +26,10 @@ use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Tem use thiserror::Error; use ulid::Ulid; -use crate::{BoundActivityTracker, PreferredLanguage, impl_from_error_for_route}; +use crate::{ + BoundActivityTracker, PreferredLanguage, impl_from_error_for_route, + session::{SessionOrFallback, load_session_or_fallback}, +}; #[derive(Debug, Error)] pub enum RouteError { @@ -54,6 +56,7 @@ impl_from_error_for_route!(mas_templates::TemplateError); impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_policy::LoadError); impl_from_error_for_route!(mas_policy::EvaluationError); +impl_from_error_for_route!(crate::session::SessionLoadError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { @@ -85,9 +88,18 @@ pub(crate) async fn get( cookie_jar: CookieJar, Path(grant_id): Path, ) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; let user_agent = user_agent.map(|ua| ua.to_string()); @@ -107,48 +119,48 @@ pub(crate) async fn get( return Err(RouteError::GrantNotPending); } - if let Some(session) = maybe_session { - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - - let res = policy - .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { - user: Some(&session.user), - client: &client, - scope: &grant.scope, - grant_type: mas_policy::GrantType::AuthorizationCode, - requester: mas_policy::Requester { - ip_address: activity_tracker.ip(), - user_agent, - }, - }) - .await?; - - if res.valid() { - let ctx = ConsentContext::new(grant, client) - .with_session(session) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - - let content = templates.render_consent(&ctx)?; - - Ok((cookie_jar, Html(content)).into_response()) - } else { - let ctx = PolicyViolationContext::for_authorization_grant(grant, client) - .with_session(session) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - - let content = templates.render_policy_violation(&ctx)?; - - Ok((cookie_jar, Html(content)).into_response()) - } - } else { + let Some(session) = maybe_session else { let login = mas_router::Login::and_continue_grant(grant_id); - Ok((cookie_jar, url_builder.redirect(&login)).into_response()) + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); + }; + + activity_tracker + .record_browser_session(&clock, &session) + .await; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let res = policy + .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { + user: Some(&session.user), + client: &client, + scope: &grant.scope, + grant_type: mas_policy::GrantType::AuthorizationCode, + requester: mas_policy::Requester { + ip_address: activity_tracker.ip(), + user_agent, + }, + }) + .await?; + + if res.valid() { + let ctx = ConsentContext::new(grant, client) + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_consent(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) + } else { + let ctx = PolicyViolationContext::for_authorization_grant(grant, client) + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_policy_violation(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) } } @@ -161,6 +173,8 @@ pub(crate) async fn get( pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -172,9 +186,18 @@ pub(crate) async fn post( ) -> Result { cookie_jar.verify_form(&clock, form)?; - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; let user_agent = user_agent.map(|ua| ua.to_string()); diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 4b2262bd9..3f46c7a38 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -12,7 +12,7 @@ use axum::{ }; use axum_extra::TypedHeader; use mas_axum_utils::{ - FancyError, SessionInfoExt, + FancyError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -24,7 +24,10 @@ use serde::Deserialize; use tracing::warn; use ulid::Ulid; -use crate::{BoundActivityTracker, PreferredLanguage}; +use crate::{ + BoundActivityTracker, PreferredLanguage, + session::{SessionOrFallback, load_session_or_fallback}, +}; #[derive(Deserialize, Debug)] #[serde(rename_all = "lowercase")] @@ -51,10 +54,20 @@ pub(crate) async fn get( cookie_jar: CookieJar, Path(grant_id): Path, ) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; - let maybe_session = session_info.load_session(&mut repo).await?; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let user_agent = user_agent.map(|ua| ua.to_string()); @@ -137,12 +150,21 @@ pub(crate) async fn post( Path(grant_id): Path, Form(form): Form>, ) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); let form = cookie_jar.verify_form(&clock, form)?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let maybe_session = session_info.load_session(&mut repo).await?; - let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string()); let Some(session) = maybe_session else { diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index e011214ca..bb5642036 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -327,6 +327,7 @@ mod tests { sub: "123-456".to_owned(), created_at: now, locked_at: None, + deactivated_at: None, can_request_admin: false, }; @@ -336,6 +337,7 @@ mod tests { sub: "123-456".to_owned(), created_at: now, locked_at: None, + deactivated_at: None, can_request_admin: false, }; diff --git a/crates/handlers/src/session.rs b/crates/handlers/src/session.rs new file mode 100644 index 000000000..9eac19307 --- /dev/null +++ b/crates/handlers/src/session.rs @@ -0,0 +1,104 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! Utilities for showing proposer HTML fallbacks when the user is logged out, +//! locked or deactivated + +use axum::response::{Html, IntoResponse as _, Response}; +use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt}; +use mas_data_model::BrowserSession; +use mas_i18n::DataLocale; +use mas_storage::{BoxRepository, Clock, RepositoryError}; +use mas_templates::{AccountInactiveContext, TemplateContext, Templates}; +use rand::RngCore; +use thiserror::Error; + +#[derive(Debug, Error)] +#[error(transparent)] +pub enum SessionLoadError { + Template(#[from] mas_templates::TemplateError), + Repository(#[from] RepositoryError), +} + +#[allow(clippy::large_enum_variant)] +pub enum SessionOrFallback { + MaybeSession { + cookie_jar: CookieJar, + maybe_session: Option, + }, + Fallback { + response: Response, + }, +} + +/// Load a session from the cookie jar, or fall back to an HTML error page if +/// the account is locked, deactivated or logged out +pub async fn load_session_or_fallback( + cookie_jar: CookieJar, + clock: &impl Clock, + rng: impl RngCore, + templates: &Templates, + locale: &DataLocale, + repo: &mut BoxRepository, +) -> Result { + let (session_info, cookie_jar) = cookie_jar.session_info(); + let Some(session_id) = session_info.current_session_id() else { + return Ok(SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session: None, + }); + }; + + let Some(session) = repo.browser_session().lookup(session_id).await? else { + // We looked up the session, but it was not found. Still update the cookie + let session_info = session_info.mark_session_ended(); + let cookie_jar = cookie_jar.update_session_info(&session_info); + return Ok(SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session: None, + }); + }; + + if session.user.deactivated_at.is_some() { + // The account is deactivated, show the 'account deactivated' fallback + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); + let ctx = AccountInactiveContext::new(session.user) + .with_csrf(csrf_token.form_value()) + .with_language(locale.clone()); + let fallback = templates.render_account_deactivated(&ctx)?; + let response = (cookie_jar, Html(fallback)).into_response(); + return Ok(SessionOrFallback::Fallback { response }); + } + + if session.user.locked_at.is_some() { + // The account is locked, show the 'account locked' fallback + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); + let ctx = AccountInactiveContext::new(session.user) + .with_csrf(csrf_token.form_value()) + .with_language(locale.clone()); + let fallback = templates.render_account_locked(&ctx)?; + let response = (cookie_jar, Html(fallback)).into_response(); + return Ok(SessionOrFallback::Fallback { response }); + } + + if session.finished_at.is_some() { + // The session has finished, but the browser still has the cookie. This is + // likely a 'remote' logout, triggered either by an admin or from the + // user-management UI. In this case, we show the 'account logged out' + // fallback. + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); + let ctx = AccountInactiveContext::new(session.user) + .with_csrf(csrf_token.form_value()) + .with_language(locale.clone()); + let fallback = templates.render_account_logged_out(&ctx)?; + let response = (cookie_jar, Html(fallback)).into_response(); + return Ok(SessionOrFallback::Fallback { response }); + } + + Ok(SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session: Some(session), + }) +} diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index e59a7514d..a19a1ef55 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -19,7 +19,7 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, sentry::SentryEventID, }; -use mas_data_model::{User, UserAgent}; +use mas_data_model::UserAgent; use mas_jose::jwt::Jwt; use mas_matrix::HomeserverConnection; use mas_policy::Policy; @@ -31,8 +31,8 @@ use mas_storage::{ user::{BrowserSessionRepository, UserEmailRepository, UserRepository}, }; use mas_templates::{ - ErrorContext, FieldError, FormError, TemplateContext, Templates, ToFormState, - UpstreamExistingLinkContext, UpstreamRegister, UpstreamSuggestLink, + AccountInactiveContext, ErrorContext, FieldError, FormError, TemplateContext, Templates, + ToFormState, UpstreamExistingLinkContext, UpstreamRegister, UpstreamSuggestLink, }; use minijinja::Environment; use serde::{Deserialize, Serialize}; @@ -242,7 +242,7 @@ pub(crate) async fn get( let (user_session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, mut cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let maybe_user_session = user_session_info.load_session(&mut repo).await?; + let maybe_user_session = user_session_info.load_active_session(&mut repo).await?; let response = match (maybe_user_session, link.user_id) { (Some(session), Some(user_id)) if session.user.id == user_id => { @@ -272,8 +272,6 @@ pub(crate) async fn get( .user() .lookup(user_id) .await? - // XXX: is that right? - .filter(User::is_valid) .ok_or(RouteError::UserNotFound)?; let ctx = UpstreamExistingLinkContext::new(user) @@ -300,9 +298,27 @@ pub(crate) async fn get( .user() .lookup(user_id) .await? - .filter(mas_data_model::User::is_valid) .ok_or(RouteError::UserNotFound)?; + // Check that the user is not locked or deactivated + if user.deactivated_at.is_some() { + // The account is deactivated, show the 'account deactivated' fallback + let ctx = AccountInactiveContext::new(user) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + let fallback = templates.render_account_deactivated(&ctx)?; + return Ok((cookie_jar, Html(fallback).into_response())); + } + + if user.locked_at.is_some() { + // The account is locked, show the 'account locked' fallback + let ctx = AccountInactiveContext::new(user) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + let fallback = templates.render_account_locked(&ctx)?; + return Ok((cookie_jar, Html(fallback).into_response())); + } + let session = repo .browser_session() .add(&mut rng, &clock, &user, user_agent) @@ -556,7 +572,7 @@ pub(crate) async fn post( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (user_session_info, cookie_jar) = cookie_jar.session_info(); - let maybe_user_session = user_session_info.load_session(&mut repo).await?; + let maybe_user_session = user_session_info.load_active_session(&mut repo).await?; let form_state = form.to_form_state(); let session = match (maybe_user_session, link.user_id, form) { diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index 9640937f6..d8010306f 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -8,13 +8,16 @@ use axum::{ extract::{Query, State}, response::{Html, IntoResponse}, }; -use mas_axum_utils::{FancyError, SessionInfoExt, cookies::CookieJar}; +use mas_axum_utils::{FancyError, cookies::CookieJar}; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository}; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{AppContext, TemplateContext, Templates}; use serde::Deserialize; -use crate::{BoundActivityTracker, PreferredLanguage}; +use crate::{ + BoundActivityTracker, PreferredLanguage, + session::{SessionOrFallback, load_session_or_fallback}, +}; #[derive(Deserialize)] pub struct Params { @@ -31,13 +34,24 @@ pub async fn get( Query(Params { action }): Query, mut repo: BoxRepository, clock: BoxClock, + mut rng: BoxRng, cookie_jar: CookieJar, ) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - let session = session_info.load_session(&mut repo).await?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; // TODO: keep the full path, not just the action - let Some(session) = session else { + let Some(session) = maybe_session else { return Ok(( cookie_jar, url_builder.redirect(&mas_router::Login::and_then( diff --git a/crates/handlers/src/views/index.rs b/crates/handlers/src/views/index.rs index ca671dd89..8774b8528 100644 --- a/crates/handlers/src/views/index.rs +++ b/crates/handlers/src/views/index.rs @@ -6,14 +6,18 @@ use axum::{ extract::State, - response::{Html, IntoResponse}, + response::{Html, IntoResponse, Response}, }; -use mas_axum_utils::{FancyError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt}; +use mas_axum_utils::{FancyError, cookies::CookieJar, csrf::CsrfExt}; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{IndexContext, TemplateContext, Templates}; -use crate::{BoundActivityTracker, preferred_language::PreferredLanguage}; +use crate::{ + BoundActivityTracker, + preferred_language::PreferredLanguage, + session::{SessionOrFallback, load_session_or_fallback}, +}; #[tracing::instrument(name = "handlers.views.index.get", skip_all, err)] pub async fn get( @@ -25,23 +29,34 @@ pub async fn get( mut repo: BoxRepository, cookie_jar: CookieJar, PreferredLanguage(locale): PreferredLanguage, -) -> Result { +) -> Result { + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); - let session = session_info.load_session(&mut repo).await?; - if let Some(session) = session.as_ref() { + if let Some(session) = maybe_session.as_ref() { activity_tracker .record_browser_session(&clock, session) .await; } let ctx = IndexContext::new(url_builder.oidc_discovery()) - .maybe_with_session(session) + .maybe_with_session(maybe_session) .with_csrf(csrf_token.form_value()) .with_language(locale); let content = templates.render_index(&ctx)?; - Ok((cookie_jar, Html(content))) + Ok((cookie_jar, Html(content)).into_response()) } diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 90f496557..aa2978824 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -38,6 +38,7 @@ use super::shared::OptionalPostAuthAction; use crate::{ BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint, SiteConfig, passwords::PasswordManager, + session::{SessionOrFallback, load_session_or_fallback}, }; #[derive(Debug, Deserialize, Serialize)] @@ -64,10 +65,20 @@ pub(crate) async fn get( Query(query): Query, cookie_jar: CookieJar, ) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; - let maybe_session = session_info.load_session(&mut repo).await?; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); if let Some(session) = maybe_session { activity_tracker diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 6745e4322..5f717a5cf 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -29,21 +29,27 @@ pub(crate) async fn post( ) -> Result { let form = cookie_jar.verify_form(&clock, form)?; - let (session_info, mut cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - if let Some(session) = maybe_session { - activity_tracker - .record_browser_session(&clock, &session) - .await; - - repo.browser_session().finish(&clock, session).await?; - cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended()); + let (session_info, cookie_jar) = cookie_jar.session_info(); + + if let Some(session_id) = session_info.current_session_id() { + let maybe_session = repo.browser_session().lookup(session_id).await?; + if let Some(session) = maybe_session { + if session.finished_at.is_none() { + activity_tracker + .record_browser_session(&clock, &session) + .await; + + repo.browser_session().finish(&clock, session).await?; + } + } } repo.save().await?; + // We always want to clear out the session cookie, even if the session was + // invalid + let cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended()); + let destination = if let Some(action) = form { action.go_next(&url_builder) } else { diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index 9baf4b73d..d7f238c71 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -25,7 +25,11 @@ use serde::Deserialize; use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; -use crate::{BoundActivityTracker, PreferredLanguage, SiteConfig, passwords::PasswordManager}; +use crate::{ + BoundActivityTracker, PreferredLanguage, SiteConfig, + passwords::PasswordManager, + session::{SessionOrFallback, load_session_or_fallback}, +}; #[derive(Deserialize, Debug)] pub(crate) struct ReauthForm { @@ -52,10 +56,18 @@ pub(crate) async fn get( .into_response()); } - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; let Some(session) = maybe_session else { // If there is no session, redirect to the login screen, keeping the @@ -64,6 +76,8 @@ pub(crate) async fn get( return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + activity_tracker .record_browser_session(&clock, &session) .await; @@ -89,6 +103,8 @@ pub(crate) async fn get( pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, State(password_manager): State, State(url_builder): State, State(site_config): State, @@ -104,9 +120,18 @@ pub(crate) async fn post( let form = cookie_jar.verify_form(&clock, form)?; - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; + let (cookie_jar, maybe_session) = match load_session_or_fallback( + cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, + ) + .await? + { + SessionOrFallback::MaybeSession { + cookie_jar, + maybe_session, + .. + } => (cookie_jar, maybe_session), + SessionOrFallback::Fallback { response } => return Ok(response), + }; let Some(session) = maybe_session else { // If there is no session, redirect to the login screen, keeping the diff --git a/crates/handlers/src/views/recovery/progress.rs b/crates/handlers/src/views/recovery/progress.rs index 30df611ef..eaabef134 100644 --- a/crates/handlers/src/views/recovery/progress.rs +++ b/crates/handlers/src/views/recovery/progress.rs @@ -46,7 +46,7 @@ pub(crate) async fn get( let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if maybe_session.is_some() { // TODO: redirect to continue whatever action was going on return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); @@ -100,7 +100,7 @@ pub(crate) async fn post( let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if maybe_session.is_some() { // TODO: redirect to continue whatever action was going on return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); diff --git a/crates/handlers/src/views/recovery/start.rs b/crates/handlers/src/views/recovery/start.rs index 09e27dea9..728e71834 100644 --- a/crates/handlers/src/views/recovery/start.rs +++ b/crates/handlers/src/views/recovery/start.rs @@ -56,7 +56,7 @@ pub(crate) async fn get( let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if maybe_session.is_some() { // TODO: redirect to continue whatever action was going on return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); @@ -96,7 +96,7 @@ pub(crate) async fn post( let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if maybe_session.is_some() { // TODO: redirect to continue whatever action was going on return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 93d5d93da..7a497f3c0 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -36,7 +36,7 @@ pub(crate) async fn get( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (session_info, cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if let Some(session) = maybe_session { activity_tracker diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 470959cb5..ee8ed7bdb 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -81,7 +81,7 @@ pub(crate) async fn get( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (session_info, cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut repo).await?; + let maybe_session = session_info.load_active_session(&mut repo).await?; if maybe_session.is_some() { let reply = query.action.go_next(&url_builder); diff --git a/crates/storage-pg/.sqlx/query-1f6297fb323e9f2fbfa1c9e3225c0b3037c8c4714533a6240c62275332aa58dc.json b/crates/storage-pg/.sqlx/query-1f6297fb323e9f2fbfa1c9e3225c0b3037c8c4714533a6240c62275332aa58dc.json deleted file mode 100644 index 52e87ebe9..000000000 --- a/crates/storage-pg/.sqlx/query-1f6297fb323e9f2fbfa1c9e3225c0b3037c8c4714533a6240c62275332aa58dc.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM user_email_confirmation_codes\n WHERE user_email_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "1f6297fb323e9f2fbfa1c9e3225c0b3037c8c4714533a6240c62275332aa58dc" -} diff --git a/crates/storage-pg/.sqlx/query-2f7aba76cd7df75d6a9a6d91d5ddebaedf37437f3bd4f796f5581fab997587d7.json b/crates/storage-pg/.sqlx/query-2f7aba76cd7df75d6a9a6d91d5ddebaedf37437f3bd4f796f5581fab997587d7.json new file mode 100644 index 000000000..6b66e72e6 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-2f7aba76cd7df75d6a9a6d91d5ddebaedf37437f3bd4f796f5581fab997587d7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET deactivated_at = $2\n WHERE user_id = $1\n AND deactivated_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "2f7aba76cd7df75d6a9a6d91d5ddebaedf37437f3bd4f796f5581fab997587d7" +} diff --git a/crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json b/crates/storage-pg/.sqlx/query-48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455.json similarity index 69% rename from crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json rename to crates/storage-pg/.sqlx/query-48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455.json index 2a7e94117..52c7ab0bc 100644 --- a/crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json +++ b/crates/storage-pg/.sqlx/query-48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ", "describe": { "columns": [ { @@ -25,6 +25,11 @@ }, { "ordinal": 4, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, "name": "can_request_admin", "type_info": "Bool" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b" + "hash": "48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455" } diff --git a/crates/storage-pg/.sqlx/query-b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69.json b/crates/storage-pg/.sqlx/query-90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819.json similarity index 59% rename from crates/storage-pg/.sqlx/query-b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69.json rename to crates/storage-pg/.sqlx/query-90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819.json index d62f4c55b..a9d19cac0 100644 --- a/crates/storage-pg/.sqlx/query-b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69.json +++ b/crates/storage-pg/.sqlx/query-90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_emails (user_email_id, user_id, email, created_at, confirmed_at)\n VALUES ($1, $2, $3, $4, $4)\n ", + "query": "\n INSERT INTO user_emails (user_email_id, user_id, email, created_at)\n VALUES ($1, $2, $3, $4)\n ", "describe": { "columns": [], "parameters": { @@ -13,5 +13,5 @@ }, "nullable": [] }, - "hash": "b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69" + "hash": "90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819" } diff --git a/crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json b/crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json similarity index 69% rename from crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json rename to crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json index 82b1b659c..6603fa37d 100644 --- a/crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json +++ b/crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", "describe": { "columns": [ { @@ -25,6 +25,11 @@ }, { "ordinal": 4, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, "name": "can_request_admin", "type_info": "Bool" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356" + "hash": "cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d" } diff --git a/crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json b/crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json similarity index 80% rename from crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json rename to crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json index ccadfd747..1ddb0acc8 100644 --- a/crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json +++ b/crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", + "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.deactivated_at AS \"user_deactivated_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", "describe": { "columns": [ { @@ -55,6 +55,11 @@ }, { "ordinal": 10, + "name": "user_deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, "name": "user_can_request_admin", "type_info": "Bool" } @@ -75,8 +80,9 @@ false, false, true, + true, false ] }, - "hash": "7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962" + "hash": "f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8" } diff --git a/crates/storage-pg/migrations/20250311093145_user_deactivated_at.sql b/crates/storage-pg/migrations/20250311093145_user_deactivated_at.sql new file mode 100644 index 000000000..e73e3c2ae --- /dev/null +++ b/crates/storage-pg/migrations/20250311093145_user_deactivated_at.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +ALTER TABLE users + -- Track when a user was deactivated. + ADD COLUMN deactivated_at TIMESTAMP WITH TIME ZONE; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 841a4648e..89d79c90b 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -25,6 +25,7 @@ pub enum Users { Username, CreatedAt, LockedAt, + DeactivatedAt, CanRequestAdmin, } diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index 8090f054c..7cb36991f 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -14,12 +14,10 @@ use mas_storage::{ Clock, Page, Pagination, user::{UserEmailFilter, UserEmailRepository}, }; -use opentelemetry_semantic_conventions::attribute::DB_QUERY_TEXT; use rand::RngCore; use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; -use tracing::{Instrument, info_span}; use ulid::Ulid; use uuid::Uuid; @@ -317,12 +315,10 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { let id = Ulid::from_datetime_with_source(created_at.into(), rng); tracing::Span::current().record("user_email.id", tracing::field::display(id)); - // We now always set the 'confirmed_at' field, so that older app version - // consider those emails as verified. sqlx::query!( r#" - INSERT INTO user_emails (user_email_id, user_id, email, created_at, confirmed_at) - VALUES ($1, $2, $3, $4, $4) + INSERT INTO user_emails (user_email_id, user_id, email, created_at) + VALUES ($1, $2, $3, $4) "#, Uuid::from(id), Uuid::from(user.id), @@ -353,22 +349,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { err, )] async fn remove(&mut self, user_email: UserEmail) -> Result<(), Self::Error> { - let span = info_span!( - "db.user_email.remove.codes", - { DB_QUERY_TEXT } = tracing::field::Empty - ); - sqlx::query!( - r#" - DELETE FROM user_email_confirmation_codes - WHERE user_email_id = $1 - "#, - Uuid::from(user_email.id), - ) - .record(&span) - .execute(&mut *self.conn) - .instrument(span) - .await?; - let res = sqlx::query!( r#" DELETE FROM user_emails @@ -385,6 +365,28 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Ok(()) } + #[tracing::instrument( + name = "db.user_email.remove_bulk", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn remove_bulk(&mut self, filter: UserEmailFilter<'_>) -> Result { + let (sql, arguments) = Query::delete() + .from_table(UserEmails::Table) + .apply_filter(filter) + .build_sqlx(PostgresQueryBuilder); + + let res = sqlx::query_with(&sql, arguments) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(res.rows_affected().try_into().unwrap_or(usize::MAX)) + } + #[tracing::instrument( name = "db.user_email.add_authentication_for_session", skip_all, diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index a7a1773d6..2f5036134 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -72,6 +72,7 @@ mod priv_ { pub(super) username: String, pub(super) created_at: DateTime, pub(super) locked_at: Option>, + pub(super) deactivated_at: Option>, pub(super) can_request_admin: bool, } } @@ -87,6 +88,7 @@ impl From for User { sub: id.to_string(), created_at: value.created_at, locked_at: value.locked_at, + deactivated_at: value.deactivated_at, can_request_admin: value.can_request_admin, } } @@ -96,10 +98,18 @@ impl Filter for UserFilter<'_> { fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { sea_query::Condition::all() .add_option(self.state().map(|state| { - if state.is_locked() { - Expr::col((Users::Table, Users::LockedAt)).is_not_null() - } else { - Expr::col((Users::Table, Users::LockedAt)).is_null() + match state { + mas_storage::user::UserState::Deactivated => { + Expr::col((Users::Table, Users::DeactivatedAt)).is_not_null() + } + mas_storage::user::UserState::Locked => { + Expr::col((Users::Table, Users::LockedAt)).is_not_null() + } + mas_storage::user::UserState::Active => { + Expr::col((Users::Table, Users::LockedAt)) + .is_null() + .and(Expr::col((Users::Table, Users::DeactivatedAt)).is_null()) + } } })) .add_option(self.can_request_admin().map(|can_request_admin| { @@ -129,6 +139,7 @@ impl UserRepository for PgUserRepository<'_> { , username , created_at , locked_at + , deactivated_at , can_request_admin FROM users WHERE user_id = $1 @@ -161,6 +172,7 @@ impl UserRepository for PgUserRepository<'_> { , username , created_at , locked_at + , deactivated_at , can_request_admin FROM users WHERE username = $1 @@ -220,6 +232,7 @@ impl UserRepository for PgUserRepository<'_> { sub: id.to_string(), created_at, locked_at: None, + deactivated_at: None, can_request_admin: false, }) } @@ -317,6 +330,42 @@ impl UserRepository for PgUserRepository<'_> { Ok(user) } + #[tracing::instrument( + name = "db.user.deactivate", + skip_all, + fields( + db.query.text, + %user.id, + ), + err, + )] + async fn deactivate(&mut self, clock: &dyn Clock, mut user: User) -> Result { + if user.deactivated_at.is_some() { + return Ok(user); + } + + let deactivated_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE users + SET deactivated_at = $2 + WHERE user_id = $1 + AND deactivated_at IS NULL + "#, + Uuid::from(user.id), + deactivated_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user.deactivated_at = Some(user.created_at); + + Ok(user) + } + #[tracing::instrument( name = "db.user.set_can_request_admin", skip_all, @@ -382,6 +431,10 @@ impl UserRepository for PgUserRepository<'_> { Expr::col((Users::Table, Users::LockedAt)), UserLookupIden::LockedAt, ) + .expr_as( + Expr::col((Users::Table, Users::DeactivatedAt)), + UserLookupIden::DeactivatedAt, + ) .expr_as( Expr::col((Users::Table, Users::CanRequestAdmin)), UserLookupIden::CanRequestAdmin, diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index f90c89b89..ce027afc0 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -59,6 +59,7 @@ struct SessionLookup { user_username: String, user_created_at: DateTime, user_locked_at: Option>, + user_deactivated_at: Option>, user_can_request_admin: bool, } @@ -73,6 +74,7 @@ impl TryFrom for BrowserSession { sub: id.to_string(), created_at: value.user_created_at, locked_at: value.user_locked_at, + deactivated_at: value.user_deactivated_at, can_request_admin: value.user_can_request_admin, }; @@ -173,6 +175,7 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { , u.username AS "user_username" , u.created_at AS "user_created_at" , u.locked_at AS "user_locked_at" + , u.deactivated_at AS "user_deactivated_at" , u.can_request_admin AS "user_can_request_admin" FROM user_sessions s INNER JOIN users u @@ -356,6 +359,10 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { Expr::col((Users::Table, Users::LockedAt)), SessionLookupIden::UserLockedAt, ) + .expr_as( + Expr::col((Users::Table, Users::DeactivatedAt)), + SessionLookupIden::UserDeactivatedAt, + ) .expr_as( Expr::col((Users::Table, Users::CanRequestAdmin)), SessionLookupIden::UserCanRequestAdmin, diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 8eb77d633..d6f26c885 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -33,6 +33,7 @@ async fn test_user_repo(pool: PgPool) { let non_admin = all.cannot_request_admin_only(); let active = all.active_only(); let locked = all.locked_only(); + let deactivated = all.deactivated_only(); // Initially, the user shouldn't exist assert!(!repo.user().exists(USERNAME).await.unwrap()); @@ -49,6 +50,7 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(non_admin).await.unwrap(), 0); assert_eq!(repo.user().count(active).await.unwrap(), 0); assert_eq!(repo.user().count(locked).await.unwrap(), 0); + assert_eq!(repo.user().count(deactivated).await.unwrap(), 0); // Adding the user should work let user = repo @@ -73,6 +75,7 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(non_admin).await.unwrap(), 1); assert_eq!(repo.user().count(active).await.unwrap(), 1); assert_eq!(repo.user().count(locked).await.unwrap(), 0); + assert_eq!(repo.user().count(deactivated).await.unwrap(), 0); // Adding a second time should give a conflict // It should not poison the transaction though @@ -93,6 +96,7 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(non_admin).await.unwrap(), 1); assert_eq!(repo.user().count(active).await.unwrap(), 0); assert_eq!(repo.user().count(locked).await.unwrap(), 1); + assert_eq!(repo.user().count(deactivated).await.unwrap(), 0); // Check that the property is retrieved on lookup let user = repo.user().lookup(user.id).await.unwrap().unwrap(); @@ -123,6 +127,7 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(non_admin).await.unwrap(), 0); assert_eq!(repo.user().count(active).await.unwrap(), 1); assert_eq!(repo.user().count(locked).await.unwrap(), 0); + assert_eq!(repo.user().count(deactivated).await.unwrap(), 0); // Check that the property is retrieved on lookup let user = repo.user().lookup(user.id).await.unwrap().unwrap(); @@ -145,6 +150,26 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(non_admin).await.unwrap(), 1); assert_eq!(repo.user().count(active).await.unwrap(), 1); assert_eq!(repo.user().count(locked).await.unwrap(), 0); + assert_eq!(repo.user().count(deactivated).await.unwrap(), 0); + + // Deactivating the user should work + let user = repo.user().deactivate(&clock, user).await.unwrap(); + assert!(user.deactivated_at.is_some()); + + // Check that the property is retrieved on lookup + let user = repo.user().lookup(user.id).await.unwrap().unwrap(); + assert!(user.deactivated_at.is_some()); + + // Deactivating a second time should not fail + let user = repo.user().deactivate(&clock, user).await.unwrap(); + assert!(user.deactivated_at.is_some()); + + assert_eq!(repo.user().count(all).await.unwrap(), 1); + assert_eq!(repo.user().count(admin).await.unwrap(), 0); + assert_eq!(repo.user().count(non_admin).await.unwrap(), 1); + assert_eq!(repo.user().count(active).await.unwrap(), 0); + assert_eq!(repo.user().count(locked).await.unwrap(), 0); + assert_eq!(repo.user().count(deactivated).await.unwrap(), 1); // Check the list method let list = repo.user().list(all, Pagination::first(10)).await.unwrap(); @@ -171,8 +196,7 @@ async fn test_user_repo(pool: PgPool) { .list(active, Pagination::first(10)) .await .unwrap(); - assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges.len(), 0); let list = repo .user() @@ -181,6 +205,14 @@ async fn test_user_repo(pool: PgPool) { .unwrap(); assert_eq!(list.edges.len(), 0); + let list = repo + .user() + .list(deactivated, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(list.edges.len(), 1); + assert_eq!(list.edges[0].id, user.id); + repo.save().await.unwrap(); } @@ -290,6 +322,21 @@ async fn test_user_email_repo(pool: PgPool) { repo.user_email().remove(user_email).await.unwrap(); assert_eq!(repo.user_email().count(all).await.unwrap(), 0); + // Add a few emails + for i in 0..5 { + let email = format!("email{i}@example.com"); + repo.user_email() + .add(&mut rng, &clock, &user, email) + .await + .unwrap(); + } + assert_eq!(repo.user_email().count(all).await.unwrap(), 5); + + // Try removing all the emails + let affected = repo.user_email().remove_bulk(all).await.unwrap(); + assert_eq!(affected, 5); + assert_eq!(repo.user_email().count(all).await.unwrap(), 0); + repo.save().await.unwrap(); } diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index b10b7998a..4fa559508 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -164,6 +164,19 @@ pub trait UserEmailRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn remove(&mut self, user_email: UserEmail) -> Result<(), Self::Error>; + /// Delete all [`UserEmail`] with the given filter + /// + /// Returns the number of deleted [`UserEmail`]s + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn remove_bulk(&mut self, filter: UserEmailFilter<'_>) -> Result; + /// Add a new [`UserEmailAuthentication`] for a [`BrowserSession`] /// /// # Parameters @@ -303,6 +316,8 @@ repository_impl!(UserEmailRepository: ) -> Result; async fn remove(&mut self, user_email: UserEmail) -> Result<(), Self::Error>; + async fn remove_bulk(&mut self, filter: UserEmailFilter<'_>) -> Result; + async fn add_authentication_for_session( &mut self, rng: &mut (dyn RngCore + Send), diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 0ab559d12..01feebbc4 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -32,6 +32,9 @@ pub use self::{ /// The state of a user account #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum UserState { + /// The account is deactivated, it has the `deactivated_at` timestamp set + Deactivated, + /// The account is locked, it has the `locked_at` timestamp set Locked, @@ -48,6 +51,14 @@ impl UserState { matches!(self, Self::Locked) } + /// Returns `true` if the user state is [`Deactivated`]. + /// + /// [`Deactivated`]: UserState::Deactivated + #[must_use] + pub fn is_deactivated(&self) -> bool { + matches!(self, Self::Deactivated) + } + /// Returns `true` if the user state is [`Active`]. /// /// [`Active`]: UserState::Active @@ -86,6 +97,13 @@ impl UserFilter<'_> { self } + /// Filter for deactivated users + #[must_use] + pub fn deactivated_only(mut self) -> Self { + self.state = Some(UserState::Deactivated); + self + } + /// Filter for users that can request admin privileges #[must_use] pub fn can_request_admin_only(mut self) -> Self { @@ -210,6 +228,20 @@ pub trait UserRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn unlock(&mut self, user: User) -> Result; + /// Deactivate a [`User`] + /// + /// Returns the deactivated [`User`] + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `user`: The [`User`] to deactivate + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result; + /// Set whether a [`User`] can request admin /// /// Returns the [`User`] with the new `can_request_admin` value @@ -280,6 +312,7 @@ repository_impl!(UserRepository: async fn exists(&mut self, username: &str) -> Result; async fn lock(&mut self, clock: &dyn Clock, user: User) -> Result; async fn unlock(&mut self, user: User) -> Result; + async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result; async fn set_can_request_admin( &mut self, user: User, diff --git a/crates/syn2mas/.sqlx/query-06cd6bff12000db3e64e98c344cc9e3b5de7af6a497ad84036ae104576ae0575.json b/crates/syn2mas/.sqlx/query-06cd6bff12000db3e64e98c344cc9e3b5de7af6a497ad84036ae104576ae0575.json deleted file mode 100644 index b52cece0d..000000000 --- a/crates/syn2mas/.sqlx/query-06cd6bff12000db3e64e98c344cc9e3b5de7af6a497ad84036ae104576ae0575.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__users (\n user_id, username,\n created_at, locked_at,\n can_request_admin, is_guest)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[],\n $5::BOOL[], $6::BOOL[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "TextArray", - "TimestamptzArray", - "TimestamptzArray", - "BoolArray", - "BoolArray" - ] - }, - "nullable": [] - }, - "hash": "06cd6bff12000db3e64e98c344cc9e3b5de7af6a497ad84036ae104576ae0575" -} diff --git a/crates/syn2mas/.sqlx/query-f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1.json b/crates/syn2mas/.sqlx/query-f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1.json new file mode 100644 index 000000000..66979a67e --- /dev/null +++ b/crates/syn2mas/.sqlx/query-f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__users (\n user_id, username,\n created_at, locked_at,\n deactivated_at,\n can_request_admin, is_guest)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[],\n $6::BOOL[], $7::BOOL[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TextArray", + "TimestamptzArray", + "TimestamptzArray", + "TimestamptzArray", + "BoolArray", + "BoolArray" + ] + }, + "nullable": [] + }, + "hash": "f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1" +} diff --git a/crates/syn2mas/src/mas_writer/mod.rs b/crates/syn2mas/src/mas_writer/mod.rs index 4acf21d6f..01c57651e 100644 --- a/crates/syn2mas/src/mas_writer/mod.rs +++ b/crates/syn2mas/src/mas_writer/mod.rs @@ -201,6 +201,7 @@ pub struct MasNewUser { pub username: String, pub created_at: DateTime, pub locked_at: Option>, + pub deactivated_at: Option>, pub can_request_admin: bool, /// Whether the user was a Synapse guest. /// Although MAS doesn't support guest access, it's still useful to track @@ -587,6 +588,8 @@ impl MasWriter { let mut created_ats: Vec> = Vec::with_capacity(users.len()); let mut locked_ats: Vec>> = Vec::with_capacity(users.len()); + let mut deactivated_ats: Vec>> = + Vec::with_capacity(users.len()); let mut can_request_admins: Vec = Vec::with_capacity(users.len()); let mut is_guests: Vec = Vec::with_capacity(users.len()); for MasNewUser { @@ -594,6 +597,7 @@ impl MasWriter { username, created_at, locked_at, + deactivated_at, can_request_admin, is_guest, } in users @@ -602,6 +606,7 @@ impl MasWriter { usernames.push(username); created_ats.push(created_at); locked_ats.push(locked_at); + deactivated_ats.push(deactivated_at); can_request_admins.push(can_request_admin); is_guests.push(is_guest); } @@ -611,17 +616,20 @@ impl MasWriter { INSERT INTO syn2mas__users ( user_id, username, created_at, locked_at, + deactivated_at, can_request_admin, is_guest) SELECT * FROM UNNEST( $1::UUID[], $2::TEXT[], $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[], - $5::BOOL[], $6::BOOL[]) + $5::TIMESTAMP WITH TIME ZONE[], + $6::BOOL[], $7::BOOL[]) "#, &user_ids[..], &usernames[..], &created_ats[..], // We need to override the typing for arrays of optionals (sqlx limitation) &locked_ats[..] as &[Option>], + &deactivated_ats[..] as &[Option>], &can_request_admins[..], &is_guests[..], ) @@ -1217,6 +1225,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1241,6 +1250,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1272,6 +1282,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1305,6 +1316,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1339,6 +1351,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1372,6 +1385,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1409,6 +1423,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) @@ -1458,6 +1473,7 @@ mod test { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, + deactivated_at: None, can_request_admin: false, is_guest: false, }]) diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap index 3bb6d1c07..39a3a5011 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap @@ -5,6 +5,7 @@ expression: db_snapshot users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_access_token.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_access_token.snap index e1c069c2e..3dbb948ec 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_access_token.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_access_token.snap @@ -23,6 +23,7 @@ compat_sessions: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_device.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_device.snap index 1e7e95d9e..13cb9dc89 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_device.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_device.snap @@ -17,6 +17,7 @@ compat_sessions: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap index c4f7d2247..e8b8a1e96 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap @@ -11,6 +11,7 @@ user_emails: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap index 4c1253026..4b6e8696c 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap @@ -12,6 +12,7 @@ user_passwords: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_refresh_token.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_refresh_token.snap index 71ad9efee..80c52bd8c 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_refresh_token.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_refresh_token.snap @@ -30,6 +30,7 @@ compat_sessions: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap index 3b70125f8..c2e7a9e50 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap @@ -10,6 +10,7 @@ user_unsupported_third_party_ids: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap index 821eb9e17..a28548e2b 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap @@ -36,6 +36,7 @@ upstream_oauth_providers: users: - can_request_admin: "false" created_at: "1970-01-01 00:00:00+00" + deactivated_at: ~ is_guest: "false" locked_at: ~ primary_user_email_id: ~ diff --git a/crates/syn2mas/src/migration.rs b/crates/syn2mas/src/migration.rs index 5135a5d80..d0aa6200e 100644 --- a/crates/syn2mas/src/migration.rs +++ b/crates/syn2mas/src/migration.rs @@ -748,7 +748,8 @@ fn transform_user( user_id, username, created_at: user.creation_ts.into(), - locked_at: bool::from(user.deactivated).then_some(user.creation_ts.into()), + locked_at: user.locked.then_some(user.creation_ts.into()), + deactivated_at: bool::from(user.deactivated).then_some(user.creation_ts.into()), can_request_admin: bool::from(user.admin), is_guest: bool::from(user.is_guest), }; diff --git a/crates/syn2mas/src/synapse_reader/mod.rs b/crates/syn2mas/src/synapse_reader/mod.rs index 6646af1b1..0154a7af8 100644 --- a/crates/syn2mas/src/synapse_reader/mod.rs +++ b/crates/syn2mas/src/synapse_reader/mod.rs @@ -185,6 +185,8 @@ pub struct SynapseUser { pub admin: SynapseBool, /// Whether the user is deactivated pub deactivated: SynapseBool, + /// Whether the user is locked + pub locked: bool, /// When the user was created pub creation_ts: SecondsTimestamp, /// Whether the user is a guest. @@ -371,7 +373,7 @@ impl<'conn> SynapseReader<'conn> { sqlx::query_as( " SELECT - name, password_hash, admin, deactivated, creation_ts, is_guest, appservice_id + name, password_hash, admin, deactivated, locked, creation_ts, is_guest, appservice_id FROM users ", ) diff --git a/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap index 9da5c221b..b56f09aba 100644 --- a/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap +++ b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap @@ -16,6 +16,7 @@ expression: users deactivated: SynapseBool( false, ), + locked: false, creation_ts: SecondsTimestamp( 2018-06-30T21:26:02Z, ), diff --git a/crates/tasks/src/user.rs b/crates/tasks/src/user.rs index 9b6893399..272111d17 100644 --- a/crates/tasks/src/user.rs +++ b/crates/tasks/src/user.rs @@ -11,7 +11,7 @@ use mas_storage::{ compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, queue::{DeactivateUserJob, ReactivateUserJob}, - user::{BrowserSessionFilter, UserRepository}, + user::{BrowserSessionFilter, UserEmailFilter, UserRepository}, }; use tracing::info; @@ -42,7 +42,7 @@ impl RunnableJob for DeactivateUserJob { .context("User not found") .map_err(JobError::fail)?; - // Let's first lock the user + // Let's first lock & deactivate the user let user = repo .user() .lock(&clock, user) @@ -50,6 +50,13 @@ impl RunnableJob for DeactivateUserJob { .context("Failed to lock user") .map_err(JobError::retry)?; + let user = repo + .user() + .deactivate(&clock, user) + .await + .context("Failed to deactivate user") + .map_err(JobError::retry)?; + // Kill all sessions for the user let n = repo .browser_session() @@ -81,6 +88,14 @@ impl RunnableJob for DeactivateUserJob { .map_err(JobError::retry)?; info!(affected = n, "Killed all compatibility sessions for user"); + // Delete all the email addresses for the user + let n = repo + .user_email() + .remove_bulk(UserEmailFilter::new().for_user(&user)) + .await + .map_err(JobError::retry)?; + info!(affected = n, "Removed all email addresses for user"); + // Before calling back to the homeserver, commit the changes to the database, as // we want the user to be locked out as soon as possible repo.save().await.map_err(JobError::retry)?; diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 973a72b35..26ed200e1 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1590,6 +1590,33 @@ impl TemplateContext for DeviceConsentContext { } } +/// Context used by the `account/deactivated.html` and `account/locked.html` +/// templates +#[derive(Serialize)] +pub struct AccountInactiveContext { + user: User, +} + +impl AccountInactiveContext { + /// Constructs a new context with an existing linked user + #[must_use] + pub fn new(user: User) -> Self { + Self { user } + } +} + +impl TemplateContext for AccountInactiveContext { + fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + User::samples(now, rng) + .into_iter() + .map(|user| AccountInactiveContext { user }) + .collect() + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 60482f792..982b3fc02 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -34,14 +34,14 @@ mod macros; pub use self::{ context::{ - ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, - DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, EmailVerificationContext, - EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, - NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext, - PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext, - RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, - RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, - RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext, + DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, + EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, + LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext, + PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext, + ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, + RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, + RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, @@ -413,6 +413,15 @@ register_templates! { /// Render the device code consent page pub fn render_device_consent(WithLanguage>>) { "pages/device_consent.html" } + + /// Render the 'account deactivated' page + pub fn render_account_deactivated(WithLanguage>) { "pages/account/deactivated.html" } + + /// Render the 'account locked' page + pub fn render_account_locked(WithLanguage>) { "pages/account/locked.html" } + + /// Render the 'account logged out' page + pub fn render_account_logged_out(WithLanguage>) { "pages/account/logged_out.html" } } impl Templates { diff --git a/templates/pages/account/deactivated.html b/templates/pages/account/deactivated.html new file mode 100644 index 000000000..cc3f52081 --- /dev/null +++ b/templates/pages/account/deactivated.html @@ -0,0 +1,26 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.delete() }} +
+ +
+

{{ _("mas.account.deactivated.heading") }}

+ {% set mxid = "@" + user.username + ":" + branding.server_name %} +

{{ _("mas.account.deactivated.description", mxid=mxid) }}

+
+ + {{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }} +
+
+{% endblock %} diff --git a/templates/pages/account/locked.html b/templates/pages/account/locked.html new file mode 100644 index 000000000..24b7a8cd3 --- /dev/null +++ b/templates/pages/account/locked.html @@ -0,0 +1,26 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.block() }} +
+ +
+

{{ _("mas.account.locked.heading") }}

+ {% set mxid = "@" + user.username + ":" + branding.server_name %} +

{{ _("mas.account.locked.description", mxid=mxid) }}

+
+ + {{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }} +
+
+{% endblock %} diff --git a/templates/pages/account/logged_out.html b/templates/pages/account/logged_out.html new file mode 100644 index 000000000..e625a4c73 --- /dev/null +++ b/templates/pages/account/logged_out.html @@ -0,0 +1,25 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.leave() }} +
+ +
+

{{ _("mas.account.logged_out.heading") }}

+

{{ _("mas.account.logged_out.description") }}

+
+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} +
+
+{% endblock %} diff --git a/translations/en.json b/translations/en.json index 39e2d46b7..ae99f427c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -18,11 +18,11 @@ }, "sign_in": "Sign in", "@sign_in": { - "context": "pages/index.html:30:26-45" + "context": "pages/account/deactivated.html:23:28-47, pages/account/locked.html:23:28-47, pages/index.html:30:26-45" }, "sign_out": "Sign out", "@sign_out": { - "context": "pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" }, "skip": "Skip", "@skip": { @@ -110,6 +110,38 @@ } }, "mas": { + "account": { + "deactivated": { + "description": "This account (%(mxid)s) has been deleted. If this is not expected, contact your server administrator.", + "@description": { + "context": "pages/account/deactivated.html:20:27-78" + }, + "heading": "Account deleted", + "@heading": { + "context": "pages/account/deactivated.html:18:29-65" + } + }, + "locked": { + "description": "This account (%(mxid)s) has been locked. If this is not expected, contact your server administrator.", + "@description": { + "context": "pages/account/locked.html:20:27-73" + }, + "heading": "Account locked", + "@heading": { + "context": "pages/account/locked.html:18:29-60" + } + }, + "logged_out": { + "description": "This session has been terminated. Sign out to be able to log back in", + "@description": { + "context": "pages/account/logged_out.html:19:27-66" + }, + "heading": "Session terminated", + "@heading": { + "context": "pages/account/logged_out.html:18:29-64" + } + } + }, "back_to_homepage": "Go back to the homepage", "@back_to_homepage": { "context": "pages/404.html:16:29-54"