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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
This commit is contained in:
Devon HudsonandClaude Opus 5.5 committed 2026-10-16 18:45:32 +00:00
1 parent 0e4640d02a
commit b1c0f03f3c
4 files changed
+136 -36

No files matched your search

+5 -3
View File
@@ -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,
+42 -13
View File
@@ -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<String> {
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<String> {
let (prefix, suffix) = split_username_template(username)?;
let (letters, digits): (Vec<u8>, Vec<u8>) = 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);
}
}
+84 -15
View File
@@ -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<Response>, _>(|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}");
+5 -5
View File
@@ -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": [