mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-12 09:19:04 +00:00
Enforce a registration token's pins when attaching it and when finishing
`POST /register` checks that a registration matches the username and email address its token pins, but the token step could attach any valid token to a registration afterwards. Registering as anyone, then attaching a pinned token, registered a username the token doesn't pin. Swapping a passwordless token for one that isn't left a registration with no password at all. The token step now refuses a token that doesn't fit the registration: one that pins another username or another email address than the registration's, or one that isn't passwordless for a registration without a password or an upstream link. Finishing a registration checks the same against the verified email address, and refuses a passwordless registration without one. Only a tampered registration gets that far, so these fail like the other last-minute checks there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d93009a319
commit
e6d64744bb
@@ -435,6 +435,173 @@ mod tests {
|
||||
csrf_token.form_value().clone()
|
||||
}
|
||||
|
||||
pub(super) async fn add_registration_token(
|
||||
state: &TestState,
|
||||
token: &str,
|
||||
username: Option<&str>,
|
||||
email: Option<&str>,
|
||||
passwordless: bool,
|
||||
) -> mas_data_model::UserRegistrationToken {
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let token = repo
|
||||
.user_registration_token()
|
||||
.add(
|
||||
&mut state.rng(),
|
||||
&state.clock,
|
||||
token.to_owned(),
|
||||
None,
|
||||
None,
|
||||
username.map(ToOwned::to_owned),
|
||||
email.map(ToOwned::to_owned),
|
||||
passwordless,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.save().await.unwrap();
|
||||
token
|
||||
}
|
||||
|
||||
/// Start a registration in this browser, as `POST /register` and the
|
||||
/// email verification step leave it
|
||||
pub(super) async fn add_registration(
|
||||
state: &TestState,
|
||||
cookies: &CookieHelper,
|
||||
username: &str,
|
||||
verified_email: Option<&str>,
|
||||
with_password: bool,
|
||||
token: Option<&mas_data_model::UserRegistrationToken>,
|
||||
) -> mas_data_model::UserRegistration {
|
||||
let mut rng = state.rng();
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
|
||||
let mut registration = repo
|
||||
.user_registration()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
username.to_owned(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Some(email) = verified_email {
|
||||
let authentication = repo
|
||||
.user_email()
|
||||
.add_authentication_for_registration(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
email.to_owned(),
|
||||
®istration,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let code = repo
|
||||
.user_email()
|
||||
.add_authentication_code(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
chrono::Duration::minutes(5),
|
||||
&authentication,
|
||||
"123456".to_owned(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let authentication = repo
|
||||
.user_email()
|
||||
.complete_authentication_with_code(&state.clock, authentication, &code)
|
||||
.await
|
||||
.unwrap();
|
||||
registration = repo
|
||||
.user_registration()
|
||||
.set_email_authentication(registration, &authentication)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if with_password {
|
||||
registration = repo
|
||||
.user_registration()
|
||||
.set_password(registration, "hashed".to_owned(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if let Some(token) = token {
|
||||
registration = repo
|
||||
.user_registration()
|
||||
.set_registration_token(registration, token)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
cookies.import(
|
||||
super::UserRegistrationSessionsCookie::default()
|
||||
.add(®istration)
|
||||
.save(state.cookie_jar(), &state.clock),
|
||||
);
|
||||
|
||||
registration
|
||||
}
|
||||
|
||||
/// Link a registration to an upstream identity, as registering through an
|
||||
/// upstream provider does
|
||||
pub(super) async fn link_upstream(
|
||||
state: &TestState,
|
||||
registration: mas_data_model::UserRegistration,
|
||||
) -> mas_data_model::UserRegistration {
|
||||
let provider_id = provider(state).await;
|
||||
let mut rng = state.rng();
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
|
||||
let provider = repo
|
||||
.upstream_oauth_provider()
|
||||
.lookup(provider_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let session = repo
|
||||
.upstream_oauth_session()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&provider,
|
||||
registration.username.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let link = repo
|
||||
.upstream_oauth_link()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&provider,
|
||||
registration.username.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session = repo
|
||||
.upstream_oauth_session()
|
||||
.complete_with_link(&state.clock, session, &link, None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let registration = repo
|
||||
.user_registration()
|
||||
.set_upstream_oauth_authorization_session(registration, &session)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
registration
|
||||
}
|
||||
|
||||
/// Decode the upstream sessions cookie set by the given response, if any
|
||||
fn upstream_sessions(
|
||||
state: &TestState,
|
||||
|
||||
@@ -423,7 +423,7 @@ mod tests {
|
||||
use sqlx::PgPool;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::super::tests::{csrf_token, mint_csrf_token};
|
||||
use super::super::tests::{add_registration_token, csrf_token, mint_csrf_token};
|
||||
use crate::{
|
||||
SiteConfig,
|
||||
test_utils::{
|
||||
@@ -1136,6 +1136,44 @@ mod tests {
|
||||
assert!(!repo.user().exists("mallory").await.unwrap());
|
||||
}
|
||||
|
||||
/// An invite code which pins an email address can't be used to register
|
||||
/// another
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_register_with_token_email_mismatch(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
add_registration_token(
|
||||
&state,
|
||||
"invite_alice",
|
||||
None,
|
||||
Some("alice@example.com"),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let csrf_token = mint_csrf_token(&state, &cookies);
|
||||
let request = Request::post(&*mas_router::Register::default().path_and_query()).form(
|
||||
serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"username": "mallory",
|
||||
"email": "mallory@example.com",
|
||||
"password": "correcthorsebatterystaple",
|
||||
"password_confirm": "correcthorsebatterystaple",
|
||||
"token": "invite_alice",
|
||||
"accept_terms": "on",
|
||||
}),
|
||||
);
|
||||
let request = cookies.with_cookies(request);
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
assert_eq!(
|
||||
form_state(response.body())["fields"]["email"]["errors"],
|
||||
serde_json::json!([{"kind": "invalid"}])
|
||||
);
|
||||
}
|
||||
|
||||
/// A passwordless invite code is a way to register on its own: it works
|
||||
/// with password registration off, and with passwords disabled entirely
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
|
||||
@@ -225,6 +225,39 @@ pub(crate) async fn get(
|
||||
None
|
||||
};
|
||||
|
||||
if registration_token
|
||||
.as_ref()
|
||||
.is_some_and(|registration_token| {
|
||||
!super::token_fits(
|
||||
registration_token,
|
||||
®istration,
|
||||
email_authentication
|
||||
.as_ref()
|
||||
.map(|authentication| authentication.email.as_str()),
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"Registration token doesn't fit the registration"
|
||||
)));
|
||||
}
|
||||
|
||||
// Without a password or an upstream link, a passwordless token and a
|
||||
// verified email address are what the user registers with
|
||||
if registration.password.is_none()
|
||||
&& registration
|
||||
.upstream_oauth_authorization_session_id
|
||||
.is_none()
|
||||
&& !(registration_token
|
||||
.as_ref()
|
||||
.is_some_and(|registration_token| registration_token.passwordless)
|
||||
&& email_authentication.is_some())
|
||||
{
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"Registration has no password, no upstream link, and no passwordless registration token with a verified email address"
|
||||
)));
|
||||
}
|
||||
|
||||
// If this registration was created from an upstream OAuth session, check
|
||||
// it is still valid and wasn't linked to a user in the meantime
|
||||
let upstream_oauth = if let Some(upstream_oauth_authorization_session_id) =
|
||||
@@ -391,3 +424,206 @@ pub(crate) async fn get(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hyper::{Request, Response, StatusCode, header::LOCATION};
|
||||
use mas_data_model::UserRegistration;
|
||||
use mas_router::Route as _;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup},
|
||||
views::register::tests::{add_registration, add_registration_token, link_upstream},
|
||||
};
|
||||
|
||||
async fn finish(
|
||||
state: &TestState,
|
||||
cookies: &CookieHelper,
|
||||
registration: &UserRegistration,
|
||||
) -> Response<String> {
|
||||
let request =
|
||||
Request::get(&*mas_router::RegisterFinish::new(registration.id).path_and_query())
|
||||
.empty();
|
||||
state.request(cookies.with_cookies(request)).await
|
||||
}
|
||||
|
||||
/// Assert that finish refused the registration with an error naming
|
||||
/// `reason`
|
||||
fn assert_refused(response: &Response<String>, reason: &str) {
|
||||
response.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(
|
||||
response.body().contains(reason),
|
||||
"response body: {}",
|
||||
response.body()
|
||||
);
|
||||
}
|
||||
|
||||
/// A registration matching the pins of its passwordless token goes on to
|
||||
/// the next step
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_finish_with_pinned_token(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let token = add_registration_token(
|
||||
&state,
|
||||
"invite_alice",
|
||||
Some("alice"),
|
||||
Some("alice@example.com"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"alice",
|
||||
Some("alice@example.com"),
|
||||
false,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
response.assert_header_value(
|
||||
LOCATION,
|
||||
&mas_router::RegisterDisplayName::new(registration.id).path_and_query(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A registration linked upstream needs neither a password nor a
|
||||
/// passwordless token
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_finish_upstream_without_password(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let token = add_registration_token(&state, "plain", None, None, false).await;
|
||||
for (username, token) in [("alice", None), ("bob", Some(&token))] {
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
username,
|
||||
Some(&format!("{username}@example.com")),
|
||||
false,
|
||||
token,
|
||||
)
|
||||
.await;
|
||||
let registration = link_upstream(&state, registration).await;
|
||||
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
response.assert_header_value(
|
||||
LOCATION,
|
||||
&mas_router::RegisterDisplayName::new(registration.id).path_and_query(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_finish_refuses_token_pinning_another_username(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let token =
|
||||
add_registration_token(&state, "invite_alice", Some("alice"), None, false).await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"mallory",
|
||||
Some("mallory@example.com"),
|
||||
true,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
assert_refused(&response, "fit the registration");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_finish_refuses_token_pinning_another_email(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let token = add_registration_token(
|
||||
&state,
|
||||
"invite_alice",
|
||||
Some("alice"),
|
||||
Some("alice@example.com"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"alice",
|
||||
Some("mallory@example.com"),
|
||||
false,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
assert_refused(&response, "fit the registration");
|
||||
|
||||
// A registration without an email address doesn't match a pinned one
|
||||
let token =
|
||||
add_registration_token(&state, "pins_email", None, Some("bob@example.com"), false)
|
||||
.await;
|
||||
let registration =
|
||||
add_registration(&state, &cookies, "bob", None, true, Some(&token)).await;
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
assert_refused(&response, "fit the registration");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_finish_refuses_no_password_without_passwordless_token(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"bob",
|
||||
Some("bob@example.com"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
assert_refused(&response, "no passwordless registration token");
|
||||
|
||||
let token = add_registration_token(&state, "plain", None, None, false).await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"carol",
|
||||
Some("carol@example.com"),
|
||||
false,
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
assert_refused(&response, "fit the registration");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_finish_refuses_passwordless_token_without_verified_email(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let token = add_registration_token(&state, "passwordless", None, None, true).await;
|
||||
let registration =
|
||||
add_registration(&state, &cookies, "dave", None, false, Some(&token)).await;
|
||||
|
||||
let response = finish(&state, &cookies, ®istration).await;
|
||||
assert_refused(&response, "no passwordless registration token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
// Copyright 2025, 2026 Element Creations Ltd.
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use mas_data_model::{UserRegistration, UserRegistrationToken};
|
||||
|
||||
pub(crate) mod display_name;
|
||||
pub(crate) mod finish;
|
||||
pub(crate) mod registration_token;
|
||||
pub(crate) mod verify_email;
|
||||
|
||||
/// Whether `token` can be used for `registration`, whose email address is
|
||||
/// `email`: it pins no other username or email address, and it is passwordless
|
||||
/// if the registration has neither a password nor an upstream link
|
||||
fn token_fits(
|
||||
token: &UserRegistrationToken,
|
||||
registration: &UserRegistration,
|
||||
email: Option<&str>,
|
||||
) -> bool {
|
||||
token
|
||||
.username
|
||||
.as_ref()
|
||||
.is_none_or(|username| *username == registration.username)
|
||||
&& token
|
||||
.email
|
||||
.as_deref()
|
||||
.is_none_or(|pinned| Some(pinned) == email)
|
||||
&& (token.passwordless
|
||||
|| registration.password.is_some()
|
||||
|| registration
|
||||
.upstream_oauth_authorization_session_id
|
||||
.is_some())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright 2025, 2026 Element Creations Ltd.
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -188,6 +189,35 @@ pub(crate) async fn post(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let email = match registration.email_authentication_id {
|
||||
Some(email_authentication_id) => Some(
|
||||
repo.user_email()
|
||||
.lookup_authentication(email_authentication_id)
|
||||
.await?
|
||||
.context("Could not load the email authentication")
|
||||
.map_err(InternalError::from_anyhow)?
|
||||
.email,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if !super::token_fits(®istration_token, ®istration, email.as_deref()) {
|
||||
tracing::warn!("Registration token doesn't fit the registration");
|
||||
let ctx = RegisterStepsRegistrationTokenContext::new()
|
||||
.with_form_state(form.to_form_state().with_error_on_field(
|
||||
RegisterStepsRegistrationTokenFormField::Token,
|
||||
FieldError::Mismatch,
|
||||
))
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
Html(templates.render_register_steps_registration_token(&ctx)?),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Associate the token with the registration
|
||||
let registration = repo
|
||||
.user_registration()
|
||||
@@ -200,3 +230,200 @@ pub(crate) async fn post(
|
||||
let destination = mas_router::RegisterFinish::new(registration.id);
|
||||
Ok((cookie_jar, url_builder.redirect(&destination)).into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hyper::{Request, Response, StatusCode, header::LOCATION};
|
||||
use mas_data_model::UserRegistration;
|
||||
use mas_router::Route as _;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup},
|
||||
views::register::tests::{
|
||||
add_registration, add_registration_token, link_upstream, mint_csrf_token,
|
||||
},
|
||||
};
|
||||
|
||||
async fn submit_token(
|
||||
state: &TestState,
|
||||
cookies: &CookieHelper,
|
||||
registration: &UserRegistration,
|
||||
token: &str,
|
||||
) -> Response<String> {
|
||||
let csrf_token = mint_csrf_token(state, cookies);
|
||||
let request =
|
||||
Request::post(&*mas_router::RegisterToken::new(registration.id).path_and_query())
|
||||
.form(serde_json::json!({ "csrf": csrf_token, "token": token }));
|
||||
state.request(cookies.with_cookies(request)).await
|
||||
}
|
||||
|
||||
/// Assert that the token step refused the token as not fitting the
|
||||
/// registration, and attached nothing
|
||||
async fn assert_refused(
|
||||
state: &TestState,
|
||||
response: &Response<String>,
|
||||
registration: &UserRegistration,
|
||||
) {
|
||||
response.assert_status(StatusCode::OK);
|
||||
assert!(
|
||||
response.body().contains(r#"data-error-kind="mismatch""#),
|
||||
"response body: {}",
|
||||
response.body()
|
||||
);
|
||||
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let registration = repo
|
||||
.user_registration()
|
||||
.lookup(registration.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(registration.user_registration_token_id, None);
|
||||
}
|
||||
|
||||
/// The token step comes before the email address is verified
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_accepts_token_pinning_an_unverified_email(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
add_registration_token(
|
||||
&state,
|
||||
"invite_alice",
|
||||
Some("alice"),
|
||||
Some("alice@example.com"),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let registration = add_registration(&state, &cookies, "alice", None, true, None).await;
|
||||
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let authentication = repo
|
||||
.user_email()
|
||||
.add_authentication_for_registration(
|
||||
&mut state.rng(),
|
||||
&state.clock,
|
||||
"alice@example.com".to_owned(),
|
||||
®istration,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let registration = repo
|
||||
.user_registration()
|
||||
.set_email_authentication(registration, &authentication)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let response = submit_token(&state, &cookies, ®istration, "invite_alice").await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
response.assert_header_value(
|
||||
LOCATION,
|
||||
&mas_router::RegisterFinish::new(registration.id).path_and_query(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A registration linked upstream needs no password, so any token fits it
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_accepts_token_not_passwordless_upstream(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
add_registration_token(&state, "plain", None, None, false).await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"alice",
|
||||
Some("alice@example.com"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let registration = link_upstream(&state, registration).await;
|
||||
|
||||
let response = submit_token(&state, &cookies, ®istration, "plain").await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
response.assert_header_value(
|
||||
LOCATION,
|
||||
&mas_router::RegisterFinish::new(registration.id).path_and_query(),
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_refuses_token_pinning_another_username(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
add_registration_token(&state, "invite_alice", Some("alice"), None, false).await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"mallory",
|
||||
Some("mallory@example.com"),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = submit_token(&state, &cookies, ®istration, "invite_alice").await;
|
||||
assert_refused(&state, &response, ®istration).await;
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_refuses_token_pinning_another_email(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
add_registration_token(
|
||||
&state,
|
||||
"invite_alice",
|
||||
None,
|
||||
Some("alice@example.com"),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"mallory",
|
||||
Some("mallory@example.com"),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let response = submit_token(&state, &cookies, ®istration, "invite_alice").await;
|
||||
assert_refused(&state, &response, ®istration).await;
|
||||
|
||||
// Nor does it fit a registration without an email address
|
||||
let registration = add_registration(&state, &cookies, "bob", None, true, None).await;
|
||||
let response = submit_token(&state, &cookies, ®istration, "invite_alice").await;
|
||||
assert_refused(&state, &response, ®istration).await;
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_refuses_token_not_passwordless_without_password(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
add_registration_token(&state, "plain", None, None, false).await;
|
||||
let registration = add_registration(
|
||||
&state,
|
||||
&cookies,
|
||||
"alice",
|
||||
Some("alice@example.com"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = submit_token(&state, &cookies, ®istration, "plain").await;
|
||||
assert_refused(&state, &response, ®istration).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright 2025, 2026 Element Creations Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
@@ -34,6 +35,9 @@ pub enum FieldError {
|
||||
/// That value already exists
|
||||
Exists,
|
||||
|
||||
/// The value doesn't fit what the rest of the flow already holds
|
||||
Mismatch,
|
||||
|
||||
/// Denied by the policy
|
||||
Policy {
|
||||
/// Well-known policy code
|
||||
|
||||
@@ -28,6 +28,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
{{ _("mas.errors.field_required") }}
|
||||
{% elif error.kind == "exists" and field_name == "username" %}
|
||||
{{ _("mas.errors.username_taken") }}
|
||||
{% elif error.kind == "mismatch" and field_name == "token" %}
|
||||
{{ _("mas.errors.registration_token_mismatch") }}
|
||||
{% elif error.kind == "policy" %}
|
||||
{% if error.code == "username-too-short" %}
|
||||
{{ _("mas.errors.username_too_short") }}
|
||||
|
||||
+18
-14
@@ -369,27 +369,27 @@
|
||||
},
|
||||
"denied_policy": "Denied by policy: %(policy)s",
|
||||
"@denied_policy": {
|
||||
"context": "components/errors.html:17:7-58, components/field.html:53:11-62"
|
||||
"context": "components/errors.html:17:7-58, components/field.html:55:11-62"
|
||||
},
|
||||
"email_banned": "Email is banned by the server policy",
|
||||
"@email_banned": {
|
||||
"context": "components/field.html:51:11-39"
|
||||
"context": "components/field.html:53:11-39"
|
||||
},
|
||||
"email_domain_banned": "Email domain is banned by the server policy",
|
||||
"@email_domain_banned": {
|
||||
"context": "components/field.html:47:11-46"
|
||||
"context": "components/field.html:49:11-46"
|
||||
},
|
||||
"email_domain_not_allowed": "Email domain is not allowed by the server policy",
|
||||
"@email_domain_not_allowed": {
|
||||
"context": "components/field.html:45:11-51"
|
||||
"context": "components/field.html:47:11-51"
|
||||
},
|
||||
"email_not_allowed": "Email is not allowed by the server policy",
|
||||
"@email_not_allowed": {
|
||||
"context": "components/field.html:49:11-44"
|
||||
"context": "components/field.html:51:11-44"
|
||||
},
|
||||
"field_invalid": "This field is invalid",
|
||||
"@field_invalid": {
|
||||
"context": "components/field.html:58:9-38"
|
||||
"context": "components/field.html:60:9-38"
|
||||
},
|
||||
"field_required": "This field is required",
|
||||
"@field_required": {
|
||||
@@ -401,28 +401,32 @@
|
||||
},
|
||||
"password_mismatch": "Password fields don't match",
|
||||
"@password_mismatch": {
|
||||
"context": "components/errors.html:13:7-40, components/field.html:56:9-42"
|
||||
"context": "components/errors.html:13:7-40, components/field.html:58:9-42"
|
||||
},
|
||||
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.",
|
||||
"@rate_limit_exceeded": {
|
||||
"context": "components/errors.html:15:7-42, pages/recovery/progress.html:26:11-46"
|
||||
},
|
||||
"registration_token_mismatch": "This token can't be used to create this account",
|
||||
"@registration_token_mismatch": {
|
||||
"context": "components/field.html:32:9-52"
|
||||
},
|
||||
"username_all_numeric": "Username cannot consist solely of numbers",
|
||||
"@username_all_numeric": {
|
||||
"context": "components/field.html:39:11-47"
|
||||
"context": "components/field.html:41:11-47"
|
||||
},
|
||||
"username_banned": "Username is banned by the server policy",
|
||||
"@username_banned": {
|
||||
"context": "components/field.html:41:11-42",
|
||||
"context": "components/field.html:43:11-42",
|
||||
"description": "Error message shown on registration, when the username matches a pattern that is banned by the server policy."
|
||||
},
|
||||
"username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.",
|
||||
"@username_invalid_chars": {
|
||||
"context": "components/field.html:37:11-49"
|
||||
"context": "components/field.html:39:11-49"
|
||||
},
|
||||
"username_not_allowed": "Username is not allowed by the server policy",
|
||||
"@username_not_allowed": {
|
||||
"context": "components/field.html:43:11-47",
|
||||
"context": "components/field.html:45:11-47",
|
||||
"description": "Error message shown on registration, when the username *does not match* any of the patterns that are allowed by the server policy."
|
||||
},
|
||||
"username_taken": "This username is already taken",
|
||||
@@ -431,11 +435,11 @@
|
||||
},
|
||||
"username_too_long": "Username is too long",
|
||||
"@username_too_long": {
|
||||
"context": "components/field.html:35:11-44"
|
||||
"context": "components/field.html:37:11-44"
|
||||
},
|
||||
"username_too_short": "Username is too short",
|
||||
"@username_too_short": {
|
||||
"context": "components/field.html:33:11-45"
|
||||
"context": "components/field.html:35:11-45"
|
||||
}
|
||||
},
|
||||
"legacy_consent": {
|
||||
@@ -525,7 +529,7 @@
|
||||
},
|
||||
"or_separator": "Or",
|
||||
"@or_separator": {
|
||||
"context": "components/field.html:116:10-31",
|
||||
"context": "components/field.html:118:10-31",
|
||||
"description": "Separator between the login methods"
|
||||
},
|
||||
"policy_violation": {
|
||||
|
||||
Reference in New Issue
Block a user