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/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/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..cb0c757f4 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -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 => { @@ -556,7 +556,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 { - 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?; +) -> 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), + }; - if let Some(session) = session.as_ref() { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + 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 (session_info, cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut repo).await?; + 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; - 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()); + 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/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"