fix(auth): prevent token collisions and optimise lookups

Ensures access tokens are unique across both user and appservice tables to
prevent authentication ambiguity and potential security issues.

Changes:
- On startup, automatically logout any user devices using tokens that
  conflict with appservice tokens (resolves in favour of appservices)
  and log a warning with affected user/device details
- When creating new user tokens, check for conflicts with appservice tokens
  and generate a new token if a collision would occur
- When registering new appservices, reject registration if the token is
  already in use by a user device
- Use futures::select_ok to race token lookups concurrently for better
  performance (adapted from tuwunel commit 066097a8)

This fix-forward approach resolves existing token collisions on startup
whilst preventing new ones from being created, without breaking existing
valid authentications.

The find_token optimisation is adapted from tuwunel (matrix-construct/tuwunel)
commit 066097a8: "Optimize user and appservice token queries" by Jason Volk.
This commit is contained in:
Tom Foster 2025-08-10 14:38:54 +01:00
commit d1ebcfaf0b
3 changed files with 59 additions and 17 deletions

View file

@ -133,10 +133,10 @@ impl Service {
.await
.is_ok()
{
return err!(Request(InvalidParam(
return Err(err!(Request(InvalidParam(
"Cannot register appservice: The provided token is already in use by a user \
device. Please generate a different token for the appservice."
)));
))));
}
self.db
@ -182,12 +182,13 @@ impl Service {
.map(|info| info.registration)
}
pub async fn find_from_token(&self, token: &str) -> Option<RegistrationInfo> {
pub async fn find_from_token(&self, token: &str) -> Result<RegistrationInfo> {
self.read()
.await
.values()
.find(|info| info.registration.as_token == token)
.cloned()
.ok_or_else(|| err!(Request(NotFound("Appservice token not found"))))
}
/// Checks if a given user id matches any exclusive appservice regex

View file

@ -19,7 +19,7 @@ use ruma::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{Dep, account_data, admin, globals, rooms};
use crate::{Dep, account_data, admin, appservice, globals, rooms};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSuspension {
@ -40,6 +40,7 @@ struct Services {
server: Arc<Server>,
account_data: Dep<account_data::Service>,
admin: Dep<admin::Service>,
appservice: Dep<appservice::Service>,
globals: Dep<globals::Service>,
state_accessor: Dep<rooms::state_accessor::Service>,
state_cache: Dep<rooms::state_cache::Service>,
@ -76,6 +77,7 @@ impl crate::Service for Service {
server: args.server.clone(),
account_data: args.depend::<account_data::Service>("account_data"),
admin: args.depend::<admin::Service>("admin"),
appservice: args.depend::<appservice::Service>("appservice"),
globals: args.depend::<globals::Service>("globals"),
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
@ -407,6 +409,26 @@ impl Service {
)));
}
// Prevent token collisions with appservice tokens
let final_token = if self
.services
.appservice
.find_from_token(token)
.await
.is_ok()
{
let new_token = utils::random_string(32);
conduwuit::debug_warn!(
"Token collision prevented: Generated new token for user '{}' device '{}' \
(original token conflicted with an appservice)",
user_id.localpart(),
device_id
);
new_token
} else {
token.to_owned()
};
// Remove old token
if let Ok(old_token) = self.db.userdeviceid_token.qry(&key).await {
self.db.token_userdeviceid.remove(&old_token);
@ -414,8 +436,8 @@ impl Service {
}
// Assign token to user device combination
self.db.userdeviceid_token.put_raw(key, token);
self.db.token_userdeviceid.raw_put(token, key);
self.db.userdeviceid_token.put_raw(key, &final_token);
self.db.token_userdeviceid.raw_put(&final_token, key);
Ok(())
}