1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +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

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

View File

@@ -0,0 +1,26 @@
// 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 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:?}");
}
}

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,
})

View File

@@ -49,10 +49,10 @@ pub enum Requester {
Anonymous,
/// The requester is a browser session, stored in a cookie.
BrowserSession(BrowserSession),
BrowserSession(Box<BrowserSession>),
/// The requester is a OAuth2 session, with an access token.
OAuth2Session(Session, Option<User>),
OAuth2Session(Box<(Session, Option<User>)>),
}
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<BrowserSession> for Requester {
fn from(session: BrowserSession) -> Self {
Self::BrowserSession(session)
Self::BrowserSession(Box::new(session))
}
}

View File

@@ -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<UserAgent> {
self.0.user_agent.clone().map(UserAgent::from)
}
/// The last IP address used by the session.

View File

@@ -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<UserAgent> {
self.session.user_agent.clone().map(UserAgent::from)
}
/// The associated SSO login, if any.
pub async fn sso_login(
&self,

View File

@@ -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<mas_data_model::DeviceType> 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<String>,
/// The version of the browser
pub version: Option<String>,
/// The operating system name
pub os: Option<String>,
/// The operating system version
pub os_version: Option<String>,
/// The device model
pub model: Option<String>,
/// The device type
pub device_type: DeviceType,
}
impl From<mas_data_model::UserAgent> 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(),
}
}
}

View File

@@ -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<UserAgent> {
self.0.user_agent.clone().map(UserAgent::from)
}
/// The state of the session.
pub async fn state(&self) -> SessionState {
match &self.0.state {

View File

@@ -40,7 +40,7 @@ pub struct EndCompatSessionInput {
/// The payload of the `endCompatSession` mutation.
pub enum EndCompatSessionPayload {
NotFound,
Ended(mas_data_model::CompatSession),
Ended(Box<mas_data_model::CompatSession>),
}
/// The status of the `endCompatSession` mutation.
@@ -66,7 +66,7 @@ impl EndCompatSessionPayload {
/// Returns the ended session.
async fn compat_session(&self) -> Option<CompatSession> {
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)))
}
}

View File

@@ -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(),
}
}

View File

@@ -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<TypedHeader<headers::UserAgent>>,
Json(input): Json<RequestBody>,
) -> Result<impl IntoResponse, RouteError> {
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,

View File

@@ -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?;

View File

@@ -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<TypedHeader<UserAgent>>,
user_agent: Option<TypedHeader<headers::UserAgent>>,
activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>,
State(http_client_factory): State<HttpClientFactory>,
@@ -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);

View File

@@ -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<TypedHeader<headers::UserAgent>>,
client_authorization: ClientAuthorization<AccessTokenRequest>,
) -> Result<impl IntoResponse, RouteError> {
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<String>,
user_agent: Option<UserAgent>,
) -> 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<String>,
user_agent: Option<UserAgent>,
) -> 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<String>,
user_agent: Option<UserAgent>,
) -> 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<String>,
user_agent: Option<UserAgent>,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// Check that the client is allowed to use this grant type
if !client.grant_types.contains(&GrantType::DeviceCode) {

View File

@@ -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<TypedHeader<headers::UserAgent>>,
Path(link_id): Path<Ulid>,
) -> Result<impl IntoResponse, RouteError> {
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<Ulid>,
Form(form): Form<ProtectedForm<FormData>>,
) -> Result<Response, RouteError> {
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);

View File

@@ -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<OptionalPostAuthAction>,
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<UserAgent>>,
user_agent: Option<TypedHeader<headers::UserAgent>>,
Form(form): Form<ProtectedForm<LoginForm>>,
) -> Result<Response, FancyError> {
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<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, FormError> {
// XXX: we're loosing the error context here
// First, lookup the user

View File

@@ -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<OptionalPostAuthAction>,
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<UserAgent>>,
user_agent: Option<TypedHeader<headers::UserAgent>>,
Form(form): Form<ProtectedForm<RegisterForm>>,
) -> Result<Response, FancyError> {
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());
}

View File

@@ -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<AppSessionLookup> for AppSession {
type Error = DatabaseError;
#[allow(clippy::too_many_lines)]
fn try_from(value: AppSessionLookup) -> Result<Self, Self::Error> {
// 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<AppSessionLookup> 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 (

View File

@@ -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"));

View File

@@ -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<CompatSessionLookup> 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<CompatSessionAndSsoLoginLookup> for (CompatSession, Option<CompatSs
user_session_id: value.user_session_id.map(Ulid::from),
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,
};
@@ -575,7 +575,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
async fn record_user_agent(
&mut self,
mut compat_session: CompatSession,
user_agent: String,
user_agent: UserAgent,
) -> Result<CompatSession, Self::Error> {
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)

View File

@@ -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<OAuth2DeviceGrantLookup> for DeviceCodeGrant {
created_at,
expires_at,
ip_address,
user_agent,
user_agent: user_agent.map(UserAgent::parse),
})
}
}

View File

@@ -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"));

View File

@@ -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<OAuthSessionLookup> 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<Session, Self::Error> {
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)

View File

@@ -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<SessionLookup> 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<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, Self::Error> {
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)

View File

@@ -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<CompatSession, Self::Error>;
}
@@ -305,6 +305,6 @@ repository_impl!(CompatSessionRepository:
async fn record_user_agent(
&mut self,
compat_session: CompatSession,
user_agent: String,
user_agent: UserAgent,
) -> Result<CompatSession, Self::Error>;
);

View File

@@ -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<IpAddr>,
/// The user agent from which the request was made
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
}
/// An [`OAuth2DeviceCodeGrantRepository`] helps interacting with

View File

@@ -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<Session, Self::Error>;
}
@@ -349,6 +349,6 @@ repository_impl!(OAuth2SessionRepository:
async fn record_user_agent(
&mut self,
session: Session,
user_agent: String,
user_agent: UserAgent,
) -> Result<Session, Self::Error>;
);

View File

@@ -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<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, Self::Error>;
/// Finish a [`BrowserSession`]
@@ -254,7 +254,7 @@ repository_impl!(BrowserSessionRepository:
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user: &User,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, Self::Error>;
async fn finish(
&mut self,

View File

@@ -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 }
})