From b1c0f03f3ccf34b75a6fa53d7cd55c2da17600e8 Mon Sep 17 00:00:00 2001 From: Devon Hudson Date: Tue, 13 Oct 2026 22:38:46 +0000 Subject: [PATCH] Accept a username template for room invites An invite's `username` may be a template with one `{random}`, which the minted token stores as is and registering fills in. The endpoint checks a template by filling `{random}` with letters and digits and running the registration policy on the result, so a template the policy refuses is a 400 when inviting rather than an invite that can't register. A template with more than one `{random}` is a 400. Every invite may carry the same template, and the name it fills in to is only known when registering, so the repeated-username and username-taken checks apply to literal usernames only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm --- crates/data-model/src/lib.rs | 8 +- crates/data-model/src/users.rs | 55 ++++++++--- crates/handlers/src/admin/v1/room_invites.rs | 99 +++++++++++++++++--- docs/api/spec.json | 10 +- 4 files changed, 136 insertions(+), 36 deletions(-) diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 9e713cb58..bb8aa2360 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -57,9 +58,10 @@ pub use self::{ }, user_agent::{DeviceType, UserAgent}, users::{ - Authentication, AuthenticationMethod, BrowserSession, MatrixUser, Password, User, - UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, - UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, + Authentication, AuthenticationMethod, BrowserSession, MatrixUser, Password, + USERNAME_TEMPLATE_PLACEHOLDER, User, UserEmail, UserEmailAuthentication, + UserEmailAuthenticationCode, UserRecoverySession, UserRecoveryTicket, UserRegistration, + UserRegistrationPassword, UserRegistrationToken, username_template_sample, }, utils::{BoxClock, BoxRng, UlidExt}, version::AppVersion, diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 8af72860d..9c3aa1372 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -285,16 +285,6 @@ impl UserRegistrationToken { self.is_valid(now) } - /// The literal parts before and after the `{random}` of the username, if - /// it is a template - fn username_template(&self) -> Option<(&str, &str)> { - let (prefix, suffix) = self - .username - .as_deref()? - .split_once(USERNAME_TEMPLATE_PLACEHOLDER)?; - (!suffix.contains(USERNAME_TEMPLATE_PLACEHOLDER)).then_some((prefix, suffix)) - } - /// Returns `true` if the token allows registering `username`: it imposes /// none, it imposes this one, or its template fills in to it #[must_use] @@ -303,7 +293,7 @@ impl UserRegistrationToken { return true; }; - match self.username_template() { + match split_username_template(imposed) { Some((prefix, suffix)) => username .strip_prefix(prefix) .and_then(|rest| rest.strip_suffix(suffix)) @@ -323,7 +313,7 @@ impl UserRegistrationToken { /// username is one #[must_use] pub fn fill_username(&self, rng: &mut (impl RngExt + ?Sized)) -> Option { - let (prefix, suffix) = self.username_template()?; + let (prefix, suffix) = split_username_template(self.username.as_deref()?)?; let random: String = (0..USERNAME_TEMPLATE_RANDOM_LENGTH) .map(|_| { @@ -336,10 +326,39 @@ impl UserRegistrationToken { } } +/// The literal parts before and after the `{random}` of `username`, if it is +/// a username template +fn split_username_template(username: &str) -> Option<(&str, &str)> { + let (prefix, suffix) = username.split_once(USERNAME_TEMPLATE_PLACEHOLDER)?; + (!suffix.contains(USERNAME_TEMPLATE_PLACEHOLDER)).then_some((prefix, suffix)) +} + +/// `username` with its `{random}` filled in with letters and digits in turn, +/// if it is a username template. A policy that refuses letters or digits +/// refuses the sample too, but one that refuses only long runs of either does +/// not. +#[must_use] +pub fn username_template_sample(username: &str) -> Option { + let (prefix, suffix) = split_username_template(username)?; + + let (letters, digits): (Vec, Vec) = USERNAME_TEMPLATE_CHARSET + .iter() + .partition(|c| c.is_ascii_lowercase()); + let random: String = letters + .iter() + .cycle() + .zip(digits.iter().cycle()) + .flat_map(|(&letter, &digit)| [letter, digit]) + .take(USERNAME_TEMPLATE_RANDOM_LENGTH) + .map(char::from) + .collect(); + Some(format!("{prefix}{random}{suffix}")) +} + /// Stands for the random part of a [`UserRegistrationToken`]'s username /// template. The registration policy refuses `{` and `}` in a localpart, so a /// pin that can register never contains it. -const USERNAME_TEMPLATE_PLACEHOLDER: &str = "{random}"; +pub const USERNAME_TEMPLATE_PLACEHOLDER: &str = "{random}"; /// 36^32 is about 2^165 names, so two filled-in templates never collide const USERNAME_TEMPLATE_RANDOM_LENGTH: usize = 32; /// The lowercase letters and digits of the localpart grammar @@ -447,5 +466,15 @@ mod tests { assert!(!token.username_fits("user-{random}-{random}")); assert!(!token.username_fits(&format!("user-{random}-{random}"))); assert_eq!(token.fill_username(&mut rng), None); + assert_eq!(username_template_sample("user-{random}-{random}"), None); + } + + #[test] + fn test_username_template_sample() { + let sample = username_template_sample("user-{random}-x").unwrap(); + assert_eq!(sample, "user-a0b1c2d3e4f5g6h7i8j9k0l1m2n3o4p5-x"); + assert!(with_username(Some("user-{random}-x")).username_fits(&sample)); + + assert_eq!(username_template_sample("alice"), None); } } diff --git a/crates/handlers/src/admin/v1/room_invites.rs b/crates/handlers/src/admin/v1/room_invites.rs index ca915bdc3..9a5cac764 100644 --- a/crates/handlers/src/admin/v1/room_invites.rs +++ b/crates/handlers/src/admin/v1/room_invites.rs @@ -11,7 +11,7 @@ use chrono::Duration; use hyper::StatusCode; use lettre::Address; use mas_axum_utils::record_error; -use mas_data_model::{BoxRng, SiteConfig}; +use mas_data_model::{BoxRng, SiteConfig, USERNAME_TEMPLATE_PLACEHOLDER, username_template_sample}; use mas_policy::Policy; use mas_storage::queue::{QueueJobRepositoryExt as _, SendRoomInviteEmailJob}; use rand::distr::{Alphanumeric, SampleString}; @@ -54,6 +54,9 @@ pub enum RouteError { source: lettre::address::AddressError, }, + #[error("Username {0:?} has more than one {{random}}")] + InvalidUsernameTemplate(String), + #[error("Username {0:?} is given twice")] DuplicateUsername(String), @@ -86,6 +89,7 @@ impl IntoResponse for RouteError { Self::InvalidRoomId(_) | Self::NoInvites | Self::EmailNotValid { .. } + | Self::InvalidUsernameTemplate(_) | Self::DuplicateUsername(_) | Self::NotAllowed { .. } => StatusCode::BAD_REQUEST, Self::UsernameTaken(_) => StatusCode::CONFLICT, @@ -104,8 +108,10 @@ pub struct Invite { #[schemars(email, example = &"alice@example.com")] email: String, - /// The username the invitee registers with - #[schemars(example = &"user-abcdef")] + /// The username the invitee registers with, or a template for it: a value + /// with one `{random}`, which registering fills in with random lowercase + /// letters and digits + #[schemars(example = &"user-{random}")] username: String, } @@ -151,9 +157,9 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { .summary("Invite a list of email addresses to a room") .description( "Mints a single-use, passwordless registration token for each invite, pinned to its \ - address and username, and emails the address a link to register with the token and \ - open the room. The links are only ever sent to the addresses, so they are not in \ - the response.", + address and its username, or username template, and emails the address a link to \ + register with the token and open the room. The links are only ever sent to the \ + addresses, so they are not in the response.", ) .tag("room-invite") .response_with::<202, Json, _>(|t| { @@ -163,12 +169,12 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { .response_with::<400, RouteError, _>(|t| { let response = ErrorResponse::from_error(&RouteError::NotAllowed { email: "alice@example.com".to_owned(), - username: "user-abcdef".to_owned(), + username: "user-{random}".to_owned(), violations: vec!["email: email domain is banned".to_owned()], }); t.description( - "The request has an invalid room ID, email or username, no invites, a repeated \ - username, or an invite the registration policy refuses", + "The request has an invalid room ID, email, username or username template, no \ + invites, a repeated username, or an invite the registration policy refuses", ) .example(response) }) @@ -237,7 +243,19 @@ pub async fn handler( continue; } - if !usernames.insert(invite.username.clone()) { + // A template is checked by a sample of what it fills in to. Every invite + // may carry the same one, and the name it fills in to is only known when + // registering, which checks it again. + let template_sample = if invite.username.contains(USERNAME_TEMPLATE_PLACEHOLDER) { + let Some(sample) = username_template_sample(&invite.username) else { + return Err(RouteError::InvalidUsernameTemplate(invite.username)); + }; + Some(sample) + } else { + None + }; + + if template_sample.is_none() && !usernames.insert(invite.username.clone()) { return Err(RouteError::DuplicateUsername(invite.username)); } @@ -246,7 +264,7 @@ pub async fn handler( let result = policy .evaluate_register(mas_policy::RegisterInput { registration_method: mas_policy::RegistrationMethod::Password, - username: &invite.username, + username: template_sample.as_deref().unwrap_or(&invite.username), email: Some(&invite.email), // The request comes from the inviter's homeserver, not the invitee requester: mas_policy::Requester::default(), @@ -269,9 +287,9 @@ pub async fn handler( }); } - // A token pins the username, so the recipient of a token pinned to a - // name someone else already has can never finish registering - if repo.user().exists(&invite.username).await? { + // The recipient of a token pinned to a name someone else already has + // can never finish registering + if template_sample.is_none() && repo.user().exists(&invite.username).await? { return Err(RouteError::UsernameTaken(invite.username)); } @@ -522,6 +540,40 @@ mod tests { assert_eq!(tokens[0].email.as_deref(), Some("Alice@Example.com")); } + /// Every invite may carry the same username template, which the minted + /// tokens store as is. A template is never taken. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_username_template(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut state.rng(), &state.clock, "user-{random}".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let (status, _) = invite( + &mut state, + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [ + { "email": "alice@example.com", "username": "user-{random}" }, + { "email": "bob@example.com", "username": "user-{random}" }, + ], + }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED); + + let tokens = tokens(&state).await; + assert_eq!(tokens.len(), 2); + for token in &tokens { + assert_eq!(token.username.as_deref(), Some("user-{random}")); + } + } + /// A token pinned to a username nobody can register with is refused, rather /// than emailed out as a dead link #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] @@ -565,7 +617,9 @@ mod tests { .json(serde_json::json!({ "data": { "banned_domains": ["banned.example"], - "registration": { "banned_usernames": { "literals": ["user-banned"] } }, + "registration": { + "banned_usernames": { "literals": ["user-banned"], "prefixes": ["banned-"] }, + }, }, })); state @@ -586,6 +640,14 @@ mod tests { serde_json::json!({ "email": "bob@banned.example", "username": "user-banned" }), r#"Invite for "bob@banned.example" with username "user-banned" is not allowed: email: email domain is banned; username: username is banned"#, ), + ( + serde_json::json!({ "email": "bob@example.com", "username": "banned-{random}" }), + r#"Invite for "bob@example.com" with username "banned-{random}" is not allowed: username: username is banned"#, + ), + ( + serde_json::json!({ "email": "bob@example.com", "username": "User-{random}" }), + r#"Invite for "bob@example.com" with username "User-{random}" is not allowed: username: username contains invalid characters"#, + ), ] { let (status, title) = invite_one(&mut state, invite).await; assert_eq!(status, StatusCode::BAD_REQUEST); @@ -648,6 +710,13 @@ mod tests { }), r#"Username "user-a" is given twice"#, ), + ( + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [{ "email": "alice@example.com", "username": "user-{random}-{random}" }], + }), + r#"Username "user-{random}-{random}" has more than one {random}"#, + ), ] { let (status, title) = invite(&mut state, body).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{error}"); diff --git a/docs/api/spec.json b/docs/api/spec.json index 0bfae35d6..323ca49d0 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2011,7 +2011,7 @@ "room-invite" ], "summary": "Invite a list of email addresses to a room", - "description": "Mints a single-use, passwordless registration token for each invite, pinned to its address and username, and emails the address a link to register with the token and open the room. The links are only ever sent to the addresses, so they are not in the response.", + "description": "Mints a single-use, passwordless registration token for each invite, pinned to its address and its username, or username template, and emails the address a link to register with the token and open the room. The links are only ever sent to the addresses, so they are not in the response.", "operationId": "createRoomInvites", "requestBody": { "content": { @@ -2038,7 +2038,7 @@ } }, "400": { - "description": "The request has an invalid room ID, email or username, no invites, a repeated username, or an invite the registration policy refuses", + "description": "The request has an invalid room ID, email, username or username template, no invites, a repeated username, or an invite the registration policy refuses", "content": { "application/json": { "schema": { @@ -2047,7 +2047,7 @@ "example": { "errors": [ { - "title": "Invite for \"alice@example.com\" with username \"user-abcdef\" is not allowed: email: email domain is banned" + "title": "Invite for \"alice@example.com\" with username \"user-{random}\" is not allowed: email: email domain is banned" } ] } @@ -6637,9 +6637,9 @@ "example": "alice@example.com" }, "username": { - "description": "The username the invitee registers with", + "description": "The username the invitee registers with, or a template for it: a value\nwith one `{random}`, which registering fills in with random lowercase\nletters and digits", "type": "string", - "example": "user-abcdef" + "example": "user-{random}" } }, "required": [