mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 00:03:24 +00:00
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:
1 parent
0e4640d02a
commit
b1c0f03f3c
4 files changed
+136
-36
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
Reference in new issue
Block a user