1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-06 06:02:40 +03:00

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
This commit is contained in:
Quentin Gliech
2024-02-23 16:47:48 +01:00
committed by GitHub
parent f171d76dc5
commit f3cbd3b315
58 changed files with 1019 additions and 855 deletions

View File

@@ -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<Ulid>,
pub created_at: DateTime<Utc>,
pub is_synapse_admin: bool,
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}

View File

@@ -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,

View File

@@ -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<IpAddr>,
/// The user agent used to request this device code grant.
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
}
impl std::ops::Deref for DeviceCodeGrant {

View File

@@ -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<Ulid>,
pub client_id: Ulid,
pub scope: Scope,
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}

View File

@@ -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<String>,
pub version: Option<String>,
pub os: Option<String>,
pub os_version: Option<String>,
pub model: Option<String>,
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<name>[^/]+)/(?P<version>[^ ]+) \((?P<segments>.+)\)$")
.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,
}
}
}

View File

@@ -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<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}
@@ -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,
})