From f3cbd3b3150140ff57c9baa17aae70e25d3d60e5 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 23 Feb 2024 16:47:48 +0100 Subject: [PATCH] Parse User Agents on the backend side (#2388) * Parse user agents on the server side * Parse and expose user agents on the backend * Use the parsed user agent in the device consent page * Fix the device icon tests * Fix clippy warnings * Box stuff to avoid large enum variants * Ignore a clippy warning * Fix the requester boxing --- Cargo.lock | 12 + crates/data-model/Cargo.toml | 2 + .../data-model/examples/ua-parser.rs | 15 +- crates/data-model/src/compat/session.rs | 4 +- crates/data-model/src/lib.rs | 2 + .../src/oauth2/device_code_grant.rs | 4 +- crates/data-model/src/oauth2/session.rs | 4 +- crates/data-model/src/user_agent.rs | 217 ++++++++++ crates/data-model/src/users.rs | 8 +- crates/graphql/src/lib.rs | 16 +- crates/graphql/src/model/browser_sessions.rs | 8 +- crates/graphql/src/model/compat_sessions.rs | 7 +- crates/graphql/src/model/mod.rs | 68 ++- crates/graphql/src/model/oauth.rs | 7 +- .../graphql/src/mutations/compat_session.rs | 6 +- crates/graphql/src/query/viewer.rs | 13 +- crates/handlers/src/compat/login.rs | 4 +- crates/handlers/src/graphql/mod.rs | 2 +- .../handlers/src/oauth2/device/authorize.rs | 7 +- crates/handlers/src/oauth2/token.rs | 14 +- crates/handlers/src/upstream_oauth2/link.rs | 6 +- crates/handlers/src/views/login.rs | 9 +- crates/handlers/src/views/register.rs | 6 +- crates/storage-pg/src/app_session.rs | 4 +- crates/storage-pg/src/compat/mod.rs | 4 +- crates/storage-pg/src/compat/session.rs | 10 +- .../src/oauth2/device_code_grant.rs | 4 +- crates/storage-pg/src/oauth2/mod.rs | 4 +- crates/storage-pg/src/oauth2/session.rs | 10 +- crates/storage-pg/src/user/session.rs | 8 +- crates/storage/src/compat/session.rs | 6 +- .../storage/src/oauth2/device_code_grant.rs | 4 +- crates/storage/src/oauth2/session.rs | 6 +- crates/storage/src/user/session.rs | 6 +- crates/templates/src/context.rs | 4 +- frontend/.eslintrc.cjs | 2 +- frontend/locales/en.json | 6 +- frontend/schema.graphql | 68 ++- frontend/src/components/BrowserSession.tsx | 27 +- frontend/src/components/CompatSession.tsx | 20 +- frontend/src/components/OAuth2Session.tsx | 29 +- .../Session/DeviceTypeIcon.stories.tsx | 14 +- .../Session/DeviceTypeIcon.test.tsx | 10 +- .../src/components/Session/DeviceTypeIcon.tsx | 10 +- frontend/src/components/Session/Session.tsx | 2 +- .../DeviceTypeIcon.test.tsx.snap | 72 ++-- .../SessionDetail/BrowserSessionDetail.tsx | 23 +- .../SessionDetail/CompatSessionDetail.tsx | 7 +- .../__snapshots__/OAuth2Session.test.tsx.snap | 4 +- frontend/src/gql/gql.ts | 38 +- frontend/src/gql/graphql.ts | 291 ++++++++++--- frontend/src/gql/schema.ts | 92 ++++- frontend/src/templates.css | 19 +- frontend/src/utils/parseUserAgent.test.ts | 390 ------------------ frontend/src/utils/parseUserAgent.ts | 145 ------- .../session/useCurrentBrowserSessionId.ts | 42 -- templates/pages/device_consent.html | 44 +- translations/en.json | 8 +- 58 files changed, 1019 insertions(+), 855 deletions(-) rename frontend/src/utils/session/index.ts => crates/data-model/examples/ua-parser.rs (57%) create mode 100644 crates/data-model/src/user_agent.rs delete mode 100644 frontend/src/utils/parseUserAgent.test.ts delete mode 100644 frontend/src/utils/parseUserAgent.ts delete mode 100644 frontend/src/utils/session/useCurrentBrowserSessionId.ts diff --git a/Cargo.lock b/Cargo.lock index b4718187..918f8fe0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3070,10 +3070,12 @@ dependencies = [ "oauth2-types", "rand 0.8.5", "rand_chacha 0.3.1", + "regex", "serde", "thiserror", "ulid", "url", + "woothee", ] [[package]] @@ -7198,6 +7200,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "woothee" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896174c6a4779d4d7d4523dd27aef7d46609eda2497e370f6c998325c6bf6971" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "writeable" version = "0.5.4" diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml index bebd5457..94273f31 100644 --- a/crates/data-model/Cargo.toml +++ b/crates/data-model/Cargo.toml @@ -20,6 +20,8 @@ crc = "3.0.1" ulid.workspace = true rand.workspace = true rand_chacha = "0.3.1" +regex = "1.10.3" +woothee = "0.13.0" mas-iana.workspace = true mas-jose.workspace = true diff --git a/frontend/src/utils/session/index.ts b/crates/data-model/examples/ua-parser.rs similarity index 57% rename from frontend/src/utils/session/index.ts rename to crates/data-model/examples/ua-parser.rs index e5cd753d..764f8c44 100644 --- a/frontend/src/utils/session/index.ts +++ b/crates/data-model/examples/ua-parser.rs @@ -1,4 +1,4 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. +// Copyright 2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,4 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -export * from "./useCurrentBrowserSessionId"; +use mas_data_model::UserAgent; + +/// Simple command-line tool to try out user-agent parsing +/// +/// It parses user-agents from stdin and prints the parsed user-agent to stdout. +fn main() { + for line in std::io::stdin().lines() { + let user_agent = line.unwrap(); + let user_agent = UserAgent::parse(user_agent); + println!("{user_agent:?}"); + } +} diff --git a/crates/data-model/src/compat/session.rs b/crates/data-model/src/compat/session.rs index 9d4582f7..ab7b373d 100644 --- a/crates/data-model/src/compat/session.rs +++ b/crates/data-model/src/compat/session.rs @@ -19,7 +19,7 @@ use serde::Serialize; use ulid::Ulid; use super::Device; -use crate::InvalidTransitionError; +use crate::{InvalidTransitionError, UserAgent}; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub enum CompatSessionState { @@ -83,7 +83,7 @@ pub struct CompatSession { pub user_session_id: Option, pub created_at: DateTime, pub is_synapse_admin: bool, - pub user_agent: Option, + pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, } diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 0f1ea321..9b52f010 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -20,6 +20,7 @@ pub(crate) mod compat; pub(crate) mod oauth2; pub(crate) mod tokens; pub(crate) mod upstream_oauth2; +pub(crate) mod user_agent; pub(crate) mod users; /// Error when an invalid state transition is attempted. @@ -46,6 +47,7 @@ pub use self::{ UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference, }, + user_agent::{DeviceType, UserAgent}, users::{ Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, UserEmailVerification, UserEmailVerificationState, diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs index 5077d918..c51b92e2 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -19,7 +19,7 @@ use oauth2_types::scope::Scope; use serde::Serialize; use ulid::Ulid; -use crate::{BrowserSession, InvalidTransitionError, Session}; +use crate::{BrowserSession, InvalidTransitionError, Session, UserAgent}; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case", tag = "state")] @@ -200,7 +200,7 @@ pub struct DeviceCodeGrant { pub ip_address: Option, /// The user agent used to request this device code grant. - pub user_agent: Option, + pub user_agent: Option, } impl std::ops::Deref for DeviceCodeGrant { diff --git a/crates/data-model/src/oauth2/session.rs b/crates/data-model/src/oauth2/session.rs index fb27ab0a..054495b6 100644 --- a/crates/data-model/src/oauth2/session.rs +++ b/crates/data-model/src/oauth2/session.rs @@ -19,7 +19,7 @@ use oauth2_types::scope::Scope; use serde::Serialize; use ulid::Ulid; -use crate::InvalidTransitionError; +use crate::{InvalidTransitionError, UserAgent}; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub enum SessionState { @@ -75,7 +75,7 @@ pub struct Session { pub user_session_id: Option, pub client_id: Ulid, pub scope: Scope, - pub user_agent: Option, + pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, } diff --git a/crates/data-model/src/user_agent.rs b/crates/data-model/src/user_agent.rs new file mode 100644 index 00000000..dc39fb87 --- /dev/null +++ b/crates/data-model/src/user_agent.rs @@ -0,0 +1,217 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde::Serialize; +use woothee::{parser::Parser, woothee::VALUE_UNKNOWN}; + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceType { + Pc, + Mobile, + Tablet, + Unknown, +} + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct UserAgent { + pub name: Option, + pub version: Option, + pub os: Option, + pub os_version: Option, + pub model: Option, + pub device_type: DeviceType, + pub raw: String, +} + +impl std::ops::Deref for UserAgent { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.raw + } +} + +impl UserAgent { + fn parse_custom(user_agent: &str) -> Option<(&str, &str, &str, &str, Option<&str>)> { + let regex = regex::Regex::new(r"^(?P[^/]+)/(?P[^ ]+) \((?P.+)\)$") + .unwrap(); + + let captures = regex.captures(user_agent)?; + let name = captures.name("name")?.as_str(); + let version = captures.name("version")?.as_str(); + let segments: Vec<&str> = captures + .name("segments")? + .as_str() + .split(';') + .map(str::trim) + .collect(); + + match segments[..] { + ["Linux", "U", os, model, ..] | [model, os, ..] => { + // Most android model have a `/[build version]` suffix we don't care about + let model = model.split_once('/').map_or(model, |(model, _)| model); + // Some android version also have `Build/[build version]` suffix we don't care + // about + let model = model.strip_suffix("Build").unwrap_or(model); + // And let's trim any leftovers + let model = model.trim(); + + let (os, os_version) = if let Some((os, version)) = os.split_once(' ') { + (os, Some(version)) + } else { + (os, None) + }; + + Some((name, version, model, os, os_version)) + } + _ => None, + } + } + + #[must_use] + pub fn parse(user_agent: String) -> Self { + if !user_agent.contains("Mozilla/") { + if let Some((name, version, model, os, os_version)) = + UserAgent::parse_custom(&user_agent) + { + let mut device_type = DeviceType::Unknown; + + // Handle mobile simple mobile devices + if os == "Android" || os == "iOS" { + device_type = DeviceType::Mobile; + } + + // Handle iPads + if model.contains("iPad") { + device_type = DeviceType::Tablet; + } + + return Self { + name: Some(name.to_owned()), + version: Some(version.to_owned()), + os: Some(os.to_owned()), + os_version: os_version.map(std::borrow::ToOwned::to_owned), + model: Some(model.to_owned()), + device_type, + raw: user_agent, + }; + } + } + + let mut model = None; + let Some(mut result) = Parser::new().parse(&user_agent) else { + return Self { + raw: user_agent, + name: None, + version: None, + os: None, + os_version: None, + model: None, + device_type: DeviceType::Unknown, + }; + }; + + let mut device_type = match result.category { + "pc" => DeviceType::Pc, + "smartphone" | "mobilephone" => DeviceType::Mobile, + _ => DeviceType::Unknown, + }; + + // Special handling for Chrome user-agent reduction cases + // https://www.chromium.org/updates/ua-reduction/ + match (result.os, &*result.os_version) { + // Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/533.88 (KHTML, like Gecko) + // Chrome/109.1.2342.76 Safari/533.88 + ("Windows 10", "NT 10.0") if user_agent.contains("Windows NT 10.0; Win64; x64") => { + result.os = "Windows"; + result.os_version = VALUE_UNKNOWN.into(); + } + + // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like + // Gecko) Chrome/100.0.4896.133 Safari/537.36 + ("Mac OSX", "10.15.7") if user_agent.contains("Macintosh; Intel Mac OS X 10_15_7") => { + result.os = "macOS"; + result.os_version = VALUE_UNKNOWN.into(); + } + + // Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) + // Chrome/100.0.0.0 Safari/537.36 + ("Linux", _) if user_agent.contains("X11; Linux x86_64") => { + result.os = "Linux"; + result.os_version = VALUE_UNKNOWN.into(); + } + + // Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) + // Chrome/107.0.0.0 Safari/537.36 + ("ChromeOS", _) if user_agent.contains("X11; CrOS x86_64 14541.0.0") => { + result.os = "Chrome OS"; + result.os_version = VALUE_UNKNOWN.into(); + } + + // Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) + // Chrome/100.0.0.0 Mobile Safari/537.36 + ("Android", "10") if user_agent.contains("Linux; Android 10; K") => { + result.os = "Android"; + result.os_version = VALUE_UNKNOWN.into(); + } + + // Safari also freezes the OS version + // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like + // Gecko) Version/17.3.1 Safari/605.1.15 + ("Mac OSX", "10.15.7") if user_agent.contains("Macintosh; Intel Mac OS X 10_15_7") => { + result.os = "macOS"; + result.os_version = VALUE_UNKNOWN.into(); + } + + // Woothee identifies iPhone and iPod in the OS, but we want to map them to iOS and use + // them as model + ("iPhone" | "iPod", _) => { + model = Some(result.os.to_owned()); + result.os = "iOS"; + } + + ("iPad", _) => { + model = Some(result.os.to_owned()); + device_type = DeviceType::Tablet; + result.os = "iPadOS"; + } + + // Also map `Mac OSX` to `macOS` + ("Mac OSX", _) => { + result.os = "macOS"; + } + + _ => {} + } + + // For some reason, the version on Windows is on the OS field + // This transforms `Windows 10` into `Windows` and `10` + if let Some(version) = result.os.strip_prefix("Windows ") { + result.os = "Windows"; + result.os_version = version.into(); + } + + Self { + name: (result.name != VALUE_UNKNOWN).then(|| result.name.to_owned()), + version: (result.version != VALUE_UNKNOWN).then(|| result.version.to_owned()), + os: (result.os != VALUE_UNKNOWN).then(|| result.os.to_owned()), + os_version: (result.os_version != VALUE_UNKNOWN) + .then(|| result.os_version.into_owned()), + device_type, + model, + raw: user_agent, + } + } +} diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index de71e12c..30d07179 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -19,6 +19,8 @@ use rand::{Rng, SeedableRng}; use serde::Serialize; use ulid::Ulid; +use crate::UserAgent; + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct User { pub id: Ulid, @@ -83,7 +85,7 @@ pub struct BrowserSession { pub user: User, pub created_at: DateTime, pub finished_at: Option>, - pub user_agent: Option, + pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, } @@ -105,7 +107,9 @@ impl BrowserSession { user, created_at: now, finished_at: None, - user_agent: Some("Mozilla/5.0".to_owned()), + user_agent: Some(UserAgent::parse( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned() + )), last_active_at: Some(now), last_active_ip: None, }) diff --git a/crates/graphql/src/lib.rs b/crates/graphql/src/lib.rs index 3db76203..77e8a074 100644 --- a/crates/graphql/src/lib.rs +++ b/crates/graphql/src/lib.rs @@ -49,10 +49,10 @@ pub enum Requester { Anonymous, /// The requester is a browser session, stored in a cookie. - BrowserSession(BrowserSession), + BrowserSession(Box), /// The requester is a OAuth2 session, with an access token. - OAuth2Session(Session, Option), + OAuth2Session(Box<(Session, Option)>), } trait OwnerId { @@ -108,21 +108,21 @@ impl Requester { fn browser_session(&self) -> Option<&BrowserSession> { match self { Self::BrowserSession(session) => Some(session), - Self::OAuth2Session(_, _) | Self::Anonymous => None, + Self::OAuth2Session(_) | Self::Anonymous => None, } } fn user(&self) -> Option<&User> { match self { Self::BrowserSession(session) => Some(&session.user), - Self::OAuth2Session(_session, user) => user.as_ref(), + Self::OAuth2Session(tuple) => tuple.1.as_ref(), Self::Anonymous => None, } } fn oauth2_session(&self) -> Option<&Session> { match self { - Self::OAuth2Session(session, _) => Some(session), + Self::OAuth2Session(tuple) => Some(&tuple.0), Self::BrowserSession(_) | Self::Anonymous => None, } } @@ -148,10 +148,10 @@ impl Requester { fn is_admin(&self) -> bool { match self { - Self::OAuth2Session(session, _user) => { + Self::OAuth2Session(tuple) => { // TODO: is this the right scope? // This has to be in sync with the policy - session.scope.contains("urn:mas:admin") + tuple.0.scope.contains("urn:mas:admin") } Self::BrowserSession(_) | Self::Anonymous => false, } @@ -160,7 +160,7 @@ impl Requester { impl From for Requester { fn from(session: BrowserSession) -> Self { - Self::BrowserSession(session) + Self::BrowserSession(Box::new(session)) } } diff --git a/crates/graphql/src/model/browser_sessions.rs b/crates/graphql/src/model/browser_sessions.rs index 7232df2b..ac02b485 100644 --- a/crates/graphql/src/model/browser_sessions.rs +++ b/crates/graphql/src/model/browser_sessions.rs @@ -24,7 +24,7 @@ use mas_storage::{ use super::{ AppSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session, PreloadedTotalCount, - SessionState, User, + SessionState, User, UserAgent, }; use crate::state::ContextExt; @@ -87,9 +87,9 @@ impl BrowserSession { } } - /// The user-agent string with which the session was created. - pub async fn user_agent(&self) -> Option<&str> { - self.0.user_agent.as_deref() + /// The user-agent with which the session was created. + pub async fn user_agent(&self) -> Option { + self.0.user_agent.clone().map(UserAgent::from) } /// The last IP address used by the session. diff --git a/crates/graphql/src/model/compat_sessions.rs b/crates/graphql/src/model/compat_sessions.rs index 735fe031..5ce782fc 100644 --- a/crates/graphql/src/model/compat_sessions.rs +++ b/crates/graphql/src/model/compat_sessions.rs @@ -18,7 +18,7 @@ use chrono::{DateTime, Utc}; use mas_storage::{compat::CompatSessionRepository, user::UserRepository}; use url::Url; -use super::{BrowserSession, NodeType, SessionState, User}; +use super::{BrowserSession, NodeType, SessionState, User, UserAgent}; use crate::state::ContextExt; /// Lazy-loaded reverse reference. @@ -103,6 +103,11 @@ impl CompatSession { self.session.finished_at() } + /// The user-agent with which the session was created. + pub async fn user_agent(&self) -> Option { + self.session.user_agent.clone().map(UserAgent::from) + } + /// The associated SSO login, if any. pub async fn sso_login( &self, diff --git a/crates/graphql/src/model/mod.rs b/crates/graphql/src/model/mod.rs index 0c185fb1..9f169a7c 100644 --- a/crates/graphql/src/model/mod.rs +++ b/crates/graphql/src/model/mod.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Enum, Interface, Object}; +use async_graphql::{Enum, Interface, Object, SimpleObject}; use chrono::{DateTime, Utc}; mod browser_sessions; @@ -73,3 +73,69 @@ pub enum SessionState { /// The session is no longer active. Finished, } + +/// The type of a user agent +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum DeviceType { + /// A personal computer, laptop or desktop + Pc, + + /// A mobile phone. Can also sometimes be a tablet. + Mobile, + + /// A tablet + Tablet, + + /// Unknown device type + Unknown, +} + +impl From for DeviceType { + fn from(device_type: mas_data_model::DeviceType) -> Self { + match device_type { + mas_data_model::DeviceType::Pc => Self::Pc, + mas_data_model::DeviceType::Mobile => Self::Mobile, + mas_data_model::DeviceType::Tablet => Self::Tablet, + mas_data_model::DeviceType::Unknown => Self::Unknown, + } + } +} + +/// A parsed user agent string +#[derive(SimpleObject)] +pub struct UserAgent { + /// The user agent string + pub raw: String, + + /// The name of the browser + pub name: Option, + + /// The version of the browser + pub version: Option, + + /// The operating system name + pub os: Option, + + /// The operating system version + pub os_version: Option, + + /// The device model + pub model: Option, + + /// The device type + pub device_type: DeviceType, +} + +impl From for UserAgent { + fn from(ua: mas_data_model::UserAgent) -> Self { + Self { + raw: ua.raw, + name: ua.name, + version: ua.version, + os: ua.os, + os_version: ua.os_version, + model: ua.model, + device_type: ua.device_type.into(), + } + } +} diff --git a/crates/graphql/src/model/oauth.rs b/crates/graphql/src/model/oauth.rs index 090968a0..6cb9a2a3 100644 --- a/crates/graphql/src/model/oauth.rs +++ b/crates/graphql/src/model/oauth.rs @@ -20,7 +20,7 @@ use oauth2_types::{oidc::ApplicationType, scope::Scope}; use ulid::Ulid; use url::Url; -use super::{BrowserSession, NodeType, SessionState, User}; +use super::{BrowserSession, NodeType, SessionState, User, UserAgent}; use crate::{state::ContextExt, UserId}; /// An OAuth 2.0 session represents a client session which used the OAuth APIs @@ -67,6 +67,11 @@ impl OAuth2Session { } } + /// The user-agent with which the session was created. + pub async fn user_agent(&self) -> Option { + self.0.user_agent.clone().map(UserAgent::from) + } + /// The state of the session. pub async fn state(&self) -> SessionState { match &self.0.state { diff --git a/crates/graphql/src/mutations/compat_session.rs b/crates/graphql/src/mutations/compat_session.rs index 877a08ff..38c6cb8a 100644 --- a/crates/graphql/src/mutations/compat_session.rs +++ b/crates/graphql/src/mutations/compat_session.rs @@ -40,7 +40,7 @@ pub struct EndCompatSessionInput { /// The payload of the `endCompatSession` mutation. pub enum EndCompatSessionPayload { NotFound, - Ended(mas_data_model::CompatSession), + Ended(Box), } /// The status of the `endCompatSession` mutation. @@ -66,7 +66,7 @@ impl EndCompatSessionPayload { /// Returns the ended session. async fn compat_session(&self) -> Option { match self { - Self::Ended(session) => Some(CompatSession::new(session.clone())), + Self::Ended(session) => Some(CompatSession::new(*session.clone())), Self::NotFound => None, } } @@ -110,6 +110,6 @@ impl CompatSessionMutations { repo.save().await?; - Ok(EndCompatSessionPayload::Ended(session)) + Ok(EndCompatSessionPayload::Ended(Box::new(session))) } } diff --git a/crates/graphql/src/query/viewer.rs b/crates/graphql/src/query/viewer.rs index 11ef37ed..d74675c7 100644 --- a/crates/graphql/src/query/viewer.rs +++ b/crates/graphql/src/query/viewer.rs @@ -31,8 +31,11 @@ impl ViewerQuery { match requester { Requester::BrowserSession(session) => Viewer::user(session.user.clone()), - Requester::OAuth2Session(_session, Some(user)) => Viewer::user(user.clone()), - Requester::OAuth2Session(_, None) | Requester::Anonymous => Viewer::anonymous(), + Requester::OAuth2Session(tuple) => match &tuple.1 { + Some(user) => Viewer::user(user.clone()), + None => Viewer::anonymous(), + }, + Requester::Anonymous => Viewer::anonymous(), } } @@ -41,10 +44,8 @@ impl ViewerQuery { let requester = ctx.requester(); match requester { - Requester::BrowserSession(session) => ViewerSession::browser_session(session.clone()), - Requester::OAuth2Session(session, _user) => { - ViewerSession::oauth2_session(session.clone()) - } + Requester::BrowserSession(session) => ViewerSession::browser_session(*session.clone()), + Requester::OAuth2Session(tuple) => ViewerSession::oauth2_session(tuple.0.clone()), Requester::Anonymous => ViewerSession::anonymous(), } } diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5f27123c..7eaf81c9 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -16,7 +16,7 @@ use axum::{extract::State, response::IntoResponse, Json, TypedHeader}; use chrono::Duration; use hyper::StatusCode; use mas_axum_utils::sentry::SentryEventID; -use mas_data_model::{CompatSession, CompatSsoLoginState, Device, TokenType, User}; +use mas_data_model::{CompatSession, CompatSsoLoginState, Device, TokenType, User, UserAgent}; use mas_storage::{ compat::{ CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository, @@ -220,7 +220,7 @@ pub(crate) async fn post( user_agent: Option>, Json(input): Json, ) -> Result { - let user_agent = user_agent.map(|ua| ua.to_string()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let (mut session, user) = match (password_manager.is_enabled(), input.credentials) { ( true, diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index a2bc6a89..fb395424 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -237,7 +237,7 @@ async fn get_requester( return Err(RouteError::MissingScope); } - Requester::OAuth2Session(session, user) + Requester::OAuth2Session(Box::new((session, user))) } else { let maybe_session = session_info.load_session(&mut repo).await?; diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index ab639794..1428fcf3 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -14,13 +14,14 @@ use axum::{extract::State, response::IntoResponse, Json, TypedHeader}; use chrono::Duration; -use headers::{CacheControl, Pragma, UserAgent}; +use headers::{CacheControl, Pragma}; use hyper::StatusCode; use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, http_client_factory::HttpClientFactory, sentry::SentryEventID, }; +use mas_data_model::UserAgent; use mas_keystore::Encrypter; use mas_router::UrlBuilder; use mas_storage::{oauth2::OAuth2DeviceCodeGrantParams, BoxClock, BoxRepository, BoxRng}; @@ -84,7 +85,7 @@ pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, - user_agent: Option>, + user_agent: Option>, activity_tracker: BoundActivityTracker, State(url_builder): State, State(http_client_factory): State, @@ -125,7 +126,7 @@ pub(crate) async fn post( let expires_in = Duration::minutes(20); - let user_agent = user_agent.map(|ua| ua.0.to_string()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let ip_address = activity_tracker.ip(); let device_code = Alphanumeric.sample_string(&mut rng, 32); diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index f2e2df42..8f4bd3d8 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -21,7 +21,9 @@ use mas_axum_utils::{ http_client_factory::HttpClientFactory, sentry::SentryEventID, }; -use mas_data_model::{AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType}; +use mas_data_model::{ + AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType, UserAgent, +}; use mas_keystore::{Encrypter, Keystore}; use mas_oidc_client::types::scope::ScopeToken; use mas_policy::Policy; @@ -233,7 +235,7 @@ pub(crate) async fn post( user_agent: Option>, client_authorization: ClientAuthorization, ) -> Result { - let user_agent = user_agent.map(|ua| ua.to_string()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let client = client_authorization .credentials .fetch(&mut repo) @@ -335,7 +337,7 @@ async fn authorization_code_grant( url_builder: &UrlBuilder, site_config: &SiteConfig, mut repo: BoxRepository, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::AuthorizationCode) { @@ -504,7 +506,7 @@ async fn refresh_token_grant( client: &Client, site_config: &SiteConfig, mut repo: BoxRepository, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::RefreshToken) { @@ -587,7 +589,7 @@ async fn client_credentials_grant( site_config: &SiteConfig, mut repo: BoxRepository, mut policy: Policy, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::ClientCredentials) { @@ -656,7 +658,7 @@ async fn device_code_grant( url_builder: &UrlBuilder, site_config: &SiteConfig, mut repo: BoxRepository, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::DeviceCode) { diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 7bfb1e45..0902fba1 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -24,7 +24,7 @@ use mas_axum_utils::{ sentry::SentryEventID, FancyError, SessionInfoExt, }; -use mas_data_model::User; +use mas_data_model::{User, UserAgent}; use mas_jose::jwt::Jwt; use mas_policy::Policy; use mas_router::UrlBuilder; @@ -200,7 +200,7 @@ pub(crate) async fn get( user_agent: Option>, Path(link_id): Path, ) -> Result { - let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); let (session_id, post_auth_action) = sessions_cookie .lookup_link(link_id) @@ -481,7 +481,7 @@ pub(crate) async fn post( Path(link_id): Path, Form(form): Form>, ) -> Result { - let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let form = cookie_jar.verify_form(&clock, form)?; let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 48d61446..fdca38dd 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -17,14 +17,13 @@ use axum::{ response::{Html, IntoResponse, Response}, TypedHeader, }; -use headers::UserAgent; use hyper::StatusCode; use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, CsrfToken, ProtectedForm}, FancyError, SessionInfoExt, }; -use mas_data_model::BrowserSession; +use mas_data_model::{BrowserSession, UserAgent}; use mas_i18n::DataLocale; use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; use mas_storage::{ @@ -123,10 +122,10 @@ pub(crate) async fn post( activity_tracker: BoundActivityTracker, Query(query): Query, cookie_jar: CookieJar, - user_agent: Option>, + user_agent: Option>, Form(form): Form>, ) -> Result { - let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); if !password_manager.is_enabled() { // XXX: is it necessary to have better errors here? return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); @@ -216,7 +215,7 @@ async fn login( clock: &impl Clock, username: &str, password: &str, - user_agent: Option, + user_agent: Option, ) -> Result { // XXX: we're loosing the error context here // First, lookup the user diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index abac1bd5..3edad71c 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -19,7 +19,6 @@ use axum::{ response::{Html, IntoResponse, Response}, TypedHeader, }; -use headers::UserAgent; use hyper::StatusCode; use lettre::Address; use mas_axum_utils::{ @@ -27,6 +26,7 @@ use mas_axum_utils::{ csrf::{CsrfExt, CsrfToken, ProtectedForm}, FancyError, SessionInfoExt, }; +use mas_data_model::UserAgent; use mas_i18n::DataLocale; use mas_policy::Policy; use mas_router::UrlBuilder; @@ -116,10 +116,10 @@ pub(crate) async fn post( activity_tracker: BoundActivityTracker, Query(query): Query, cookie_jar: CookieJar, - user_agent: Option>, + user_agent: Option>, Form(form): Form>, ) -> Result { - let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); if !password_manager.is_enabled() { return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); } diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index cd2786fd..af1ebbb6 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -15,7 +15,7 @@ //! A module containing PostgreSQL implementation of repositories for sessions use async_trait::async_trait; -use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState}; +use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState, UserAgent}; use mas_storage::{ app_session::{AppSession, AppSessionFilter, AppSessionRepository}, Page, Pagination, @@ -84,6 +84,7 @@ use priv_::{AppSessionLookup, AppSessionLookupIden}; impl TryFrom for AppSession { type Error = DatabaseError; + #[allow(clippy::too_many_lines)] fn try_from(value: AppSessionLookup) -> Result { // This is annoying to do, but we have to match on all the fields to determine // whether it's a compat session or an oauth2 session @@ -104,6 +105,7 @@ impl TryFrom for AppSession { last_active_ip, } = value; + let user_agent = user_agent.map(UserAgent::parse); let user_session_id = user_session_id.map(Ulid::from); match ( diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index f2c8894b..418824fe 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -28,7 +28,7 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::Device; + use mas_data_model::{Device, UserAgent}; use mas_storage::{ clock::MockClock, compat::{ @@ -133,7 +133,7 @@ mod tests { assert!(session_lookup.user_agent.is_none()); let session = repo .compat_session() - .record_user_agent(session_lookup, "Mozilla/5.0".to_owned()) + .record_user_agent(session_lookup, UserAgent::parse("Mozilla/5.0".to_owned())) .await .unwrap(); assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0")); diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index b980bcbb..2b183253 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, - User, + User, UserAgent, }; use mas_storage::{ compat::{CompatSessionFilter, CompatSessionRepository}, @@ -90,7 +90,7 @@ impl TryFrom for CompatSession { device, created_at: value.created_at, is_synapse_admin: value.is_synapse_admin, - user_agent: value.user_agent, + user_agent: value.user_agent.map(UserAgent::parse), last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, }; @@ -145,7 +145,7 @@ impl TryFrom for (CompatSession, Option CompatSessionRepository for PgCompatSessionRepository<'c> { async fn record_user_agent( &mut self, mut compat_session: CompatSession, - user_agent: String, + user_agent: UserAgent, ) -> Result { let res = sqlx::query!( r#" @@ -584,7 +584,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { WHERE compat_session_id = $1 "#, Uuid::from(compat_session.id), - user_agent, + &*user_agent, ) .traced() .execute(&mut *self.conn) diff --git a/crates/storage-pg/src/oauth2/device_code_grant.rs b/crates/storage-pg/src/oauth2/device_code_grant.rs index 480f605b..584dbe00 100644 --- a/crates/storage-pg/src/oauth2/device_code_grant.rs +++ b/crates/storage-pg/src/oauth2/device_code_grant.rs @@ -16,7 +16,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session}; +use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session, UserAgent}; use mas_storage::{ oauth2::{OAuth2DeviceCodeGrantParams, OAuth2DeviceCodeGrantRepository}, Clock, @@ -140,7 +140,7 @@ impl TryFrom for DeviceCodeGrant { created_at, expires_at, ip_address, - user_agent, + user_agent: user_agent.map(UserAgent::parse), }) } } diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index 0f824ddc..0913e832 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -32,7 +32,7 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::AuthorizationCode; + use mas_data_model::{AuthorizationCode, UserAgent}; use mas_storage::{ clock::MockClock, oauth2::{OAuth2DeviceCodeGrantParams, OAuth2SessionFilter, OAuth2SessionRepository}, @@ -371,7 +371,7 @@ mod tests { assert!(session.user_agent.is_none()); let session = repo .oauth2_session() - .record_user_agent(session, "Mozilla/5.0".to_owned()) + .record_user_agent(session, UserAgent::parse("Mozilla/5.0".to_owned())) .await .unwrap(); assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0")); diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index e80523e2..663ecd35 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -16,7 +16,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Client, Session, SessionState, User}; +use mas_data_model::{BrowserSession, Client, Session, SessionState, User, UserAgent}; use mas_storage::{ oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, Clock, Page, Pagination, @@ -94,7 +94,7 @@ impl TryFrom for Session { user_id: value.user_id.map(Ulid::from), user_session_id: value.user_session_id.map(Ulid::from), scope, - user_agent: value.user_agent, + user_agent: value.user_agent.map(UserAgent::parse), last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, }) @@ -444,14 +444,14 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> { %session.id, %session.scope, client.id = %session.client_id, - session.user_agent = %user_agent, + session.user_agent = %user_agent.raw, ), err, )] async fn record_user_agent( &mut self, mut session: Session, - user_agent: String, + user_agent: UserAgent, ) -> Result { let res = sqlx::query!( r#" @@ -460,7 +460,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> { WHERE oauth2_session_id = $1 "#, Uuid::from(session.id), - user_agent, + &*user_agent, ) .traced() .execute(&mut *self.conn) diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index f19905a1..f7cafd64 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ Authentication, AuthenticationMethod, BrowserSession, Password, - UpstreamOAuthAuthorizationSession, User, + UpstreamOAuthAuthorizationSession, User, UserAgent, }; use mas_storage::{user::BrowserSessionRepository, Clock, Page, Pagination}; use rand::RngCore; @@ -86,7 +86,7 @@ impl TryFrom for BrowserSession { user, created_at: value.user_session_created_at, finished_at: value.user_session_finished_at, - user_agent: value.user_session_user_agent, + user_agent: value.user_session_user_agent.map(UserAgent::parse), last_active_at: value.user_session_last_active_at, last_active_ip: value.user_session_last_active_ip, }) @@ -189,7 +189,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, user: &User, - user_agent: Option, + user_agent: Option, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -203,7 +203,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { Uuid::from(id), Uuid::from(user.id), created_at, - user_agent, + user_agent.as_deref(), ) .traced() .execute(&mut *self.conn) diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index d676d2ae..6a0b4ab5 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -16,7 +16,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User}; +use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User, UserAgent}; use rand_core::RngCore; use ulid::Ulid; @@ -266,7 +266,7 @@ pub trait CompatSessionRepository: Send + Sync { async fn record_user_agent( &mut self, compat_session: CompatSession, - user_agent: String, + user_agent: UserAgent, ) -> Result; } @@ -305,6 +305,6 @@ repository_impl!(CompatSessionRepository: async fn record_user_agent( &mut self, compat_session: CompatSession, - user_agent: String, + user_agent: UserAgent, ) -> Result; ); diff --git a/crates/storage/src/oauth2/device_code_grant.rs b/crates/storage/src/oauth2/device_code_grant.rs index 579b1524..89762a6b 100644 --- a/crates/storage/src/oauth2/device_code_grant.rs +++ b/crates/storage/src/oauth2/device_code_grant.rs @@ -16,7 +16,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::Duration; -use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session}; +use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session, UserAgent}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; @@ -44,7 +44,7 @@ pub struct OAuth2DeviceCodeGrantParams<'a> { pub ip_address: Option, /// The user agent from which the request was made - pub user_agent: Option, + pub user_agent: Option, } /// An [`OAuth2DeviceCodeGrantRepository`] helps interacting with diff --git a/crates/storage/src/oauth2/session.rs b/crates/storage/src/oauth2/session.rs index 28a1b3a6..60a130e0 100644 --- a/crates/storage/src/oauth2/session.rs +++ b/crates/storage/src/oauth2/session.rs @@ -16,7 +16,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Client, Session, User}; +use mas_data_model::{BrowserSession, Client, Session, User, UserAgent}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; @@ -296,7 +296,7 @@ pub trait OAuth2SessionRepository: Send + Sync { async fn record_user_agent( &mut self, session: Session, - user_agent: String, + user_agent: UserAgent, ) -> Result; } @@ -349,6 +349,6 @@ repository_impl!(OAuth2SessionRepository: async fn record_user_agent( &mut self, session: Session, - user_agent: String, + user_agent: UserAgent, ) -> Result; ); diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index 77b27826..fa1d1763 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -17,7 +17,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, + Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, UserAgent, }; use rand_core::RngCore; use ulid::Ulid; @@ -127,7 +127,7 @@ pub trait BrowserSessionRepository: Send + Sync { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, user: &User, - user_agent: Option, + user_agent: Option, ) -> Result; /// Finish a [`BrowserSession`] @@ -254,7 +254,7 @@ repository_impl!(BrowserSessionRepository: rng: &mut (dyn RngCore + Send), clock: &dyn Clock, user: &User, - user_agent: Option, + user_agent: Option, ) -> Result; async fn finish( &mut self, diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 8e09982a..0c99a1bf 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -25,7 +25,7 @@ use chrono::{DateTime, Duration, Utc}; use http::{Method, Uri, Version}; use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, - DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, + DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserAgent, UserEmail, UserEmailVerification, }; use mas_i18n::DataLocale; @@ -1164,7 +1164,7 @@ impl TemplateContext for DeviceConsentContext { created_at: now - Duration::minutes(5), expires_at: now + Duration::minutes(25), ip_address: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), - user_agent: Some("Mozilla/5.0".to_owned()), + user_agent: Some(UserAgent::parse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned())), }; Self { grant, client } }) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 11284832..5e7467e9 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -102,7 +102,7 @@ module.exports = { exceptions: { // The '*Connection', '*Edge', '*Payload' and 'PageInfo' types don't have IDs // XXX: Maybe the MatrixUser type should have an ID? - types: ["PageInfo", "MatrixUser"], + types: ["PageInfo", "MatrixUser", "UserAgent"], suffixes: ["Connection", "Edge", "Payload"], }, }, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index a053b3aa..46b30816 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -55,10 +55,10 @@ "session_details_title": "Session" }, "device_type_icon_label": { - "desktop": "Desktop", "mobile": "Mobile", - "unknown": "Unknown device type", - "web": "Web" + "pc": "Computer", + "tablet": "Tablet", + "unknown": "Unknown device type" }, "end_session_button": { "confirmation_modal_title": "Are you sure you want to end this session?", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 09e1111d..54de662b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -212,9 +212,9 @@ type BrowserSession implements Node & CreationEvent { """ state: SessionState! """ - The user-agent string with which the session was created. + The user-agent with which the session was created. """ - userAgent: String + userAgent: UserAgent """ The last IP address used by the session. """ @@ -314,6 +314,10 @@ type CompatSession implements Node & CreationEvent { """ finishedAt: DateTime """ + The user-agent with which the session was created. + """ + userAgent: UserAgent + """ The associated SSO login, if any. """ ssoLogin: CompatSsoLogin @@ -500,6 +504,28 @@ The input/output is a string in RFC3339 format. """ scalar DateTime +""" +The type of a user agent +""" +enum DeviceType { + """ + A personal computer, laptop or desktop + """ + PC + """ + A mobile phone. Can also sometimes be a tablet. + """ + MOBILE + """ + A tablet + """ + TABLET + """ + Unknown device type + """ + UNKNOWN +} + """ The input of the `endBrowserSession` mutation. """ @@ -822,6 +848,10 @@ type Oauth2Session implements Node & CreationEvent { """ finishedAt: DateTime """ + The user-agent with which the session was created. + """ + userAgent: UserAgent + """ The state of the session. """ state: SessionState! @@ -1529,6 +1559,40 @@ type User implements Node { ): AppSessionConnection! } +""" +A parsed user agent string +""" +type UserAgent { + """ + The user agent string + """ + raw: String! + """ + The name of the browser + """ + name: String + """ + The version of the browser + """ + version: String + """ + The operating system name + """ + os: String + """ + The operating system version + """ + osVersion: String + """ + The device model + """ + model: String + """ + The device type + """ + deviceType: DeviceType! +} + """ A user email address """ diff --git a/frontend/src/components/BrowserSession.tsx b/frontend/src/components/BrowserSession.tsx index 846dde40..25f30ad9 100644 --- a/frontend/src/components/BrowserSession.tsx +++ b/frontend/src/components/BrowserSession.tsx @@ -17,10 +17,6 @@ import { useCallback } from "react"; import { useMutation } from "urql"; import { FragmentType, graphql, useFragment } from "../gql"; -import { - parseUserAgent, - sessionNameFromDeviceInformation, -} from "../utils/parseUserAgent"; import EndSessionButton from "./Session/EndSessionButton"; import Session from "./Session/Session"; @@ -30,7 +26,13 @@ const FRAGMENT = graphql(/* GraphQL */ ` id createdAt finishedAt - userAgent + userAgent { + raw + name + os + model + deviceType + } lastActiveIp lastActiveAt lastAuthentication { @@ -83,9 +85,16 @@ const BrowserSession: React.FC = ({ session, isCurrent }) => { const lastActiveAt = data.lastActiveAt ? parseISO(data.lastActiveAt) : undefined; - const deviceInformation = parseUserAgent(data.userAgent || undefined); - const sessionName = - sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; + let sessionName = "Browser session"; + if (data.userAgent) { + if (data.userAgent.model && data.userAgent.name) { + sessionName = `${data.userAgent.name} on ${data.userAgent.model}`; + } else if (data.userAgent.name && data.userAgent.os) { + sessionName = `${data.userAgent.name} on ${data.userAgent.os}`; + } else if (data.userAgent.name) { + sessionName = data.userAgent.name; + } + } return ( = ({ session, isCurrent }) => { createdAt={createdAt} finishedAt={finishedAt} isCurrent={isCurrent} - deviceType={deviceInformation?.deviceType} + deviceType={data.userAgent?.deviceType} lastActiveIp={data.lastActiveIp || undefined} lastActiveAt={lastActiveAt} > diff --git a/frontend/src/components/CompatSession.tsx b/frontend/src/components/CompatSession.tsx index cdb4bf52..2dce151f 100644 --- a/frontend/src/components/CompatSession.tsx +++ b/frontend/src/components/CompatSession.tsx @@ -28,6 +28,13 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + userAgent { + raw + name + os + model + deviceType + } ssoLogin { id redirectUri @@ -78,10 +85,20 @@ const CompatSession: React.FC<{ await endCompatSession({ id: data.id }); }; - const clientName = data.ssoLogin?.redirectUri + let clientName = data.ssoLogin?.redirectUri ? simplifyUrl(data.ssoLogin.redirectUri) : undefined; + if (data.userAgent) { + if (data.userAgent.model && data.userAgent.name) { + clientName = `${data.userAgent.name} on ${data.userAgent.model}`; + } else if (data.userAgent.name && data.userAgent.os) { + clientName = `${data.userAgent.name} on ${data.userAgent.os}`; + } else if (data.userAgent.name) { + clientName = data.userAgent.name; + } + } + const createdAt = parseISO(data.createdAt); const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined; const lastActiveAt = data.lastActiveAt @@ -95,6 +112,7 @@ const CompatSession: React.FC<{ createdAt={createdAt} finishedAt={finishedAt} clientName={clientName} + deviceType={data.userAgent?.deviceType} lastActiveIp={data.lastActiveIp || undefined} lastActiveAt={lastActiveAt} > diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx index dc661454..f86b4471 100644 --- a/frontend/src/components/OAuth2Session.tsx +++ b/frontend/src/components/OAuth2Session.tsx @@ -16,9 +16,8 @@ import { parseISO } from "date-fns"; import { useMutation } from "urql"; import { FragmentType, graphql, useFragment } from "../gql"; -import { Oauth2ApplicationType } from "../gql/graphql"; +import { DeviceType, Oauth2ApplicationType } from "../gql/graphql"; import { getDeviceIdFromScope } from "../utils/deviceIdFromScope"; -import { DeviceType } from "../utils/parseUserAgent"; import { Session } from "./Session"; import EndSessionButton from "./Session/EndSessionButton"; @@ -31,6 +30,14 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + + userAgent { + model + os + osVersion + deviceType + } + client { id clientId @@ -57,7 +64,7 @@ const getDeviceTypeFromClientAppType = ( appType?: Oauth2ApplicationType | null, ): DeviceType => { if (appType === Oauth2ApplicationType.Web) { - return DeviceType.Web; + return DeviceType.Pc; } if (appType === Oauth2ApplicationType.Native) { return DeviceType.Mobile; @@ -85,9 +92,17 @@ const OAuth2Session: React.FC = ({ session }) => { ? parseISO(data.lastActiveAt) : undefined; - const deviceType = getDeviceTypeFromClientAppType( - data.client.applicationType, - ); + const deviceType = + (data.userAgent?.deviceType === DeviceType.Unknown + ? null + : data.userAgent?.deviceType) ?? + getDeviceTypeFromClientAppType(data.client.applicationType); + + let clientName = data.client.clientName || data.client.clientId || undefined; + + if (data.userAgent?.model) { + clientName = `${clientName} on ${data.userAgent.model}`; + } return ( = ({ session }) => { name={deviceId} createdAt={createdAt} finishedAt={finishedAt} - clientName={data.client.clientName || data.client.clientId || undefined} + clientName={clientName} clientLogoUri={data.client.logoUri || undefined} deviceType={deviceType} lastActiveIp={data.lastActiveIp || undefined} diff --git a/frontend/src/components/Session/DeviceTypeIcon.stories.tsx b/frontend/src/components/Session/DeviceTypeIcon.stories.tsx index bea5ea98..320ef9e5 100644 --- a/frontend/src/components/Session/DeviceTypeIcon.stories.tsx +++ b/frontend/src/components/Session/DeviceTypeIcon.stories.tsx @@ -14,7 +14,7 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { DeviceType } from "../../utils/parseUserAgent"; +import { DeviceType } from "../../gql/graphql"; import DeviceTypeIcon from "./DeviceTypeIcon"; @@ -30,9 +30,9 @@ const meta = { control: "select", options: [ DeviceType.Unknown, - DeviceType.Desktop, + DeviceType.Pc, DeviceType.Mobile, - DeviceType.Web, + DeviceType.Tablet, ], }, }, @@ -43,9 +43,9 @@ type Story = StoryObj; export const Unknown: Story = {}; -export const Desktop: Story = { +export const Pc: Story = { args: { - deviceType: DeviceType.Desktop, + deviceType: DeviceType.Pc, }, }; export const Mobile: Story = { @@ -53,8 +53,8 @@ export const Mobile: Story = { deviceType: DeviceType.Mobile, }, }; -export const Web: Story = { +export const Tablet: Story = { args: { - deviceType: DeviceType.Web, + deviceType: DeviceType.Tablet, }, }; diff --git a/frontend/src/components/Session/DeviceTypeIcon.test.tsx b/frontend/src/components/Session/DeviceTypeIcon.test.tsx index ef6fa4f5..65b93014 100644 --- a/frontend/src/components/Session/DeviceTypeIcon.test.tsx +++ b/frontend/src/components/Session/DeviceTypeIcon.test.tsx @@ -18,7 +18,7 @@ import { composeStory } from "@storybook/react"; import { render, cleanup } from "@testing-library/react"; import { describe, it, expect, afterEach } from "vitest"; -import Meta, { Unknown, Desktop, Mobile, Web } from "./DeviceTypeIcon.stories"; +import Meta, { Unknown, Pc, Mobile, Tablet } from "./DeviceTypeIcon.stories"; describe("", () => { afterEach(cleanup); @@ -33,13 +33,13 @@ describe("", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); - it("renders desktop device type", () => { - const Component = composeStory(Desktop, Meta); + it("renders pc device type", () => { + const Component = composeStory(Pc, Meta); const { container } = render(); expect(container).toMatchSnapshot(); }); - it("renders Web device type", () => { - const Component = composeStory(Web, Meta); + it("renders tablet device type", () => { + const Component = composeStory(Tablet, Meta); const { container } = render(); expect(container).toMatchSnapshot(); }); diff --git a/frontend/src/components/Session/DeviceTypeIcon.tsx b/frontend/src/components/Session/DeviceTypeIcon.tsx index d26df8ab..d4c294b8 100644 --- a/frontend/src/components/Session/DeviceTypeIcon.tsx +++ b/frontend/src/components/Session/DeviceTypeIcon.tsx @@ -19,7 +19,7 @@ import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg import { FunctionComponent, SVGProps } from "react"; import { useTranslation } from "react-i18next"; -import { DeviceType } from "../../utils/parseUserAgent"; +import { DeviceType } from "../../gql/graphql"; import styles from "./DeviceTypeIcon.module.css"; @@ -28,9 +28,9 @@ const deviceTypeToIcon: Record< FunctionComponent & { title?: string | undefined }> > = { [DeviceType.Unknown]: IconUnknown, - [DeviceType.Desktop]: IconComputer, + [DeviceType.Pc]: IconComputer, [DeviceType.Mobile]: IconMobile, - [DeviceType.Web]: IconBrowser, + [DeviceType.Tablet]: IconBrowser, }; const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({ @@ -42,9 +42,9 @@ const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({ const deviceTypeToLabel: Record = { [DeviceType.Unknown]: t("frontend.device_type_icon_label.unknown"), - [DeviceType.Desktop]: t("frontend.device_type_icon_label.desktop"), + [DeviceType.Pc]: t("frontend.device_type_icon_label.pc"), [DeviceType.Mobile]: t("frontend.device_type_icon_label.mobile"), - [DeviceType.Web]: t("frontend.device_type_icon_label.web"), + [DeviceType.Tablet]: t("frontend.device_type_icon_label.tablet"), }; const label = deviceTypeToLabel[deviceType]; diff --git a/frontend/src/components/Session/Session.tsx b/frontend/src/components/Session/Session.tsx index 9bdbc503..33e1c199 100644 --- a/frontend/src/components/Session/Session.tsx +++ b/frontend/src/components/Session/Session.tsx @@ -16,7 +16,7 @@ import { Link } from "@tanstack/react-router"; import { H6, Text, Badge } from "@vector-im/compound-web"; import { Trans, useTranslation } from "react-i18next"; -import { DeviceType } from "../../utils/parseUserAgent"; +import { DeviceType } from "../../gql/graphql"; import Block from "../Block"; import DateTime from "../DateTime"; diff --git a/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap b/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap index 64083f06..4f6d6833 100644 --- a/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap +++ b/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap @@ -1,41 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > renders Web device type 1`] = ` -
- - - -
-`; - -exports[` > renders desktop device type 1`] = ` -
- - - -
-`; - exports[` > renders mobile device type 1`] = `
> renders mobile device type 1`] = `
`; +exports[` > renders pc device type 1`] = ` +
+ + + +
+`; + +exports[` > renders tablet device type 1`] = ` +
+ + + +
+`; + exports[` > renders unknown device type 1`] = `
= ({ session, isCurrent }) => { const onSessionEnd = useEndBrowserSession(data.id, isCurrent); - const deviceInformation = parseUserAgent(data.userAgent || undefined); - const sessionName = - sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; + let sessionName = "Browser session"; + if (data.userAgent) { + if (data.userAgent.model && data.userAgent.name) { + sessionName = `${data.userAgent.name} on ${data.userAgent.model}`; + } else if (data.userAgent.name && data.userAgent.os) { + sessionName = `${data.userAgent.name} on ${data.userAgent.os}`; + } else if (data.userAgent.name) { + sessionName = data.userAgent.name; + } + } const finishedAt = data.finishedAt ? [ diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx index 0928153e..7b5d3a4f 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx @@ -35,6 +35,11 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + userAgent { + name + os + model + } ssoLogin { id redirectUri @@ -102,7 +107,7 @@ const CompatSessionDetail: React.FC = ({ session }) => { if (data.ssoLogin?.redirectUri) { clientDetails.push({ label: t("frontend.compat_session_detail.name"), - value: simplifyUrl(data.ssoLogin.redirectUri), + value: data.userAgent?.name ?? simplifyUrl(data.ssoLogin.redirectUri), }); clientDetails.push({ label: t("frontend.session.uri_label"), diff --git a/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap b/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap index 6498ee97..0276096d 100644 --- a/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap +++ b/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap @@ -5,7 +5,7 @@ exports[` > renders a finished session 1`] = ` className="_block_17898c _session_634806" > > renders a finished session 1`] = ` xmlns="http://www.w3.org/2000/svg" >
; + /** The user-agent with which the session was created. */ + userAgent?: Maybe; }; /** A browser session represents a logged in user in a browser. */ @@ -241,6 +241,8 @@ export type CompatSession = CreationEvent & state: SessionState; /** The user authorized for this session. */ user: User; + /** The user-agent with which the session was created. */ + userAgent?: Maybe; }; export type CompatSessionConnection = { @@ -343,6 +345,18 @@ export type CreationEvent = { createdAt: Scalars["DateTime"]["output"]; }; +/** The type of a user agent */ +export enum DeviceType { + /** A mobile phone. Can also sometimes be a tablet. */ + Mobile = "MOBILE", + /** A personal computer, laptop or desktop */ + Pc = "PC", + /** A tablet */ + Tablet = "TABLET", + /** Unknown device type */ + Unknown = "UNKNOWN", +} + /** The input of the `endBrowserSession` mutation. */ export type EndBrowserSessionInput = { /** The ID of the session to end. */ @@ -617,6 +631,8 @@ export type Oauth2Session = CreationEvent & state: SessionState; /** User authorized for this session. */ user?: Maybe; + /** The user-agent with which the session was created. */ + userAgent?: Maybe; }; export type Oauth2SessionConnection = { @@ -1058,6 +1074,25 @@ export type UserUpstreamOauth2LinksArgs = { last?: InputMaybe; }; +/** A parsed user agent string */ +export type UserAgent = { + __typename?: "UserAgent"; + /** The device type */ + deviceType: DeviceType; + /** The device model */ + model?: Maybe; + /** The name of the browser */ + name?: Maybe; + /** The operating system name */ + os?: Maybe; + /** The operating system version */ + osVersion?: Maybe; + /** The user agent string */ + raw: Scalars["String"]["output"]; + /** The version of the browser */ + version?: Maybe; +}; + /** A user email address */ export type UserEmail = CreationEvent & Node & { @@ -1144,9 +1179,16 @@ export type BrowserSession_SessionFragment = { id: string; createdAt: string; finishedAt?: string | null; - userAgent?: string | null; lastActiveIp?: string | null; lastActiveAt?: string | null; + userAgent?: { + __typename?: "UserAgent"; + raw: string; + name?: string | null; + os?: string | null; + model?: string | null; + deviceType: DeviceType; + } | null; lastAuthentication?: { __typename?: "Authentication"; id: string; @@ -1193,6 +1235,14 @@ export type CompatSession_SessionFragment = { finishedAt?: string | null; lastActiveIp?: string | null; lastActiveAt?: string | null; + userAgent?: { + __typename?: "UserAgent"; + raw: string; + name?: string | null; + os?: string | null; + model?: string | null; + deviceType: DeviceType; + } | null; ssoLogin?: { __typename?: "CompatSsoLogin"; id: string; @@ -1225,6 +1275,13 @@ export type OAuth2Session_SessionFragment = { finishedAt?: string | null; lastActiveIp?: string | null; lastActiveAt?: string | null; + userAgent?: { + __typename?: "UserAgent"; + model?: string | null; + os?: string | null; + osVersion?: string | null; + deviceType: DeviceType; + } | null; client: { __typename?: "Oauth2Client"; id: string; @@ -1259,9 +1316,14 @@ export type BrowserSession_DetailFragment = { id: string; createdAt: string; finishedAt?: string | null; - userAgent?: string | null; lastActiveIp?: string | null; lastActiveAt?: string | null; + userAgent?: { + __typename?: "UserAgent"; + name?: string | null; + model?: string | null; + os?: string | null; + } | null; lastAuthentication?: { __typename?: "Authentication"; id: string; @@ -1278,6 +1340,12 @@ export type CompatSession_DetailFragment = { finishedAt?: string | null; lastActiveIp?: string | null; lastActiveAt?: string | null; + userAgent?: { + __typename?: "UserAgent"; + name?: string | null; + os?: string | null; + model?: string | null; + } | null; ssoLogin?: { __typename?: "CompatSsoLogin"; id: string; @@ -1736,18 +1804,6 @@ export type AllowCrossSigningResetMutation = { }; }; -export type CurrentViewerSessionQueryQueryVariables = Exact<{ - [key: string]: never; -}>; - -export type CurrentViewerSessionQueryQuery = { - __typename?: "Query"; - viewerSession: - | { __typename: "Anonymous" } - | { __typename: "BrowserSession"; id: string } - | { __typename: "Oauth2Session" }; -}; - export const BrowserSession_SessionFragmentDoc = { kind: "Document", definitions: [ @@ -1764,7 +1820,20 @@ export const BrowserSession_SessionFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, - { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "raw" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { @@ -1828,6 +1897,20 @@ export const CompatSession_SessionFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "raw" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "ssoLogin" }, @@ -1863,6 +1946,19 @@ export const OAuth2Session_SessionFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "osVersion" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "client" }, @@ -1901,7 +1997,18 @@ export const BrowserSession_DetailFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, - { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { @@ -1950,6 +2057,18 @@ export const CompatSession_DetailFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "ssoLogin" }, @@ -2337,7 +2456,20 @@ export const EndBrowserSessionDocument = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, - { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "raw" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { @@ -2512,6 +2644,19 @@ export const EndOAuth2SessionDocument = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "osVersion" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "client" }, @@ -3518,6 +3663,18 @@ export const SessionDetailQueryDocument = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "ssoLogin" }, @@ -3578,7 +3735,18 @@ export const SessionDetailQueryDocument = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, - { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { @@ -3833,7 +4001,20 @@ export const BrowserSessionListDocument = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, - { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "raw" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { @@ -4151,6 +4332,20 @@ export const AppSessionsListQueryDocument = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "raw" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "ssoLogin" }, @@ -4181,6 +4376,19 @@ export const AppSessionsListQueryDocument = { { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "userAgent" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "model" } }, + { kind: "Field", name: { kind: "Name", value: "os" } }, + { kind: "Field", name: { kind: "Name", value: "osVersion" } }, + { kind: "Field", name: { kind: "Name", value: "deviceType" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "client" }, @@ -4639,44 +4847,3 @@ export const AllowCrossSigningResetDocument = { AllowCrossSigningResetMutation, AllowCrossSigningResetMutationVariables >; -export const CurrentViewerSessionQueryDocument = { - kind: "Document", - definitions: [ - { - kind: "OperationDefinition", - operation: "query", - name: { kind: "Name", value: "CurrentViewerSessionQuery" }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { - kind: "Field", - name: { kind: "Name", value: "viewerSession" }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { kind: "Field", name: { kind: "Name", value: "__typename" } }, - { - kind: "InlineFragment", - typeCondition: { - kind: "NamedType", - name: { kind: "Name", value: "BrowserSession" }, - }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { kind: "Field", name: { kind: "Name", value: "id" } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode< - CurrentViewerSessionQueryQuery, - CurrentViewerSessionQueryQueryVariables ->; diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index 16096c7c..630befcf 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -413,8 +413,9 @@ export default { { name: "userAgent", type: { - kind: "SCALAR", - name: "Any", + kind: "OBJECT", + name: "UserAgent", + ofType: null, }, args: [], }, @@ -628,6 +629,15 @@ export default { }, args: [], }, + { + name: "userAgent", + type: { + kind: "OBJECT", + name: "UserAgent", + ofType: null, + }, + args: [], + }, ], interfaces: [ { @@ -1741,6 +1751,15 @@ export default { }, args: [], }, + { + name: "userAgent", + type: { + kind: "OBJECT", + name: "UserAgent", + ofType: null, + }, + args: [], + }, ], interfaces: [ { @@ -3133,6 +3152,75 @@ export default { }, ], }, + { + kind: "OBJECT", + name: "UserAgent", + fields: [ + { + name: "deviceType", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + args: [], + }, + { + name: "model", + type: { + kind: "SCALAR", + name: "Any", + }, + args: [], + }, + { + name: "name", + type: { + kind: "SCALAR", + name: "Any", + }, + args: [], + }, + { + name: "os", + type: { + kind: "SCALAR", + name: "Any", + }, + args: [], + }, + { + name: "osVersion", + type: { + kind: "SCALAR", + name: "Any", + }, + args: [], + }, + { + name: "raw", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + args: [], + }, + { + name: "version", + type: { + kind: "SCALAR", + name: "Any", + }, + args: [], + }, + ], + interfaces: [], + }, { kind: "OBJECT", name: "UserEmail", diff --git a/frontend/src/templates.css b/frontend/src/templates.css index 820cd485..1436080b 100644 --- a/frontend/src/templates.css +++ b/frontend/src/templates.css @@ -90,10 +90,21 @@ width: var(--cpd-space-10x); } - & .name { - font: var(--cpd-font-body-md-semibold); - letter-spacing: var(--cpd-font-letter-spacing-body-md); - color: var(--cpd-color-text-primary); + & .lines { + display: flex; + flex-direction: column; + + & div:first-child { + font: var(--cpd-font-body-md-semibold); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + color: var(--cpd-color-text-primary); + } + + & div { + font: var(--cpd-font-body-sm-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-sm); + color: var(--cpd-color-text-secondary); + } } } diff --git a/frontend/src/utils/parseUserAgent.test.ts b/frontend/src/utils/parseUserAgent.test.ts deleted file mode 100644 index 8bce96b2..00000000 --- a/frontend/src/utils/parseUserAgent.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { describe, it, expect } from "vitest"; - -import { - DeviceType, - DeviceInformation, - parseUserAgent, - sessionNameFromDeviceInformation, -} from "./parseUserAgent"; - -const makeDeviceExtendedInfo = ( - deviceType: DeviceType, - deviceModel?: string, - deviceModelVersion?: string, - deviceOperatingSystem?: string, - deviceOperatingSystemVersion?: string, - clientName?: string, - clientVersion?: string, -): DeviceInformation => ({ - deviceType, - deviceModel, - deviceModelVersion, - deviceOperatingSystem, - deviceOperatingSystemVersion, - client: clientName, - clientVersion, -}); - -/* eslint-disable max-len */ -const ANDROID_UA = [ - // New User Agent Implementation - "Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)", - "Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", - "Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", - "Element/1.5.0 (Google (Nexus) 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", - "Element/1.5.0 (Google (Nexus) (5); Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)", - // Legacy User Agent Implementation - "Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)", - "Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)", -]; - -const ANDROID_EXPECTED_RESULT = [ - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Xiaomi Mi 9T", - undefined, - "Android", - "11", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Samsung", - "SM-G960F", - "Android", - "6.0.1", - ), - makeDeviceExtendedInfo(DeviceType.Mobile, "LG", "Nexus 5", "Android", "7.0"), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Google (Nexus) 5", - undefined, - "Android", - "7.0", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Google (Nexus) (5)", - undefined, - "Android", - "7.0", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Samsung", - "SM-A510F", - "Android", - "6.0.1", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Samsung", - "SM-G610M", - "Android", - "7.0", - ), -]; - -const IOS_UA = [ - "Element/1.8.21 (iPhone; iOS 15.2; Scale/3.00)", - "Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)", - "Element/1.8.21 (iPad Pro (11-inch); iOS 15.2; Scale/3.00)", - "Element/1.8.21 (iPad Pro (12.9-inch) (3rd generation); iOS 15.2; Scale/3.00)", -]; -const IOS_EXPECTED_RESULT = [ - makeDeviceExtendedInfo(DeviceType.Mobile, "Apple", "iPhone", "iOS", "15.2"), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Apple", - "iPhone XS Max", - "iOS", - "15.2", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "iPad Pro (11-inch)", - undefined, - "iOS 15.2", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "iPad Pro (12.9-inch) (3rd generation)", - undefined, - "iOS 15.2", - ), -]; -const DESKTOP_UA = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102" + - " Electron/20.1.1 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36", -]; -const DESKTOP_EXPECTED_RESULT = [ - makeDeviceExtendedInfo( - DeviceType.Desktop, - "Apple", - "Macintosh", - "Mac OS", - undefined, - "Electron", - "20.1.1", - ), - makeDeviceExtendedInfo( - DeviceType.Desktop, - undefined, - undefined, - "Windows", - undefined, - "Electron", - "20.1.1", - ), -]; - -const WEB_UA = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18", - "Mozilla/5.0 (Windows NT 6.0; rv:40.0) Gecko/20100101 Firefox/40.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246", - // using mobile browser - "Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4", - "Mozilla/5.0 (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4", - "Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36", -]; - -const WEB_EXPECTED_RESULT = [ - makeDeviceExtendedInfo( - DeviceType.Web, - "Apple", - "Macintosh", - "Mac OS", - undefined, - "Chrome", - "104.0.5112.102", - ), - makeDeviceExtendedInfo( - DeviceType.Web, - undefined, - undefined, - "Windows", - undefined, - "Chrome", - "104.0.5112.102", - ), - makeDeviceExtendedInfo( - DeviceType.Web, - "Apple", - "Macintosh", - "Mac OS", - undefined, - "Firefox", - "39.0", - ), - makeDeviceExtendedInfo( - DeviceType.Web, - "Apple", - "Macintosh", - "Mac OS", - undefined, - "Safari", - "8.0.3", - ), - makeDeviceExtendedInfo( - DeviceType.Web, - undefined, - undefined, - "Windows", - undefined, - "Firefox", - "40.0", - ), - makeDeviceExtendedInfo( - DeviceType.Web, - undefined, - undefined, - "Windows", - undefined, - "Edge", - "12.246", - ), - // using mobile browser - makeDeviceExtendedInfo( - DeviceType.Web, - "Apple", - "iPad", - "iOS", - undefined, - "Mobile Safari", - "8.0", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Apple", - "iPhone", - "iOS", - "8.4.1", - "Mobile Safari", - "8.0", - ), - makeDeviceExtendedInfo( - DeviceType.Mobile, - "Samsung", - "SM-G973U", - "Android", - "9", - "Chrome", - "69.0.3497.100", - ), -]; - -const MISC_UA = [ - "AppleTV11,1/11.1", - "Curl Client/1.0", - "banana", - "", - // fluffy chat ios - "Dart/2.18 (dart:io)", -]; - -const MISC_EXPECTED_RESULT = [ - makeDeviceExtendedInfo( - DeviceType.Unknown, - "Apple", - "Apple TV", - undefined, - undefined, - undefined, - ), - makeDeviceExtendedInfo( - DeviceType.Unknown, - undefined, - undefined, - undefined, - undefined, - ), - makeDeviceExtendedInfo( - DeviceType.Unknown, - undefined, - undefined, - undefined, - undefined, - ), - makeDeviceExtendedInfo( - DeviceType.Unknown, - undefined, - undefined, - undefined, - undefined, - ), - makeDeviceExtendedInfo( - DeviceType.Unknown, - undefined, - undefined, - undefined, - undefined, - ), -]; -/* eslint-disable max-len */ - -describe("parseUserAgent()", () => { - it("returns deviceType unknown when user agent is falsy", () => { - expect(parseUserAgent(undefined)).toEqual({ - deviceType: DeviceType.Unknown, - }); - }); - - type TestCase = [string, DeviceInformation]; - - const testPlatform = ( - platform: string, - userAgents: string[], - results: DeviceInformation[], - ): void => { - const testCases: TestCase[] = userAgents.map((userAgent, index) => [ - userAgent, - results[index], - ]); - - describe(`on platform ${platform}`, () => { - it.each(testCases)( - "should parse the user agent correctly - %s", - (userAgent, expectedResult) => { - expect(parseUserAgent(userAgent)).toEqual(expectedResult); - }, - ); - }); - }; - - testPlatform("Android", ANDROID_UA, ANDROID_EXPECTED_RESULT); - testPlatform("iOS", IOS_UA, IOS_EXPECTED_RESULT); - testPlatform("Desktop", DESKTOP_UA, DESKTOP_EXPECTED_RESULT); - testPlatform("Web", WEB_UA, WEB_EXPECTED_RESULT); - testPlatform("Misc", MISC_UA, MISC_EXPECTED_RESULT); -}); - -describe("sessionNameFromDeviceInformation", () => { - const deviceInfo = { - client: "Chrome", - clientVersion: "123", - deviceModel: "Apple Macintosh", - deviceOperatingSystem: "Mac OS", - deviceType: DeviceType.Web, - }; - - it("should concatenate device info", () => { - expect(sessionNameFromDeviceInformation(deviceInfo)).toEqual( - "Chrome on Mac OS", - ); - }); - - it("should use device model when deviceOS is falsy", () => { - expect( - sessionNameFromDeviceInformation({ - ...deviceInfo, - deviceOperatingSystem: undefined, - }), - ).toEqual("Chrome on Apple Macintosh"); - }); - - it("should exclude device model and OS when both are falsy", () => { - expect( - sessionNameFromDeviceInformation({ - ...deviceInfo, - deviceOperatingSystem: undefined, - deviceModel: undefined, - }), - ).toEqual("Chrome"); - }); - - it("should exclude client when falsy", () => { - expect( - sessionNameFromDeviceInformation({ - ...deviceInfo, - client: undefined, - }), - ).toEqual("Mac OS"); - }); - - it("should return an empty string when no info", () => { - expect( - sessionNameFromDeviceInformation({ - deviceType: DeviceType.Unknown, - }), - ).toEqual(""); - }); -}); diff --git a/frontend/src/utils/parseUserAgent.ts b/frontend/src/utils/parseUserAgent.ts deleted file mode 100644 index 248b88e4..00000000 --- a/frontend/src/utils/parseUserAgent.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import UAParser from "ua-parser-js"; - -export enum DeviceType { - Desktop = "Desktop", - Mobile = "Mobile", - Web = "Web", - Unknown = "Unknown", -} -export type DeviceInformation = { - deviceType: DeviceType; - // eg Google Pixel 6 - deviceModel?: string; - deviceModelVersion?: string; - // eg Android 11 - deviceOperatingSystem?: string; - deviceOperatingSystemVersion?: string; - // eg Firefox 1.1.0 - client?: string; - clientVersion?: string; -}; - -// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00) -const IOS_KEYWORD = "; iOS "; -const BROWSER_KEYWORD = "Mozilla/"; - -const getDeviceType = ( - userAgent: string, - device: UAParser.IDevice, - browser: UAParser.IBrowser, - operatingSystem: UAParser.IOS, -): DeviceType => { - if (browser.name === "Electron") { - return DeviceType.Desktop; - } - if ( - device.type === "mobile" || - operatingSystem.name?.includes("Android") || - userAgent.indexOf(IOS_KEYWORD) > -1 - ) { - return DeviceType.Mobile; - } - if (browser.name) { - return DeviceType.Web; - } - return DeviceType.Unknown; -}; - -interface CustomValues { - customDeviceModel?: string; - customDeviceOS?: string; -} -/** - * Some mobile model and OS strings are not recognised - * by the UA parsing library - * check they exist by hand - */ -const checkForCustomValues = (userAgent: string): CustomValues => { - if (userAgent.includes(BROWSER_KEYWORD)) { - return {}; - } - - const mightHaveDevice = userAgent.includes("("); - if (!mightHaveDevice) { - return {}; - } - const deviceInfoSegments = userAgent - .substring(userAgent.indexOf("(") + 1) - .split("; "); - const customDeviceModel = deviceInfoSegments[0] || undefined; - const customDeviceOS = deviceInfoSegments[1] || undefined; - return { customDeviceModel, customDeviceOS }; -}; - -export const parseUserAgent = (userAgent?: string): DeviceInformation => { - if (!userAgent) { - return { - deviceType: DeviceType.Unknown, - }; - } - - const parser = new UAParser(userAgent); - - const browser = parser.getBrowser(); - const device = parser.getDevice(); - const operatingSystem = parser.getOS(); - - const deviceType = getDeviceType(userAgent, device, browser, operatingSystem); - - // OSX versions are frozen at 10.15.17 in UA strings https://chromestatus.com/feature/5452592194781184 - // ignore OS version in browser based sessions - const shouldIgnoreOSVersion = - deviceType === DeviceType.Web || deviceType === DeviceType.Desktop; - const deviceOperatingSystem = operatingSystem.name; - const deviceOperatingSystemVersion = shouldIgnoreOSVersion - ? undefined - : operatingSystem.version; - const deviceModel = device.vendor; - const deviceModelVersion = device.model; - const client = browser.name; - const clientVersion = browser.version; - - // only try to parse custom model and OS when device type is known - const { customDeviceModel, customDeviceOS } = - deviceType !== DeviceType.Unknown - ? checkForCustomValues(userAgent) - : ({} as CustomValues); - - return { - deviceType, - deviceModel: deviceModel || customDeviceModel, - deviceModelVersion, - deviceOperatingSystem: deviceOperatingSystem || customDeviceOS, - deviceOperatingSystemVersion, - client, - clientVersion, - }; -}; - -export const sessionNameFromDeviceInformation = ({ - deviceModel, - deviceOperatingSystem, - client, -}: DeviceInformation): string | undefined => { - const description = [client, deviceOperatingSystem || deviceModel] - .filter(Boolean) - .join(" on "); - - return description; -}; diff --git a/frontend/src/utils/session/useCurrentBrowserSessionId.ts b/frontend/src/utils/session/useCurrentBrowserSessionId.ts deleted file mode 100644 index 96e01a24..00000000 --- a/frontend/src/utils/session/useCurrentBrowserSessionId.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { useQuery } from "urql"; - -import { graphql } from "../../gql"; - -const CURRENT_VIEWER_SESSION_QUERY = graphql(/* GraphQL */ ` - query CurrentViewerSessionQuery { - viewerSession { - __typename - ... on BrowserSession { - id - } - } - } -`); - -/** - * Query the current browser session id - * and unwrap the result - * throws error when error result - */ -export const useCurrentBrowserSessionId = (): string | null => { - const [result] = useQuery({ query: CURRENT_VIEWER_SESSION_QUERY }); - if (result.error) throw result.error; - if (!result.data) throw new Error(); // Suspense mode is enabled - return result.data.viewerSession.__typename === "BrowserSession" - ? result.data.viewerSession.id - : null; -}; diff --git a/templates/pages/device_consent.html b/templates/pages/device_consent.html index f7e7141a..593247e6 100644 --- a/templates/pages/device_consent.html +++ b/templates/pages/device_consent.html @@ -33,10 +33,46 @@ limitations under the License.

Allow access to your account?