You've already forked authentication-service
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:
@@ -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>,
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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 {
|
||||
|
@@ -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>,
|
||||
}
|
||||
|
217
crates/data-model/src/user_agent.rs
Normal file
217
crates/data-model/src/user_agent.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
@@ -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,
|
||||
})
|
||||
|
Reference in New Issue
Block a user