From 802cf142fdfaf2476130f932b4f880a74b7aa7a6 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 19 Jul 2023 15:31:17 +0200 Subject: [PATCH] Remove the last authentication from the browser session model --- crates/data-model/src/users.rs | 11 -- crates/graphql/src/model/browser_sessions.rs | 21 ++- crates/graphql/src/model/users.rs | 26 +++- .../src/oauth2/authorization/complete.rs | 14 +- crates/handlers/src/oauth2/mod.rs | 8 +- crates/handlers/src/oauth2/token.rs | 6 + crates/handlers/src/upstream_oauth2/link.rs | 10 +- crates/handlers/src/views/account/password.rs | 5 +- crates/handlers/src/views/login.rs | 5 +- crates/handlers/src/views/reauth.rs | 5 +- crates/handlers/src/views/register.rs | 5 +- ...2982ac472f5ec2fab08b0b5275c4b78c11a7e.json | 52 +++++++ ...6d3b130ec71bc592d722eb75b959b80f0b4ff.json | 22 --- ...20d381a254d00dbe39fef1e9652029d51b89b.json | 64 --------- ...9d88898d4c5df8ab557846e2f9184636f2dcf.json | 28 ++++ crates/storage-pg/src/sea_query_sqlx.rs | 9 +- crates/storage-pg/src/user/session.rs | 134 +++++++++++------- crates/storage-pg/src/user/tests.rs | 19 ++- crates/storage/src/user/session.rs | 54 +++++-- frontend/schema.graphql | 4 + .../src/components/BrowserSessionList.tsx | 3 + frontend/src/gql/gql.ts | 6 +- frontend/src/gql/graphql.ts | 7 + frontend/src/gql/schema.ts | 11 ++ 24 files changed, 325 insertions(+), 204 deletions(-) create mode 100644 crates/storage-pg/.sqlx/query-25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e.json delete mode 100644 crates/storage-pg/.sqlx/query-751d549073d77ded84aea1aaba36d3b130ec71bc592d722eb75b959b80f0b4ff.json delete mode 100644 crates/storage-pg/.sqlx/query-79295f3d3a75f831e9469aabfa720d381a254d00dbe39fef1e9652029d51b89b.json create mode 100644 crates/storage-pg/.sqlx/query-ce0dbf84b23f4d5cfbd068811149d88898d4c5df8ab557846e2f9184636f2dcf.json diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 512d0e94..d53a688b 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -60,7 +60,6 @@ pub struct BrowserSession { pub user: User, pub created_at: DateTime, pub finished_at: Option>, - pub last_authentication: Option, } impl BrowserSession { @@ -68,15 +67,6 @@ impl BrowserSession { pub fn active(&self) -> bool { self.finished_at.is_none() } - - #[must_use] - pub fn was_authenticated_after(&self, after: DateTime) -> bool { - if let Some(auth) = &self.last_authentication { - auth.created_at > after - } else { - false - } - } } impl BrowserSession { @@ -89,7 +79,6 @@ impl BrowserSession { user, created_at: now, finished_at: None, - last_authentication: None, }) .collect() } diff --git a/crates/graphql/src/model/browser_sessions.rs b/crates/graphql/src/model/browser_sessions.rs index 140a2f51..ba4b3efe 100644 --- a/crates/graphql/src/model/browser_sessions.rs +++ b/crates/graphql/src/model/browser_sessions.rs @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Description, Enum, Object, ID}; +use async_graphql::{Context, Description, Enum, Object, ID}; use chrono::{DateTime, Utc}; +use mas_storage::{user::BrowserSessionRepository, RepositoryAccess}; use super::{NodeType, User}; +use crate::state::ContextExt; /// A browser session represents a logged in user in a browser. #[derive(Description)] @@ -50,8 +52,21 @@ impl BrowserSession { } /// The most recent authentication of this session. - async fn last_authentication(&self) -> Option { - self.0.last_authentication.clone().map(Authentication) + async fn last_authentication( + &self, + ctx: &Context<'_>, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let mut repo = state.repository().await?; + + let last_authentication = repo + .browser_session() + .get_last_authentication(&self.0) + .await?; + + repo.cancel().await?; + + Ok(last_authentication.map(Authentication)) } /// When the object was created. diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index 986c4335..2a086739 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -198,7 +198,7 @@ impl User { before: Option, #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, - ) -> Result, async_graphql::Error> { + ) -> Result, async_graphql::Error> { let state = ctx.state(); let mut repo = state.repository().await?; @@ -225,9 +225,20 @@ impl User { let page = repo.browser_session().list(filter, pagination).await?; + // Preload the total count if requested + let count = if ctx.look_ahead().field("totalCount").exists() { + Some(repo.browser_session().count(filter).await?) + } else { + None + }; + repo.cancel().await?; - let mut connection = Connection::new(page.has_previous_page, page.has_next_page); + let mut connection = Connection::with_additional_fields( + page.has_previous_page, + page.has_next_page, + PreloadedTotalCount(count), + ); connection.edges.extend(page.edges.into_iter().map(|u| { Edge::new( OpaqueCursor(NodeCursor(NodeType::BrowserSession, u.id)), @@ -400,6 +411,17 @@ impl User { } } +pub struct PreloadedTotalCount(Option); + +#[Object] +impl PreloadedTotalCount { + /// Identifies the total count of items in the connection. + async fn total_count(&self) -> Result { + self.0 + .ok_or_else(|| async_graphql::Error::new("total count not preloaded")) + } +} + /// A user email address #[derive(Description)] pub struct UserEmail(pub mas_data_model::UserEmail); diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs index ccbee9f6..b8a03159 100644 --- a/crates/handlers/src/oauth2/authorization/complete.rs +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -27,7 +27,8 @@ use mas_policy::PolicyFactory; use mas_router::{PostAuthAction, Route, UrlBuilder}; use mas_storage::{ oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2SessionRepository}, - BoxClock, BoxRepository, BoxRng, + user::BrowserSessionRepository, + BoxClock, BoxRepository, BoxRng, RepositoryAccess, }; use mas_templates::Templates; use oauth2_types::requests::AuthorizationResponse; @@ -194,10 +195,16 @@ pub(crate) async fn complete( } // Check if the authentication is fresh enough - if !browser_session.was_authenticated_after(grant.max_auth_time()) { + let authentication = repo + .browser_session() + .get_last_authentication(&browser_session) + .await?; + let authentication = authentication.filter(|auth| auth.created_at > grant.max_auth_time()); + + let Some(valid_authentication) = authentication else { repo.save().await?; return Err(GrantCompletionError::RequiresReauth); - } + }; // Run through the policy let mut policy = policy_factory.instantiate().await?; @@ -257,6 +264,7 @@ pub(crate) async fn complete( &grant, &browser_session, None, + Some(&valid_authentication), )?); } diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index 19edd0da..824741f6 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -16,7 +16,8 @@ use std::collections::HashMap; use chrono::Duration; use mas_data_model::{ - AccessToken, AuthorizationGrant, BrowserSession, Client, RefreshToken, Session, TokenType, + AccessToken, Authentication, AuthorizationGrant, BrowserSession, Client, RefreshToken, Session, + TokenType, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_jose::{ @@ -60,6 +61,7 @@ pub(crate) fn generate_id_token( grant: &AuthorizationGrant, browser_session: &BrowserSession, access_token: Option<&AccessToken>, + last_authentication: Option<&Authentication>, ) -> Result { let mut claims = HashMap::new(); let now = clock.now(); @@ -73,7 +75,7 @@ pub(crate) fn generate_id_token( claims::NONCE.insert(&mut claims, nonce.clone())?; } - if let Some(ref last_authentication) = browser_session.last_authentication { + if let Some(last_authentication) = last_authentication { claims::AUTH_TIME.insert(&mut claims, last_authentication.created_at)?; } @@ -113,7 +115,7 @@ pub(crate) async fn generate_token_pair( let access_token = repo .oauth2_access_token() - .add(rng, clock, session, access_token_str.clone(), ttl) + .add(rng, clock, session, access_token_str, ttl) .await?; let refresh_token = repo diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 885deb4b..e52476f4 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -302,6 +302,11 @@ async fn authorization_code_grant( .await? .ok_or(RouteError::NoSuchBrowserSession)?; + let last_authentication = repo + .browser_session() + .get_last_authentication(&browser_session) + .await?; + let ttl = Duration::minutes(5); let (access_token, refresh_token) = generate_token_pair(&mut rng, clock, &mut repo, &session, ttl).await?; @@ -316,6 +321,7 @@ async fn authorization_code_grant( &authz_grant, &browser_session, Some(&access_token), + last_authentication.as_ref(), )?) } else { None diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index e43856b0..b5d97578 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -214,9 +214,8 @@ pub(crate) async fn get( .consume(&clock, upstream_session) .await?; - let session = repo - .browser_session() - .authenticate_with_upstream(&mut rng, &clock, session, &link) + repo.browser_session() + .authenticate_with_upstream(&mut rng, &clock, &session, &link) .await?; cookie_jar = cookie_jar.set_session(&session); @@ -509,9 +508,8 @@ pub(crate) async fn post( .consume(&clock, upstream_session) .await?; - let session = repo - .browser_session() - .authenticate_with_upstream(&mut rng, &clock, session, &link) + repo.browser_session() + .authenticate_with_upstream(&mut rng, &clock, &session, &link) .await?; let cookie_jar = sessions_cookie diff --git a/crates/handlers/src/views/account/password.rs b/crates/handlers/src/views/account/password.rs index 8de6d586..2628bd4b 100644 --- a/crates/handlers/src/views/account/password.rs +++ b/crates/handlers/src/views/account/password.rs @@ -150,9 +150,8 @@ pub(crate) async fn post( ) .await?; - let session = repo - .browser_session() - .authenticate_with_password(&mut rng, &clock, session, &user_password) + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &session, &user_password) .await?; let reply = render(&mut rng, &clock, templates.clone(), session, cookie_jar).await?; diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 8a77e255..fd4a07e2 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -250,9 +250,8 @@ async fn login( .map_err(|_| FormError::Internal)?; // And mark it as authenticated by the password - let user_session = repo - .browser_session() - .authenticate_with_password(&mut rng, clock, user_session, &user_password) + repo.browser_session() + .authenticate_with_password(&mut rng, clock, &user_session, &user_password) .await .map_err(|_| FormError::Internal)?; diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index 1c6243d5..de5d990c 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -147,9 +147,8 @@ pub(crate) async fn post( }; // Mark the session as authenticated by the password - let session = repo - .browser_session() - .authenticate_with_password(&mut rng, &clock, session, &user_password) + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &session, &user_password) .await?; let cookie_jar = cookie_jar.set_session(&session); diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 8d336af9..def96e97 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -209,9 +209,8 @@ pub(crate) async fn post( let session = repo.browser_session().add(&mut rng, &clock, &user).await?; - let session = repo - .browser_session() - .authenticate_with_password(&mut rng, &clock, session, &user_password) + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &session, &user_password) .await?; repo.job() diff --git a/crates/storage-pg/.sqlx/query-25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e.json b/crates/storage-pg/.sqlx/query-25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e.json new file mode 100644 index 00000000..6ea5b01e --- /dev/null +++ b/crates/storage-pg/.sqlx/query-25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e.json @@ -0,0 +1,52 @@ +{ + "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 , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_session_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "user_session_finished_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "user_primary_user_email_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true + ] + }, + "hash": "25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e" +} diff --git a/crates/storage-pg/.sqlx/query-751d549073d77ded84aea1aaba36d3b130ec71bc592d722eb75b959b80f0b4ff.json b/crates/storage-pg/.sqlx/query-751d549073d77ded84aea1aaba36d3b130ec71bc592d722eb75b959b80f0b4ff.json deleted file mode 100644 index bd74f740..00000000 --- a/crates/storage-pg/.sqlx/query-751d549073d77ded84aea1aaba36d3b130ec71bc592d722eb75b959b80f0b4ff.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT COUNT(*) as \"count!\"\n FROM user_sessions s\n WHERE s.user_id = $1 AND s.finished_at IS NULL\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "751d549073d77ded84aea1aaba36d3b130ec71bc592d722eb75b959b80f0b4ff" -} diff --git a/crates/storage-pg/.sqlx/query-79295f3d3a75f831e9469aabfa720d381a254d00dbe39fef1e9652029d51b89b.json b/crates/storage-pg/.sqlx/query-79295f3d3a75f831e9469aabfa720d381a254d00dbe39fef1e9652029d51b89b.json deleted file mode 100644 index b0f702f1..00000000 --- a/crates/storage-pg/.sqlx/query-79295f3d3a75f831e9469aabfa720d381a254d00dbe39fef1e9652029d51b89b.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "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 , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n , a.user_session_authentication_id AS \"last_authentication_id?\"\n , a.created_at AS \"last_authd_at?\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n LEFT JOIN user_session_authentications a\n USING (user_session_id)\n WHERE s.user_session_id = $1\n ORDER BY a.created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_session_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "user_session_created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "user_session_finished_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "user_username", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "user_primary_user_email_id", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "last_authentication_id?", - "type_info": "Uuid" - }, - { - "ordinal": 7, - "name": "last_authd_at?", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "79295f3d3a75f831e9469aabfa720d381a254d00dbe39fef1e9652029d51b89b" -} diff --git a/crates/storage-pg/.sqlx/query-ce0dbf84b23f4d5cfbd068811149d88898d4c5df8ab557846e2f9184636f2dcf.json b/crates/storage-pg/.sqlx/query-ce0dbf84b23f4d5cfbd068811149d88898d4c5df8ab557846e2f9184636f2dcf.json new file mode 100644 index 00000000..4a715ab5 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-ce0dbf84b23f4d5cfbd068811149d88898d4c5df8ab557846e2f9184636f2dcf.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_session_authentication_id AS id\n , created_at\n FROM user_session_authentications\n WHERE user_session_id = $1\n ORDER BY created_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "ce0dbf84b23f4d5cfbd068811149d88898d4c5df8ab557846e2f9184636f2dcf" +} diff --git a/crates/storage-pg/src/sea_query_sqlx.rs b/crates/storage-pg/src/sea_query_sqlx.rs index 653f7feb..2f264ae5 100644 --- a/crates/storage-pg/src/sea_query_sqlx.rs +++ b/crates/storage-pg/src/sea_query_sqlx.rs @@ -25,9 +25,9 @@ pub(crate) fn map_values(values: sea_query::Values) -> sqlx::postgres::PgArgumen Value::SmallInt(i) => arguments.add(i), Value::Int(i) => arguments.add(i), Value::BigInt(i) => arguments.add(i), - Value::TinyUnsigned(u) => arguments.add(u.map(|u| u as i16)), - Value::SmallUnsigned(u) => arguments.add(u.map(|u| u as i32)), - Value::Unsigned(u) => arguments.add(u.map(|u| u as i64)), + Value::TinyUnsigned(u) => arguments.add(u.map(i16::from)), + Value::SmallUnsigned(u) => arguments.add(u.map(i32::from)), + Value::Unsigned(u) => arguments.add(u.map(i64::from)), Value::BigUnsigned(u) => arguments.add(u.map(|u| i64::try_from(u).unwrap_or(i64::MAX))), Value::Float(f) => arguments.add(f), Value::Double(d) => arguments.add(d), @@ -41,6 +41,9 @@ pub(crate) fn map_values(values: sea_query::Values) -> sqlx::postgres::PgArgumen Value::ChronoDateTimeLocal(dt) => arguments.add(dt.as_deref()), Value::ChronoDateTimeWithTimeZone(dt) => arguments.add(dt.as_deref()), Value::Uuid(u) => arguments.add(u.as_deref()), + + // This depends on the features enabled for sea-query, so let's keep the wildcard + #[allow(unreachable_patterns)] _ => unimplemented!(), } } diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index d4623917..9e928830 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -18,7 +18,7 @@ use mas_data_model::{Authentication, BrowserSession, Password, UpstreamOAuthLink use mas_storage::{user::BrowserSessionRepository, Clock, Page, Pagination}; use rand::RngCore; use sea_query::{Expr, IntoColumnRef, PostgresQueryBuilder}; -use sqlx::{PgConnection, QueryBuilder}; +use sqlx::PgConnection; use ulid::Ulid; use uuid::Uuid; @@ -50,8 +50,6 @@ struct SessionLookup { user_id: Uuid, user_username: String, user_primary_user_email_id: Option, - last_authentication_id: Option, - last_authd_at: Option>, } #[derive(sea_query::Iden)] @@ -71,14 +69,6 @@ enum Users { PrimaryUserEmailId, } -#[derive(sea_query::Iden)] -enum SessionAuthentication { - Table, - UserSessionAuthenticationId, - UserSessionId, - CreatedAt, -} - impl TryFrom for BrowserSession { type Error = DatabaseInconsistencyError; @@ -91,25 +81,11 @@ impl TryFrom for BrowserSession { primary_user_email_id: value.user_primary_user_email_id.map(Into::into), }; - let last_authentication = match (value.last_authentication_id, value.last_authd_at) { - (Some(id), Some(created_at)) => Some(Authentication { - id: id.into(), - created_at, - }), - (None, None) => None, - _ => { - return Err(DatabaseInconsistencyError::on( - "user_session_authentications", - )) - } - }; - Ok(BrowserSession { id: value.user_session_id.into(), user, created_at: value.user_session_created_at, finished_at: value.user_session_finished_at, - last_authentication, }) } } @@ -132,21 +108,15 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { SessionLookup, r#" SELECT s.user_session_id - , s.created_at AS "user_session_created_at" - , s.finished_at AS "user_session_finished_at" + , s.created_at AS "user_session_created_at" + , s.finished_at AS "user_session_finished_at" , u.user_id - , u.username AS "user_username" - , u.primary_user_email_id AS "user_primary_user_email_id" - , a.user_session_authentication_id AS "last_authentication_id?" - , a.created_at AS "last_authd_at?" + , u.username AS "user_username" + , u.primary_user_email_id AS "user_primary_user_email_id" FROM user_sessions s INNER JOIN users u USING (user_id) - LEFT JOIN user_session_authentications a - USING (user_session_id) WHERE s.user_session_id = $1 - ORDER BY a.created_at DESC - LIMIT 1 "#, Uuid::from(id), ) @@ -199,7 +169,6 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { user: user.clone(), created_at, finished_at: None, - last_authentication: None, }; Ok(session) @@ -278,14 +247,6 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { Expr::col((Users::Table, Users::PrimaryUserEmailId)), SessionLookupIden::UserPrimaryUserEmailId, ) - .expr_as( - Expr::value(None::), - SessionLookupIden::LastAuthenticationId, - ) - .expr_as( - Expr::value(None::>), - SessionLookupIden::LastAuthdAt, - ) .from(UserSessions::Table) .inner_join( Users::Table, @@ -324,6 +285,45 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { Ok(page) } + #[tracing::instrument( + name = "db.browser_session.count", + skip_all, + fields( + db.statement, + ), + err, + )] + async fn count( + &mut self, + filter: mas_storage::user::BrowserSessionFilter<'_>, + ) -> Result { + let (sql, values) = sea_query::Query::select() + .expr(Expr::col((UserSessions::Table, UserSessions::UserSessionId)).count()) + .from(UserSessions::Table) + .and_where_option(filter.user().map(|user| { + Expr::col((UserSessions::Table, UserSessions::UserId)).eq(Uuid::from(user.id)) + })) + .and_where_option(filter.state().map(|state| { + if state.is_active() { + Expr::col((UserSessions::Table, UserSessions::FinishedAt)).is_null() + } else { + Expr::col((UserSessions::Table, UserSessions::FinishedAt)).is_not_null() + } + })) + .build(PostgresQueryBuilder); + + let arguments = map_values(values); + + let count: i64 = sqlx::query_scalar_with(&sql, arguments) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count + .try_into() + .map_err(DatabaseError::to_invalid_operation) + } + #[tracing::instrument( name = "db.browser_session.authenticate_with_password", skip_all, @@ -339,9 +339,9 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - mut user_session: BrowserSession, + user_session: &BrowserSession, user_password: &Password, - ) -> Result { + ) -> Result { let _user_password = user_password; let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -364,9 +364,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { .execute(&mut *self.conn) .await?; - user_session.last_authentication = Some(Authentication { id, created_at }); - - Ok(user_session) + Ok(Authentication { id, created_at }) } #[tracing::instrument( @@ -384,9 +382,9 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - mut user_session: BrowserSession, + user_session: &BrowserSession, upstream_oauth_link: &UpstreamOAuthLink, - ) -> Result { + ) -> Result { let _upstream_oauth_link = upstream_oauth_link; let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -409,8 +407,38 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { .execute(&mut *self.conn) .await?; - user_session.last_authentication = Some(Authentication { id, created_at }); + Ok(Authentication { id, created_at }) + } - Ok(user_session) + #[tracing::instrument( + name = "db.browser_session.get_last_authentication", + skip_all, + fields( + db.statement, + %user_session.id, + ), + err, + )] + async fn get_last_authentication( + &mut self, + user_session: &BrowserSession, + ) -> Result, Self::Error> { + let authentication = sqlx::query_as!( + Authentication, + r#" + SELECT user_session_authentication_id AS id + , created_at + FROM user_session_authentications + WHERE user_session_id = $1 + ORDER BY created_at DESC + LIMIT 1 + "#, + Uuid::from(user_session.id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + Ok(authentication) } } diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 62ea2767..2965dd18 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -15,7 +15,10 @@ use chrono::Duration; use mas_storage::{ clock::MockClock, - user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, + user::{ + BrowserSessionFilter, BrowserSessionRepository, UserEmailRepository, + UserPasswordRepository, UserRepository, + }, Pagination, Repository, RepositoryAccess, }; use rand::SeedableRng; @@ -360,7 +363,11 @@ async fn test_user_session(pool: PgPool) { .await .unwrap(); - assert_eq!(repo.browser_session().count_active(&user).await.unwrap(), 0); + let filter = BrowserSessionFilter::default() + .for_user(&user) + .active_only(); + + assert_eq!(repo.browser_session().count(filter).await.unwrap(), 0); let session = repo .browser_session() @@ -370,12 +377,12 @@ async fn test_user_session(pool: PgPool) { assert_eq!(session.user.id, user.id); assert!(session.finished_at.is_none()); - assert_eq!(repo.browser_session().count_active(&user).await.unwrap(), 1); + assert_eq!(repo.browser_session().count(filter).await.unwrap(), 1); // The session should be in the list of active sessions let session_list = repo .browser_session() - .list_active_paginated(&user, Pagination::first(10)) + .list(filter, Pagination::first(10)) .await .unwrap(); assert!(!session_list.has_next_page); @@ -400,12 +407,12 @@ async fn test_user_session(pool: PgPool) { .unwrap(); // The active session counter is back to 0 - assert_eq!(repo.browser_session().count_active(&user).await.unwrap(), 0); + assert_eq!(repo.browser_session().count(filter).await.unwrap(), 0); // The session should not be in the list of active sessions anymore let session_list = repo .browser_session() - .list_active_paginated(&user, Pagination::first(10)) + .list(filter, Pagination::first(10)) .await .unwrap(); assert!(!session_list.has_next_page); diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index 46e14ee0..f517d9ed 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -13,7 +13,7 @@ // limitations under the License. use async_trait::async_trait; -use mas_data_model::{BrowserSession, Password, UpstreamOAuthLink, User}; +use mas_data_model::{Authentication, BrowserSession, Password, UpstreamOAuthLink, User}; use rand_core::RngCore; use ulid::Ulid; @@ -157,9 +157,18 @@ pub trait BrowserSessionRepository: Send + Sync { pagination: Pagination, ) -> Result, Self::Error>; - /// Authenticate a [`BrowserSession`] with the given [`Password`] + /// Count the number of [`BrowserSession`] with the given filter /// - /// Returns the updated [`BrowserSession`] + /// # Parameters + /// + /// * `filter`: The filter to apply + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn count(&mut self, filter: BrowserSessionFilter<'_>) -> Result; + + /// Authenticate a [`BrowserSession`] with the given [`Password`] /// /// # Parameters /// @@ -175,14 +184,12 @@ pub trait BrowserSessionRepository: Send + Sync { &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_session: BrowserSession, + user_session: &BrowserSession, user_password: &Password, - ) -> Result; + ) -> Result; /// Authenticate a [`BrowserSession`] with the given [`UpstreamOAuthLink`] /// - /// Returns the updated [`BrowserSession`] - /// /// # Parameters /// /// * `rng`: The random number generator to use @@ -198,9 +205,23 @@ pub trait BrowserSessionRepository: Send + Sync { &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_session: BrowserSession, + user_session: &BrowserSession, upstream_oauth_link: &UpstreamOAuthLink, - ) -> Result; + ) -> Result; + + /// Get the last successful authentication for a [`BrowserSession`] + /// + /// # Params + /// + /// * `user_session`: The session for which to get the last authentication + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn get_last_authentication( + &mut self, + user_session: &BrowserSession, + ) -> Result, Self::Error>; } repository_impl!(BrowserSessionRepository: @@ -223,19 +244,26 @@ repository_impl!(BrowserSessionRepository: pagination: Pagination, ) -> Result, Self::Error>; + async fn count(&mut self, filter: BrowserSessionFilter<'_>) -> Result; + async fn authenticate_with_password( &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_session: BrowserSession, + user_session: &BrowserSession, user_password: &Password, - ) -> Result; + ) -> Result; async fn authenticate_with_upstream( &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_session: BrowserSession, + user_session: &BrowserSession, upstream_oauth_link: &UpstreamOAuthLink, - ) -> Result; + ) -> Result; + + async fn get_last_authentication( + &mut self, + user_session: &BrowserSession, + ) -> Result, Self::Error>; ); diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 6b42cb62..a41d53ce 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -102,6 +102,10 @@ type BrowserSessionConnection { A list of nodes. """ nodes: [BrowserSession!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! } """ diff --git a/frontend/src/components/BrowserSessionList.tsx b/frontend/src/components/BrowserSessionList.tsx index a20ed222..dae867af 100644 --- a/frontend/src/components/BrowserSessionList.tsx +++ b/frontend/src/components/BrowserSessionList.tsx @@ -50,6 +50,8 @@ const QUERY = graphql(/* GraphQL */ ` before: $before state: ACTIVE ) { + totalCount + edges { cursor node { @@ -129,6 +131,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => { paginate(prevPage) : null} onNext={nextPage ? (): void => paginate(nextPage) : null} + count={browserSessions.totalCount} disabled={pending} /> {browserSessions.edges.map((n) => ( diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index f74ff549..9fd9046e 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -23,7 +23,7 @@ const documents = { types.BrowserSession_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n": types.EndBrowserSessionDocument, - "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": + "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.BrowserSessionListDocument, "\n fragment CompatSession_sso_login on CompatSsoLogin {\n id\n redirectUri\n }\n": types.CompatSession_Sso_LoginFragmentDoc, @@ -109,8 +109,8 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n" -): typeof documents["\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; + source: "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n" +): typeof documents["\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 54d23e13..1c8a06c3 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -106,6 +106,8 @@ export type BrowserSessionConnection = { nodes: Array; /** Information to aid in pagination. */ pageInfo: PageInfo; + /** Identifies the total count of items in the connection. */ + totalCount: Scalars["Int"]["output"]; }; /** An edge in a connection. */ @@ -879,6 +881,7 @@ export type BrowserSessionListQuery = { id: string; browserSessions: { __typename?: "BrowserSessionConnection"; + totalCount: number; edges: Array<{ __typename?: "BrowserSessionEdge"; cursor: string; @@ -1815,6 +1818,10 @@ export const BrowserSessionListDocument = { selectionSet: { kind: "SelectionSet", selections: [ + { + kind: "Field", + name: { kind: "Name", value: "totalCount" }, + }, { kind: "Field", name: { kind: "Name", value: "edges" }, diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index 82bdeb1a..12c6c4d2 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -217,6 +217,17 @@ export default { }, args: [], }, + { + name: "totalCount", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + args: [], + }, ], interfaces: [], },