diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index b72d48111..462da7528 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -231,6 +231,7 @@ impl Options { password_manager.clone(), url_builder.clone(), limiter.clone(), + http_client.clone(), ); let state = { diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 454276150..224166b53 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -243,6 +243,20 @@ pub fn site_config_from_config( soft_limit: c.soft_limit, hard_limit: c.hard_limit, }), + postnumber_validation: account_config.postnumber_validation.as_ref().map(|c| { + mas_data_model::PostnumberValidationConfig { + endpoint: c.endpoint.clone(), + timeout: c.timeout, + on_unavailable: match c.on_unavailable { + mas_config::PostnumberValidationFailureMode::Open => { + mas_data_model::PostnumberValidationFailureMode::Open + } + mas_config::PostnumberValidationFailureMode::Closed => { + mas_data_model::PostnumberValidationFailureMode::Closed + } + }, + } + }), }) } diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index 2b6538a2b..4b657b96e 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -4,8 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::time::Duration; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use url::Url; use crate::ConfigurationSection; @@ -27,6 +31,54 @@ const fn is_default_false(value: &bool) -> bool { *value == default_false() } +fn default_postnumber_timeout() -> Duration { + Duration::from_secs(2) +} + +fn is_default_postnumber_timeout(value: &Duration) -> bool { + *value == default_postnumber_timeout() +} + +/// How MAS should behave when the postnumber resolver cannot be reached. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PostnumberValidationFailureMode { + /// Allow the registration or admin action to proceed without resolver + /// input. + Open, + /// Reject the registration or admin action when the resolver is + /// unavailable. + Closed, +} + +impl Default for PostnumberValidationFailureMode { + fn default() -> Self { + Self::Closed + } +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +/// Configuration for the external postnumber resolver integration. +pub struct PostnumberValidationConfig { + /// Resolver endpoint used to validate user-facing postnumbers. + pub endpoint: Url, + + /// Per-request timeout when calling the resolver. + #[serde_as(as = "serde_with::DurationSeconds")] + #[schemars(with = "u64")] + #[serde( + default = "default_postnumber_timeout", + skip_serializing_if = "is_default_postnumber_timeout" + )] + pub timeout: Duration, + + /// Behavior when the resolver cannot be reached or returns an unexpected + /// error. + #[serde(default)] + pub on_unavailable: PostnumberValidationFailureMode, +} + /// Configuration section to configure features related to account management #[allow(clippy::struct_excessive_bools)] #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] @@ -88,6 +140,11 @@ pub struct AccountConfig { /// is disabled. #[serde(default = "default_false", skip_serializing_if = "is_default_false")] pub registration_token_required: bool, + + /// Optional external resolver for validating user-facing postnumbers in + /// registration flows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub postnumber_validation: Option, } impl Default for AccountConfig { @@ -102,6 +159,7 @@ impl Default for AccountConfig { account_deactivation_allowed: default_true(), login_with_email_allowed: default_false(), registration_token_required: default_false(), + postnumber_validation: None, } } } @@ -117,6 +175,7 @@ impl AccountConfig { && is_default_true(&self.account_deactivation_allowed) && is_default_false(&self.login_with_email_allowed) && is_default_false(&self.registration_token_required) + && self.postnumber_validation.is_none() } } diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index eb4ff2a44..e2ae0c1ec 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -28,7 +28,7 @@ mod templates; mod upstream_oauth2; pub use self::{ - account::AccountConfig, + account::{AccountConfig, PostnumberValidationConfig, PostnumberValidationFailureMode}, branding::BrandingConfig, captcha::{CaptchaConfig, CaptchaServiceKind}, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 05b2466b9..6854157fc 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -40,7 +40,8 @@ pub use self::{ }, policy_data::PolicyData, site_config::{ - CaptchaConfig, CaptchaService, SessionExpirationConfig, SessionLimitConfig, SiteConfig, + CaptchaConfig, CaptchaService, PostnumberValidationConfig, PostnumberValidationFailureMode, + SessionExpirationConfig, SessionLimitConfig, SiteConfig, }, tokens::{ AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index bb92dc3e4..20d4a2ba2 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::num::NonZeroU64; +use std::{num::NonZeroU64, time::Duration as StdDuration}; use chrono::Duration; use serde::Serialize; @@ -111,4 +111,27 @@ pub struct SiteConfig { /// Limits on the number of application sessions that each user can have pub session_limit: Option, + + /// Postnumber validation configuration + pub postnumber_validation: Option, +} + +/// What to do when the postnumber resolver is unreachable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostnumberValidationFailureMode { + /// Allow registration to proceed (fail-open). + Open, + /// Block registration (fail-closed). + Closed, +} + +/// Runtime configuration for the postnumber resolver service. +#[derive(Debug, Clone)] +pub struct PostnumberValidationConfig { + /// Base URL of the resolver (e.g. `http://localhost:3001`). + pub endpoint: Url, + /// Per-request timeout. + pub timeout: StdDuration, + /// Behaviour when the resolver cannot be reached. + pub on_unavailable: PostnumberValidationFailureMode, } diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index cbb23edbf..a9168b777 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -166,6 +166,7 @@ where Arc: FromRef, SiteConfig: FromRef, AppVersion: FromRef, + reqwest::Client: FromRef, { // We *always* want to explicitly set the possible responses, beacuse the // infered ones are not necessarily correct diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 98f1d10e2..769f7f73d 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -39,6 +39,7 @@ where SiteConfig: FromRef, AppVersion: FromRef, Arc: FromRef, + reqwest::Client: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, { diff --git a/crates/handlers/src/admin/v1/users/add.rs b/crates/handlers/src/admin/v1/users/add.rs index 9b3307e8d..78e3f3b2c 100644 --- a/crates/handlers/src/admin/v1/users/add.rs +++ b/crates/handlers/src/admin/v1/users/add.rs @@ -17,6 +17,7 @@ use serde::Deserialize; use tracing::warn; use crate::{ + SiteConfig, admin::{ call_context::CallContext, model::User, @@ -26,14 +27,7 @@ use crate::{ }; fn valid_username_character(c: char) -> bool { - c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '=' - || c == '_' - || c == '-' - || c == '.' - || c == '/' - || c == '+' + c.is_ascii_lowercase() || c.is_ascii_digit() } // XXX: this should be shared with the graphql handler @@ -42,12 +36,7 @@ fn username_valid(username: &str) -> bool { return false; } - // Should not start with an underscore - if username.starts_with('_') { - return false; - } - - // Should only contain valid characters + // Should only contain lowercase ASCII letters and digits if !username.chars().all(valid_username_character) { return false; } @@ -72,6 +61,12 @@ pub enum RouteError { #[error("Username is reserved by the homeserver")] UsernameReserved, + + #[error("Postnumber is reserved")] + PostnumberReserved, + + #[error("Postnumber resolver unavailable")] + PostnumberResolverUnavailable, } impl_from_error_for_route!(mas_storage::RepositoryError); @@ -81,9 +76,13 @@ impl IntoResponse for RouteError { let error = ErrorResponse::from_error(&self); let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_)); let status = match self { - Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Internal(_) | Self::Homeserver(_) | Self::PostnumberResolverUnavailable => { + StatusCode::INTERNAL_SERVER_ERROR + } Self::UsernameNotValid => StatusCode::BAD_REQUEST, - Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT, + Self::UserAlreadyExists | Self::UsernameReserved | Self::PostnumberReserved => { + StatusCode::CONFLICT + } }; (status, sentry_event_id, Json(error)).into_response() } @@ -103,6 +102,12 @@ pub struct Request { /// tokens (like with admin access) for them #[serde(default)] skip_homeserver_check: bool, + + /// Explicitly claim a postnumber that is marked as reserved by the + /// postnumber resolver. Has no effect when postnumber validation is + /// not configured. + #[serde(default)] + claim_reserved_postnumber: bool, } pub fn doc(operation: TransformOperation) -> TransformOperation { @@ -137,6 +142,8 @@ pub async fn handler( }: CallContext, NoApi(mut rng): NoApi, State(homeserver): State>, + State(site_config): State, + State(http_client): State, Json(params): Json, ) -> Result<(StatusCode, Json>), RouteError> { if repo.user().exists(¶ms.username).await? { @@ -148,6 +155,35 @@ pub async fn handler( return Err(RouteError::UsernameNotValid); } + // Postnumber validation via external resolver + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + ¶ms.username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + if !params.claim_reserved_postnumber { + return Err(RouteError::PostnumberReserved); + } + tracing::info!( + username = %params.username, + "Admin claiming reserved postnumber" + ); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during admin user creation" + ); + return Err(RouteError::PostnumberResolverUnavailable); + } + } + // Ask the homeserver if the username is available let homeserver_available = homeserver .is_localpart_available(¶ms.username) @@ -323,4 +359,104 @@ mod tests { assert_eq!(user.username, "bob"); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user_postnumber_reserved(pool: PgPool) { + setup(); + + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/check")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "admin", + "status": "RESERVED", + "match_kind": "exact", + "matched_terms": ["admin"], + "matched_categories": [], + "pattern_family": null + })), + ) + .mount(&mock_server) + .await; + + let mut site_config = crate::test_utils::test_site_config(); + site_config.postnumber_validation = Some(mas_data_model::PostnumberValidationConfig { + endpoint: url::Url::parse(&format!("{}/", mock_server.uri())).unwrap(), + timeout: std::time::Duration::from_secs(5), + on_unavailable: mas_data_model::PostnumberValidationFailureMode::Closed, + }); + + let mut state = TestState::from_pool_with_site_config(pool, site_config) + .await + .unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Reserved postnumber without claim flag → rejected + let request = Request::post("/api/admin/v1/users") + .bearer(&token) + .json(serde_json::json!({ + "username": "admin", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_eq!(body["errors"][0]["title"], "Postnumber is reserved"); + + // Reserved postnumber with claim flag → allowed + let request = Request::post("/api/admin/v1/users") + .bearer(&token) + .json(serde_json::json!({ + "username": "admin", + "claim_reserved_postnumber": true, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["attributes"]["username"], "admin"); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user_postnumber_allowed(pool: PgPool) { + setup(); + + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/check")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "alice", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + })), + ) + .mount(&mock_server) + .await; + + let mut site_config = crate::test_utils::test_site_config(); + site_config.postnumber_validation = Some(mas_data_model::PostnumberValidationConfig { + endpoint: url::Url::parse(&format!("{}/", mock_server.uri())).unwrap(), + timeout: std::time::Duration::from_secs(5), + on_unavailable: mas_data_model::PostnumberValidationFailureMode::Closed, + }); + + let mut state = TestState::from_pool_with_site_config(pool, site_config) + .await + .unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Allowed postnumber → success + let request = Request::post("/api/admin/v1/users") + .bearer(&token) + .json(serde_json::json!({ + "username": "alice", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["attributes"]["username"], "alice"); + } } diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 1b73c05c3..da300cf11 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -61,6 +61,7 @@ impl_from_ref!(mas_handlers::passwords::PasswordManager); impl_from_ref!(Arc); impl_from_ref!(mas_data_model::SiteConfig); impl_from_ref!(mas_data_model::AppVersion); +impl_from_ref!(reqwest::Client); fn main() -> Result<(), Box> { let (mut api, _) = mas_handlers::admin_api_router::(); diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 7ccf9e51b..e70f72f1d 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -76,6 +76,7 @@ struct GraphQLState { password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + http_client: reqwest::Client, } #[async_trait::async_trait] @@ -108,6 +109,10 @@ impl state::State for GraphQLState { &self.limiter } + fn http_client(&self) -> &reqwest::Client { + &self.http_client + } + fn clock(&self) -> BoxClock { let clock = SystemClock::default(); Box::new(clock) @@ -131,6 +136,7 @@ pub fn schema( password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + http_client: reqwest::Client, ) -> Schema { let state = GraphQLState { repository_factory, @@ -140,6 +146,7 @@ pub fn schema( password_manager, url_builder, limiter, + http_client, }; let state: BoxState = Box::new(state); diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 376652a46..e4c9d8bf8 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -440,14 +440,7 @@ impl DeactivateUserPayload { } fn valid_username_character(c: char) -> bool { - c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '=' - || c == '_' - || c == '-' - || c == '.' - || c == '/' - || c == '+' + c.is_ascii_lowercase() || c.is_ascii_digit() } // XXX: this should probably be moved somewhere else @@ -456,12 +449,7 @@ fn username_valid(username: &str) -> bool { return false; } - // Should not start with an underscore - if username.starts_with('_') { - return false; - } - - // Should only contain valid characters + // Should only contain lowercase ASCII letters and digits if !username.chars().all(valid_username_character) { return false; } @@ -497,6 +485,29 @@ impl UserMutations { return Ok(AddUserPayload::Invalid); } + // Postnumber (username) validation via external resolver + match crate::postnumber::check( + state.http_client(), + state.site_config().postnumber_validation.as_ref(), + &input.username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + return Ok(AddUserPayload::Reserved); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during addUser" + ); + return Err(async_graphql::Error::new("Postnumber resolver unavailable")); + } + } + // Ask the homeserver if the username is available let homeserver_available = state .homeserver_connection() diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index 7faf76334..a270d7c0f 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -26,6 +26,7 @@ pub trait State { fn site_config(&self) -> &SiteConfig; fn url_builder(&self) -> &UrlBuilder; fn limiter(&self) -> &Limiter; + fn http_client(&self) -> &reqwest::Client; } pub type BoxState = Box; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 0cb450f53..ce2be4e95 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -64,6 +64,7 @@ mod activity_tracker; mod captcha; #[cfg(test)] mod cleanup_tests; +pub(crate) mod postnumber; mod preferred_language; mod rate_limit; mod session; @@ -395,7 +396,7 @@ where .route(mas_router::Logout::route(), post(self::views::logout::post)) .route( mas_router::Register::route(), - get(self::views::register::get), + get(self::views::register::get).post(self::views::register::post), ) .route( mas_router::PasswordRegister::route(), diff --git a/crates/handlers/src/postnumber.rs b/crates/handlers/src/postnumber.rs new file mode 100644 index 000000000..9d8029b02 --- /dev/null +++ b/crates/handlers/src/postnumber.rs @@ -0,0 +1,564 @@ +// 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. + +//! Client for the external postnumber-resolver service. +//! +//! The resolver validates user-facing "postnumbers" (usernames) during +//! registration and returns whether a value is *allowed* or *reserved*. +//! +//! MAS usernames are restricted to `[a-z0-9]` which is the same alphabet +//! as the resolver. The `normalize_for_resolver` helper lowercases the +//! input before sending it to the resolver. + +use std::sync::LazyLock; + +use mas_data_model::{PostnumberValidationConfig, PostnumberValidationFailureMode}; +use mas_http::RequestBuilderExt as _; +use opentelemetry::{Key, KeyValue, metrics::Counter}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +use crate::METER; + +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- + +static OUTCOME: Key = Key::from_static_str("postnumber.outcome"); + +static CHECK_COUNTER: LazyLock> = LazyLock::new(|| { + METER + .u64_counter("mas.postnumber.check") + .with_description("Number of postnumber resolver checks") + .with_unit("{check}") + .build() +}); + +// --------------------------------------------------------------------------- +// Wire types – mirror of the resolver's HTTP contract +// --------------------------------------------------------------------------- + +/// Request payload sent to `POST /v1/check`. +#[derive(Debug, Serialize)] +struct CheckRequest<'a> { + value: &'a str, +} + +/// Status returned by the resolver on a successful check. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CheckStatus { + Allow, + Reserved, +} + +/// Full success response from `POST /v1/check`. +#[derive(Debug, Deserialize)] +pub struct CheckResponse { + pub status: CheckStatus, + #[allow(dead_code)] + pub match_kind: serde_json::Value, // we don't need the enum on this side + #[allow(dead_code)] + pub matched_terms: Vec, + #[allow(dead_code)] + pub matched_categories: Vec, + #[allow(dead_code)] + pub pattern_family: Option, +} + +// --------------------------------------------------------------------------- +// Domain result +// --------------------------------------------------------------------------- + +/// Maximum length the resolver accepts. Values longer than this after +/// stripping non-alphanumeric characters are not postnumbers — skip the +/// check and let MAS's own rules decide. +const MAX_RESOLVER_INPUT_LENGTH: usize = 20; + +/// The outcome of a postnumber check that callers should act on. +#[derive(Debug)] +pub enum PostnumberOutcome { + /// The postnumber is valid and available for any user. + Allowed, + /// The postnumber is valid but reserved by policy (admin-only). + Reserved, +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug, Error)] +pub enum Error { + /// HTTP-level failure (connection refused, timeout, TLS error, …). + #[error("postnumber resolver request failed")] + Request(#[from] reqwest::Error), + + /// The resolver returned an unexpected status code or unparseable body. + #[error("postnumber resolver returned an unexpected response (status {status})")] + UnexpectedResponse { status: reqwest::StatusCode }, + + /// The resolver is temporarily unavailable (rate-limited, service down, …) + /// and the configured failure-mode decides what happens. + #[error("postnumber resolver is unavailable")] + Unavailable, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Check a postnumber against the resolver service. +/// +/// Returns `Ok(None)` when postnumber validation is not configured (i.e. the +/// feature is disabled). +/// +/// Returns `Ok(Some(outcome))` with the resolver's decision, or `Err` when the +/// resolver is unreachable and the failure mode is `Closed`. +#[tracing::instrument( + name = "postnumber.check", + skip_all, + fields(postnumber.value = %value), +)] +pub async fn check( + http_client: &reqwest::Client, + config: Option<&PostnumberValidationConfig>, + value: &str, +) -> Result, Error> { + let Some(config) = config else { + return Ok(None); + }; + + // Lowercase the input before sending to the resolver. If the + // result is empty or exceeds the resolver's max length, skip. + let sanitized = normalize_for_resolver(value); + if sanitized.is_empty() || sanitized.len() > MAX_RESOLVER_INPUT_LENGTH { + tracing::debug!( + original = %value, + sanitized = %sanitized, + "skipping postnumber check – value not in resolver's domain", + ); + return Ok(None); + } + + match do_check(http_client, &config.endpoint, config.timeout, &sanitized).await { + Ok(outcome) => { + let label = match &outcome { + PostnumberOutcome::Allowed => "allowed", + PostnumberOutcome::Reserved => "reserved", + }; + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), label)]); + Ok(Some(outcome)) + } + Err(Error::Unavailable) => { + // Single retry with backoff for transient failures before + // triggering the fail-open/fail-closed policy. + tracing::info!("postnumber resolver unavailable – retrying once after 500ms"); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + match do_check(http_client, &config.endpoint, config.timeout, &sanitized).await { + Ok(outcome) => { + let label = match &outcome { + PostnumberOutcome::Allowed => "allowed", + PostnumberOutcome::Reserved => "reserved", + }; + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), label)]); + Ok(Some(outcome)) + } + Err(Error::Unavailable) => match config.on_unavailable { + PostnumberValidationFailureMode::Open => { + tracing::warn!( + "postnumber resolver unavailable after retry – failing open" + ); + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "unavailable_open")]); + Ok(None) + } + PostnumberValidationFailureMode::Closed => { + tracing::warn!( + "postnumber resolver unavailable after retry – failing closed" + ); + CHECK_COUNTER + .add(1, &[KeyValue::new(OUTCOME.clone(), "unavailable_closed")]); + Err(Error::Unavailable) + } + }, + Err(e) => { + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "error")]); + Err(e) + } + } + } + Err(e) => { + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "error")]); + Err(e) + } + } +} + +// --------------------------------------------------------------------------- +// Internal request logic +// --------------------------------------------------------------------------- + +/// Lowercases the input for the resolver. Since MAS now only allows +/// `[a-z0-9]` usernames, this is effectively just a `.to_ascii_lowercase()` +/// pass — no characters need to be stripped. +fn normalize_for_resolver(value: &str) -> String { + value + .chars() + .map(|c| c.to_ascii_lowercase()) + .collect::() +} + +async fn do_check( + http_client: &reqwest::Client, + endpoint: &Url, + timeout: std::time::Duration, + value: &str, +) -> Result { + // Ensure the endpoint has a trailing slash so that `Url::join` + // appends "v1/check" as a sub-path rather than replacing the last + // segment. For example, without this normalization: + // "http://host:3001/api".join("v1/check") → "http://host:3001/v1/check" + // With the trailing slash: + // "http://host:3001/api/".join("v1/check") → "http://host:3001/api/v1/check" + let mut base = endpoint.clone(); + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + + let url = base + .join("v1/check") + .expect("endpoint URL should be joinable with v1/check"); + + let response = http_client + .post(url) + .timeout(timeout) + .json(&CheckRequest { value }) + .send_traced() + .await + .map_err(|e| { + if e.is_timeout() || e.is_connect() { + tracing::warn!(error = &e as &dyn std::error::Error, "resolver unreachable"); + Error::Unavailable + } else { + Error::Request(e) + } + })?; + + let status = response.status(); + + if status.is_success() { + let body: CheckResponse = response + .json() + .await + .map_err(|_| Error::UnexpectedResponse { status })?; + + match body.status { + CheckStatus::Allow => Ok(PostnumberOutcome::Allowed), + CheckStatus::Reserved => Ok(PostnumberOutcome::Reserved), + } + } else if status == reqwest::StatusCode::BAD_REQUEST + || status == reqwest::StatusCode::UNPROCESSABLE_ENTITY + { + // The resolver rejected the format. Since MAS is the authority on + // username format (via OPA policy / `username_valid()`), we treat + // this as "not a postnumber" and allow it through. + tracing::debug!( + %status, + %value, + "resolver returned client error – treating as allowed", + ); + Ok(PostnumberOutcome::Allowed) + } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::SERVICE_UNAVAILABLE + || status.is_server_error() + { + Err(Error::Unavailable) + } else { + Err(Error::UnexpectedResponse { status }) + } +} + +#[cfg(test)] +mod tests { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, + }; + + use super::*; + + fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("failed to create test HTTP client") + } + + fn test_config(server: &MockServer) -> PostnumberValidationConfig { + PostnumberValidationConfig { + endpoint: Url::parse(&format!("{}/", server.uri())).unwrap(), + timeout: std::time::Duration::from_secs(5), + on_unavailable: PostnumberValidationFailureMode::Closed, + } + } + + #[tokio::test] + async fn test_allowed() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .and(body_json(serde_json::json!({ "value": "alice" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "alice", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + }))) + .expect(1) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "alice").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => {} + other => panic!("expected Allowed, got {other:?}"), + } + } + + #[tokio::test] + async fn test_reserved() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "admin", + "status": "RESERVED", + "match_kind": "exact", + "matched_terms": ["admin"], + "matched_categories": [], + "pattern_family": null + }))) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "admin").await.unwrap(); + match result { + Some(PostnumberOutcome::Reserved) => {} + other => panic!("expected Reserved, got {other:?}"), + } + } + + #[tokio::test] + async fn test_resolver_400_treated_as_allowed() { + // When the resolver rejects the format, MAS treats it as + // "not a postnumber" and allows it — MAS's own rules handle format. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_postnumber_format", + "message": "postnumber must start with a letter" + }))) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "abc123").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => { /* correct */ } + other => panic!("expected Allowed, got {other:?}"), + } + } + + #[tokio::test] + async fn test_uppercase_lowered() { + // normalize_for_resolver lowercases the input before sending. + // Verify the resolver receives "alice", not "Alice". + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .and(body_json(serde_json::json!({ "value": "alice" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "alice", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + }))) + .expect(1) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + // "Alice" → "alice" sent to resolver + let result = check(&client, Some(&config), "Alice").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => {} + other => panic!("expected Allowed, got {other:?}"), + } + } + + #[tokio::test] + async fn test_long_username_skipped() { + // Usernames > 20 alphanumeric chars are outside the resolver's + // domain and should be skipped entirely (returns None). + let server = MockServer::start().await; + // Don't mount any mock — if the resolver is called, the test fails. + + let client = test_http_client(); + let config = test_config(&server); + + let long_name = "a".repeat(21); + let result = check(&client, Some(&config), &long_name).await.unwrap(); + assert!(result.is_none(), "expected None for long username"); + } + + #[tokio::test] + async fn test_unavailable_closed() { + // No server running → connection refused + let config = PostnumberValidationConfig { + endpoint: Url::parse("http://127.0.0.1:1/").unwrap(), + timeout: std::time::Duration::from_millis(200), + on_unavailable: PostnumberValidationFailureMode::Closed, + }; + + let client = test_http_client(); + let result = check(&client, Some(&config), "test").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_unavailable_open() { + let config = PostnumberValidationConfig { + endpoint: Url::parse("http://127.0.0.1:1/").unwrap(), + timeout: std::time::Duration::from_millis(200), + on_unavailable: PostnumberValidationFailureMode::Open, + }; + + let client = test_http_client(); + let result = check(&client, Some(&config), "test").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_no_config() { + let client = test_http_client(); + let result = check(&client, None, "anything").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_rate_limited() { + let server = MockServer::start().await; + + // With retry, the resolver will be called twice before giving up. + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({ + "error": "rate_limited", + "message": "Too many requests" + }))) + .expect(2) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "test").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_retry_succeeds_on_second_attempt() { + // First call returns 503, second call succeeds. + let server = MockServer::start().await; + + // Mount a 503 response that only fires once… + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .mount(&server) + .await; + + // …then an ALLOW response for subsequent calls. + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "bob", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + }))) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "bob").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => {} + other => panic!("expected Allowed after retry, got {other:?}"), + } + } + + #[test] + fn test_endpoint_url_normalization() { + // Verify that the URL join works correctly with and without trailing slash. + let with_slash = Url::parse("http://localhost:3001/").unwrap(); + let without_slash = Url::parse("http://localhost:3001").unwrap(); + let with_path = Url::parse("http://localhost:3001/api").unwrap(); + let with_path_slash = Url::parse("http://localhost:3001/api/").unwrap(); + + // Helper that mirrors the normalization in do_check + let normalize_and_join = |endpoint: &Url| -> String { + let mut base = endpoint.clone(); + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + base.join("v1/check").unwrap().to_string() + }; + + assert_eq!( + normalize_and_join(&with_slash), + "http://localhost:3001/v1/check" + ); + assert_eq!( + normalize_and_join(&without_slash), + "http://localhost:3001/v1/check" + ); + assert_eq!( + normalize_and_join(&with_path), + "http://localhost:3001/api/v1/check" + ); + assert_eq!( + normalize_and_join(&with_path_slash), + "http://localhost:3001/api/v1/check" + ); + } +} diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 521a4848d..4fd4dbb77 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -68,6 +68,13 @@ pub(crate) fn setup() { .try_init(); } +pub(crate) fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("failed to create test HTTP client") +} + pub(crate) async fn policy_factory( server_name: &str, data: serde_json::Value, @@ -150,6 +157,7 @@ pub fn test_site_config() -> SiteConfig { login_with_email_allowed: true, plan_management_iframe_uri: None, session_limit: None, + postnumber_validation: None, } } @@ -183,7 +191,7 @@ impl TestState { ) .await?; - let http_client = mas_http::reqwest_client(); + let http_client = test_http_client(); // TODO: add more test keys to the store let rsa = @@ -228,6 +236,7 @@ impl TestState { password_manager: password_manager.clone(), url_builder: url_builder.clone(), limiter: limiter.clone(), + http_client: http_client.clone(), }; let state: crate::graphql::BoxState = Box::new(graphql_state); @@ -441,6 +450,7 @@ struct TestGraphQLState { password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + http_client: reqwest::Client, } #[async_trait::async_trait] @@ -482,6 +492,10 @@ impl graphql::State for TestGraphQLState { let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG"); Box::new(rng) } + + fn http_client(&self) -> &reqwest::Client { + &self.http_client + } } impl FromRef for PgPool { diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 0857bc2c1..f5f6ff987 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -314,13 +314,13 @@ mod tests { }; use super::*; - use crate::test_utils::setup; + use crate::test_utils::{setup, test_http_client}; #[tokio::test] async fn test_metadata_cache() { setup(); let mock_server = MockServer::start().await; - let http_client = mas_http::reqwest_client(); + let http_client = test_http_client(); let cache = MetadataCache::new(); @@ -384,7 +384,7 @@ mod tests { setup(); let mock_server = MockServer::start().await; - let http_client = mas_http::reqwest_client(); + let http_client = test_http_client(); let expected_calls = 2; let mut calls = 0; diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index e081b0f79..86bc0e419 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -235,6 +235,8 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, + State(site_config): State, + State(http_client): State, cookie_jar: CookieJar, activity_tracker: BoundActivityTracker, user_agent: Option>, @@ -730,6 +732,48 @@ pub(crate) async fn get( )); } + // Postnumber validation via external resolver + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + &localpart, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + if !forced_or_required { + tracing::warn!( + upstream_oauth_provider.id = %provider.id, + upstream_oauth_link.id = %link.id, + "Upstream provider returned a localpart {localpart:?} which is reserved. As the username is just a suggestion, it was ignored." + ); + break 'localpart None; + } + + let ctx = ErrorContext::new() + .with_code("postnumber-reserved") + .with_description(format!( + r"Localpart {localpart:?} is reserved and cannot be used for self-service registration" + )) + .with_language(&locale); + + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during upstream registration" + ); + return Err(RouteError::Internal(Box::new(e))); + } + } + // Now let's check if the localpart is allowed by the homeserver. It's possible // that it's plain invalid (although that should have been caught by the // policy), or just reserved by an application service @@ -857,6 +901,7 @@ pub(crate) async fn post( State(homeserver): State>, State(url_builder): State, State(site_config): State, + State(http_client): State, Path(link_id): Path, Form(form): Form>, ) -> Result { @@ -1128,6 +1173,39 @@ pub(crate) async fn post( ); } + // Postnumber (username) validation via external resolver. + // Run after OPA policy so we don't waste HTTP calls on usernames + // that OPA would already reject (all-numeric, banned, etc.). + if !username.is_empty() && form_state.is_valid() { + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + &username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + form_state.add_error_on_field( + mas_templates::UpstreamRegisterFormField::Username, + FieldError::Policy { + code: Some("postnumber-reserved"), + message: "This username is reserved".to_owned(), + }, + ); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during upstream registration" + ); + form_state.add_error_on_form(FormError::Internal); + } + } + } + form_state }; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 6a51852ae..a04bcc3ca 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -3,16 +3,28 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::{net::IpAddr, sync::Arc}; + use axum::{ - extract::State, + extract::{Form, State}, response::{Html, IntoResponse, Response}, }; use axum_extra::extract::Query; -use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; +use mas_axum_utils::{ + InternalError, SessionInfoExt, + cookies::CookieJar, + csrf::{CsrfExt as _, ProtectedForm}, +}; use mas_data_model::{BoxClock, BoxRng, SiteConfig}; +use mas_matrix::HomeserverConnection; +use mas_policy::Policy; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; -use mas_storage::BoxRepository; -use mas_templates::{RegisterContext, TemplateContext, Templates}; +use mas_storage::{BoxRepository, RepositoryAccess}; +use mas_templates::{ + FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext, + Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; use super::shared::OptionalPostAuthAction; use crate::{BoundActivityTracker, PreferredLanguage}; @@ -23,6 +35,128 @@ pub(crate) mod steps; pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie; +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct UsernameForm { + username: String, + #[serde(flatten)] + action: OptionalPostAuthAction, +} + +impl ToFormState for UsernameForm { + type Field = RegisterFormField; +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn validate_registration_username( + state: &mut FormState, + username: &str, + user_agent: Option, + ip_address: Option, + repo: &mut BoxRepository, + policy: &mut Policy, + homeserver: &Arc, + http_client: &reqwest::Client, + site_config: &SiteConfig, +) -> Result<(), InternalError> { + let mut homeserver_denied_username = false; + if username.is_empty() { + state.add_error_on_field(RegisterFormField::Username, FieldError::Required); + } else if repo.user().exists(username).await? { + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } else if !homeserver + .is_localpart_available(username) + .await + .map_err(InternalError::from_anyhow)? + { + tracing::warn!(username, "Homeserver denied username provided by user"); + homeserver_denied_username = true; + } + + let res = policy + .evaluate_register(mas_policy::RegisterInput { + registration_method: mas_policy::RegistrationMethod::Password, + username, + email: None, + requester: mas_policy::Requester { + ip_address, + user_agent, + }, + }) + .await?; + + for violation in res.violations { + match violation.field.as_deref() { + Some("username") => { + homeserver_denied_username = false; + state.add_error_on_field( + RegisterFormField::Username, + FieldError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }, + ); + } + _ => state.add_error_on_form(FormError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }), + } + } + + if homeserver_denied_username { + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } + + if !username.is_empty() && state.is_valid() { + match crate::postnumber::check( + http_client, + site_config.postnumber_validation.as_ref(), + username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => {} + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + state.add_error_on_field( + RegisterFormField::Username, + FieldError::Policy { + code: Some("postnumber-reserved"), + message: "This username is reserved".to_owned(), + }, + ); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error" + ); + state.add_error_on_form(FormError::Internal); + } + } + } + + Ok(()) +} + +async fn render( + locale: mas_i18n::DataLocale, + mut ctx: RegisterContext, + action: OptionalPostAuthAction, + csrf_token: impl ToString, + repo: &mut BoxRepository, + templates: &Templates, +) -> Result { + let post_action = action + .load_context(repo) + .await + .map_err(InternalError::from_anyhow)?; + if let Some(action) = post_action { + ctx = ctx.with_post_action(action); + } + + Ok(templates.render_register(&ctx.with_csrf(csrf_token).with_language(locale))?) +} + #[tracing::instrument(name = "handlers.views.register.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, @@ -78,18 +212,80 @@ pub(crate) async fn get( return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } - let mut ctx = RegisterContext::new(providers); - let post_action = query - .load_context(&mut repo) - .await - .map_err(InternalError::from_anyhow)?; - if let Some(action) = post_action { - ctx = ctx.with_post_action(action); - } - - let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); - - let content = templates.render_register(&ctx)?; + let content = render( + locale, + RegisterContext::new(providers), + query, + csrf_token.form_value(), + &mut repo, + &templates, + ) + .await?; Ok((cookie_jar, Html(content)).into_response()) } + +#[tracing::instrument(name = "handlers.views.register.post", skip_all)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + State(site_config): State, + State(homeserver): State>, + State(http_client): State, + mut policy: Policy, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + user_agent: Option>, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + + let form = cookie_jar.verify_form(&clock, form)?; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + if !site_config.password_registration_enabled { + return Ok(url_builder + .redirect(&mas_router::Login::from(form.action.post_auth_action)) + .into_response()); + } + + let providers = repo.upstream_oauth_provider().all_enabled().await?; + let mut state = form.to_form_state(); + validate_registration_username( + &mut state, + &form.username, + user_agent, + activity_tracker.ip(), + &mut repo, + &mut policy, + &homeserver, + &http_client, + &site_config, + ) + .await?; + + if !state.is_valid() { + let content = render( + locale, + RegisterContext::new(providers).with_form_state(state), + form.action, + csrf_token.form_value(), + &mut repo, + &templates, + ) + .await?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + + let mut destination = PasswordRegister::default().with_username(form.username); + if let Some(action) = form.action.post_auth_action { + destination = destination.and_then(action); + } + + Ok((cookie_jar, url_builder.redirect(&destination)).into_response()) +} diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 02820fad3..696af7b4f 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -26,7 +26,7 @@ use mas_router::UrlBuilder; use mas_storage::{ BoxRepository, RepositoryAccess, queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, - user::{UserEmailRepository, UserRepository}, + user::UserEmailRepository, }; use mas_templates::{ FieldError, FormError, FormState, PasswordRegisterContext, RegisterFormField, TemplateContext, @@ -181,27 +181,18 @@ pub(crate) async fn post( state.add_error_on_form(FormError::Captcha); } - let mut homeserver_denied_username = false; - if form.username.is_empty() { - state.add_error_on_field(RegisterFormField::Username, FieldError::Required); - } else if repo.user().exists(&form.username).await? { - // The user already exists in the database - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } else if !homeserver - .is_localpart_available(&form.username) - .await - .map_err(InternalError::from_anyhow)? - { - // The user already exists on the homeserver - tracing::warn!( - username = &form.username, - "Homeserver denied username provided by user" - ); - - // We defer adding the error on the field, until we know whether we had another - // error from the policy, to avoid showing both - homeserver_denied_username = true; - } + super::validate_registration_username( + &mut state, + &form.username, + user_agent.clone(), + activity_tracker.ip(), + &mut repo, + &mut policy, + &homeserver, + &http_client, + &site_config, + ) + .await?; if let Some(email) = &email { // Note that we don't check here if the email is already taken here, as @@ -267,18 +258,7 @@ pub(crate) async fn post( message: violation.msg, }, ), - Some("username") => { - // If the homeserver denied the username, but we also had an error on the policy - // side, we don't want to show both, so we reset the state here - homeserver_denied_username = false; - state.add_error_on_field( - RegisterFormField::Username, - FieldError::Policy { - code: violation.variant.map(|c| c.as_str()), - message: violation.msg, - }, - ); - } + Some("username") => {} Some("password") => state.add_error_on_field( RegisterFormField::Password, FieldError::Policy { @@ -293,11 +273,6 @@ pub(crate) async fn post( } } - if homeserver_denied_username { - // XXX: we may want to return different errors like "this username is reserved" - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } - if state.is_valid() { // Check the rate limit if we are about to process the form if let Err(e) = limiter.check_registration(requester) { diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index af0b8ef9f..f10121a22 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -51,6 +51,7 @@ pub(crate) async fn get( user_agent: Option>, State(url_builder): State, State(homeserver): State>, + State(http_client): State, State(templates): State, State(site_config): State, PreferredLanguage(lang): PreferredLanguage, @@ -120,6 +121,34 @@ pub(crate) async fn get( ))); } + // Re-check postnumber reservation to close the TOCTOU window between + // the initial form check and this final commit step. + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + ®istration.username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid or not configured – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "This PostNumber is reserved and cannot be used for registration" + ))); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during registration finalization" + ); + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Could not verify PostNumber availability" + ))); + } + } + // Check if the registration token is required and was provided let registration_token = if site_config.registration_token_required { if let Some(registration_token_id) = registration.user_registration_token_id { diff --git a/crates/oidc-client/tests/it/main.rs b/crates/oidc-client/tests/it/main.rs index cc8641085..f8f44a7fe 100644 --- a/crates/oidc-client/tests/it/main.rs +++ b/crates/oidc-client/tests/it/main.rs @@ -42,10 +42,17 @@ fn now() -> DateTime { Utc::now() } +fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("failed to create test HTTP client") +} + async fn init_test() -> (reqwest::Client, MockServer, Url) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let client = mas_http::reqwest_client(); + let client = test_http_client(); let mock_server = MockServer::start().await; let issuer = Url::parse(&mock_server.uri()).expect("Couldn't parse URL"); diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 239f24b25..e4b8bda42 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -340,6 +340,12 @@ pub struct PasswordRegister { } impl PasswordRegister { + #[must_use] + pub fn with_username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + #[must_use] pub fn and_then(mut self, action: PostAuthAction) -> Self { self.post_auth_action = Some(action); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 25123970b..b433894c1 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -647,6 +647,7 @@ impl FormField for RegisterFormField { /// Context used by the `register.html` template #[derive(Serialize, Default)] pub struct RegisterContext { + form: FormState, providers: Vec, next: Option, } @@ -661,6 +662,7 @@ impl TemplateContext for RegisterContext { Self: Sized, { sample_list(vec![RegisterContext { + form: FormState::default(), providers: Vec::new(), next: None, }]) @@ -672,11 +674,18 @@ impl RegisterContext { #[must_use] pub fn new(providers: Vec) -> Self { Self { + form: FormState::default(), providers, next: None, } } + /// Add an error on the registration form + #[must_use] + pub fn with_form_state(self, form: FormState) -> Self { + Self { form, ..self } + } + /// Add a post authentication action to the context #[must_use] pub fn with_post_action(self, next: PostAuthContext) -> Self { diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d2d65a0b4..a4ecc17b4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -358,6 +358,25 @@ account: # When enabled, users must provide a valid registration token during password # registration. This has no effect if password registration is disabled. registration_token_required: false + + # Optional external resolver for validating postnumbers (usernames) during + # registration. When configured, every self-service and upstream-OAuth + # registration will call this service. The Admin API also calls this, + # but allows explicitly claiming reserved postnumbers via the + # `claim_reserved_postnumber` flag. + # + # Omit this section entirely to disable postnumber validation. + #postnumber_validation: + # # URL of the postnumber-resolver service (required) + # endpoint: "http://localhost:3001/" + # + # # Per-request timeout in seconds (default: 2) + # timeout: 2 + # + # # What to do when the resolver is unreachable: + # # - "closed" (default): reject the registration + # # - "open": allow the registration to proceed + # on_unavailable: closed ``` ## `captcha` diff --git a/frontend/src/entrypoints/templates.ts b/frontend/src/entrypoints/templates.ts index 5aea14b6f..4fb18ac37 100644 --- a/frontend/src/entrypoints/templates.ts +++ b/frontend/src/entrypoints/templates.ts @@ -10,7 +10,7 @@ // Ideally later on we could find a way to hydrate full React components instead // of doing this, as this can very quickly get out of hands. -const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/+-]+|@[a-z0-9.=_/+-]+(:.*)?)\s*$/g; +const VALID_USERNAME_RE = /^\s*([a-z0-9]+|@[a-z0-9]+(:.*)?)\s*$/g; /** Grab the nearest error message inserted by the templates by error kind and code */ function grabErrorMessage( diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index bd0bdbc7f..4338a8008 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -117,22 +117,22 @@ const AccountSessionsBrowsersRoute = AccountSessionsBrowsersRouteImport.update({ } as any) export interface FileRoutesByFullPath { - '/': typeof AccountIndexRoute '/reset-cross-signing': typeof ResetCrossSigningRouteWithChildren '/clients/$id': typeof ClientsIdRoute '/devices/$': typeof DevicesSplatRoute '/reset-cross-signing/cancelled': typeof ResetCrossSigningCancelledRoute '/reset-cross-signing/success': typeof ResetCrossSigningSuccessRoute '/sessions/$id': typeof SessionsIdRoute + '/': typeof AccountIndexRoute '/reset-cross-signing/': typeof ResetCrossSigningIndexRoute '/sessions/browsers': typeof AccountSessionsBrowsersRoute '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessRoute - '/plan/': typeof AccountPlanIndexRoute - '/sessions/': typeof AccountSessionsIndexRoute - '/password/change/': typeof PasswordChangeIndexRoute - '/password/recovery/': typeof PasswordRecoveryIndexRoute + '/plan': typeof AccountPlanIndexRoute + '/sessions': typeof AccountSessionsIndexRoute + '/password/change': typeof PasswordChangeIndexRoute + '/password/recovery': typeof PasswordRecoveryIndexRoute } export interface FileRoutesByTo { '/clients/$id': typeof ClientsIdRoute @@ -174,22 +174,22 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' | '/reset-cross-signing' | '/clients/$id' | '/devices/$' | '/reset-cross-signing/cancelled' | '/reset-cross-signing/success' | '/sessions/$id' + | '/' | '/reset-cross-signing/' | '/sessions/browsers' | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' - | '/plan/' - | '/sessions/' - | '/password/change/' - | '/password/recovery/' + | '/plan' + | '/sessions' + | '/password/change' + | '/password/recovery' fileRoutesByTo: FileRoutesByTo to: | '/clients/$id' @@ -253,7 +253,7 @@ declare module '@tanstack/react-router' { '/_account': { id: '/_account' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof AccountRouteImport parentRoute: typeof rootRouteImport } @@ -309,28 +309,28 @@ declare module '@tanstack/react-router' { '/password/recovery/': { id: '/password/recovery/' path: '/password/recovery' - fullPath: '/password/recovery/' + fullPath: '/password/recovery' preLoaderRoute: typeof PasswordRecoveryIndexRouteImport parentRoute: typeof rootRouteImport } '/password/change/': { id: '/password/change/' path: '/password/change' - fullPath: '/password/change/' + fullPath: '/password/change' preLoaderRoute: typeof PasswordChangeIndexRouteImport parentRoute: typeof rootRouteImport } '/_account/sessions/': { id: '/_account/sessions/' path: '/sessions' - fullPath: '/sessions/' + fullPath: '/sessions' preLoaderRoute: typeof AccountSessionsIndexRouteImport parentRoute: typeof AccountRoute } '/_account/plan/': { id: '/_account/plan/' path: '/plan' - fullPath: '/plan/' + fullPath: '/plan' preLoaderRoute: typeof AccountPlanIndexRouteImport parentRoute: typeof AccountRoute } diff --git a/policies/register/register.rego b/policies/register/register.rego index 93ef86a0d..02a2d58e9 100644 --- a/policies/register/register.rego +++ b/policies/register/register.rego @@ -49,7 +49,7 @@ violation contains { "field": "username", "code": "username-invalid-chars", "msg": "username contains invalid characters", } if { - not regex.match(`^[a-z0-9.=_/+-]+$`, input.username) + not regex.match(`^[a-z0-9]+$`, input.username) } violation contains { diff --git a/templates/components/field.html b/templates/components/field.html index f69abe40a..2ce161e5b 100644 --- a/templates/components/field.html +++ b/templates/components/field.html @@ -40,6 +40,8 @@ Please see LICENSE files in the repository root for full details. {{ _("mas.errors.username_banned") }} {% elif error.code == "username-not-allowed" %} {{ _("mas.errors.username_not_allowed") }} + {% elif error.code == "postnumber-reserved" %} + {{ _("mas.errors.postnumber_reserved") }} {% elif error.code == "email-domain-not-allowed" %} {{ _("mas.errors.email_domain_not_allowed") }} {% elif error.code == "email-domain-banned" %} diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index 6ac98251a..530760fbc 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -12,7 +12,7 @@ Please see LICENSE files in the repository root for full details. {% from "components/idp_brand.html" import logo %} {% block content %} -
+
{% if features.password_registration %} + + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + {{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }} + {{ field.error(error={"kind": "policy", "code": "postnumber-reserved"}, hidden=true) }}
@username:{{ branding.server_name }}
@@ -37,6 +41,12 @@ Please see LICENSE files in the repository root for full details. {% endif %}
+ {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% for key, value in next["params"] | default({}) | items %} {% endfor %} diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html index 8d01c4c0e..dd5db3259 100644 --- a/templates/pages/register/password.html +++ b/templates/pages/register/password.html @@ -36,6 +36,7 @@ Please see LICENSE files in the repository root for full details. {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} {{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }} + {{ field.error(error={"kind": "policy", "code": "postnumber-reserved"}, hidden=true) }} {% endcall %} {% if features.password_registration_email_required %} diff --git a/templates/pages/upstream_oauth2/do_register.html b/templates/pages/upstream_oauth2/do_register.html index 7db4fe3c5..e66c583ce 100644 --- a/templates/pages/upstream_oauth2/do_register.html +++ b/templates/pages/upstream_oauth2/do_register.html @@ -102,6 +102,7 @@ Please see LICENSE files in the repository root for full details. {% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %} {{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }} + {{ field.error(error={"kind": "policy", "code": "postnumber-reserved"}, hidden=true) }} {% if f.errors is empty %}
diff --git a/translations/cs.json b/translations/cs.json index bf2de1fe2..d0a131b00 100644 --- a/translations/cs.json +++ b/translations/cs.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "V krátké době jste podali příliš mnoho žádostí. Počkejte prosím několik minut a zkuste to znovu.", "username_all_numeric": "PostNumber se nemůže skládat pouze z čísel", "username_banned": "PostNumber je zakázáno zásadami serveru", - "username_invalid_chars": "PostNumber obsahuje neplatné znaky. Používejte pouze malá písmena, číslice, pomlčky a podtržítka.", + "username_invalid_chars": "PostNumber obsahuje neplatné znaky. Používejte pouze malá písmena a číslice.", "username_not_allowed": "PostNumber není povoleno zásadami serveru", + "postnumber_reserved": "Toto PostNumber je rezervováno a nelze ho použít k registraci", "username_taken": "Toto PostNumber je již obsazeno", "username_too_long": "PostNumber je příliš dlouhé", "username_too_short": "PostNumber je příliš krátké" diff --git a/translations/da.json b/translations/da.json index a57153855..265201aca 100644 --- a/translations/da.json +++ b/translations/da.json @@ -139,8 +139,9 @@ "rate_limit_exceeded": "Du har indsendt for mange anmodninger på kort tid. Vent et par minutter, og prøv igen.", "username_all_numeric": "PostNumber kan ikke udelukkende bestå af tal", "username_banned": "PostNumber er forbudt af serverpolitikken", - "username_invalid_chars": "PostNumber indeholder ugyldige tegn. Brug kun små bogstaver, tal, bindestreger og underscores.", + "username_invalid_chars": "PostNumber indeholder ugyldige tegn. Brug kun små bogstaver og tal.", "username_not_allowed": "PostNumber er ikke tilladt af serverpolitikken", + "postnumber_reserved": "Dette PostNumber er reserveret og kan ikke bruges til registrering", "username_taken": "Dette PostNumber er allerede taget", "username_too_long": "PostNumber er for langt", "username_too_short": "PostNumber er for kort" diff --git a/translations/de.json b/translations/de.json index bded1798f..b0dde964f 100644 --- a/translations/de.json +++ b/translations/de.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Du hast in kurzer Zeit zu viele Anfragen gestellt. Warte bitte ein paar Minuten und versuch's nochmal.", "username_all_numeric": "PostNumber darf nicht nur aus Zahlen bestehen", "username_banned": "PostNumber ist durch die Serverrichtlinie gesperrt", - "username_invalid_chars": "PostNumber enthält ungültige Zeichen. Verwende nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche.", + "username_invalid_chars": "PostNumber enthält ungültige Zeichen. Verwende nur Kleinbuchstaben und Zahlen.", "username_not_allowed": "PostNumber ist gemäß der Serverrichtlinie nicht zulässig", + "postnumber_reserved": "Diese PostNumber ist reserviert und kann nicht zur Registrierung verwendet werden", "username_taken": "Dieser PostNumber ist bereits vergeben", "username_too_long": "PostNumber ist zu lang", "username_too_short": "PostNumber ist zu kurz" diff --git a/translations/en.json b/translations/en.json index c04f364cd..0ca73b9c3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -388,7 +388,7 @@ "context": "components/field.html:40:11-42", "description": "Error message shown on registration, when the postNumber matches a pattern that is banned by the server policy." }, - "username_invalid_chars": "PostNumber contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.", + "username_invalid_chars": "PostNumber contains invalid characters. Use lowercase letters and numbers only.", "@username_invalid_chars": { "context": "components/field.html:36:11-49" }, @@ -397,6 +397,11 @@ "context": "components/field.html:42:11-47", "description": "Error message shown on registration, when the postNumber *does not match* any of the patterns that are allowed by the server policy." }, + "postnumber_reserved": "This PostNumber is reserved and cannot be used for registration", + "@postnumber_reserved": { + "context": "components/field.html:44:11-47", + "description": "Error message shown on registration, when the postNumber is reserved by the postnumber resolver." + }, "username_taken": "This postNumber is already taken", "@username_taken": { "context": "components/field.html:29:9-39" diff --git a/translations/et.json b/translations/et.json index 6754c89a3..1fa28477f 100644 --- a/translations/et.json +++ b/translations/et.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Sa oled lühikese aja jooksul teinud liiga palju päringuid. Palun oota paar minutit ja proovi uuesti.", "username_all_numeric": "PostNumber ei tohi koosneda vaid numbritest", "username_banned": "PostNumber on serverireeglite alusel keelatud", - "username_invalid_chars": "PostNumber sisaldab keelatud tähemärke. Palun kasuta vaid väiketähti, numbreid, sidekriipsu ja alakriipsu.", + "username_invalid_chars": "PostNumber sisaldab keelatud tähemärke. Palun kasuta vaid väiketähti ja numbreid.", "username_not_allowed": "PostNumber pole serveri reeglite alusel lubatud", + "postnumber_reserved": "See PostNumber on reserveeritud ja seda ei saa registreerimiseks kasutada", "username_taken": "Selline PostNumber on juba olemas", "username_too_long": "PostNumber on liiga pikk", "username_too_short": "PostNumber on liiga lühike" diff --git a/translations/fi.json b/translations/fi.json index b140dc7b8..90936cf62 100644 --- a/translations/fi.json +++ b/translations/fi.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Olet tehnyt liian monta pyyntöä lyhyessä ajassa. Odota muutama minuutti ja yritä uudelleen.", "username_all_numeric": "PostNumber ei voi koostua pelkästään numeroista", "username_banned": "Palvelinkäytäntö kieltää tämän PostNumber-tunnuksen", - "username_invalid_chars": "PostNumber sisältää virheellisiä merkkejä. Käytä vain pieniä kirjaimia, numeroita, viivoja ja alaviivoja.", + "username_invalid_chars": "PostNumber sisältää virheellisiä merkkejä. Käytä vain pieniä kirjaimia ja numeroita.", "username_not_allowed": "Palvelinkäytäntö ei salli tätä PostNumber-tunnusta", + "postnumber_reserved": "Tämä PostNumber on varattu eikä sitä voi käyttää rekisteröitymiseen", "username_taken": "Tämä PostNumber on jo käytössä.", "username_too_long": "PostNumber on liian pitkä", "username_too_short": "PostNumber on liian lyhyt" diff --git a/translations/fr.json b/translations/fr.json index 998708590..22745b0b7 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Vous avez effectué trop de requêtes sur une courte période. Veuillez patienter quelques minutes et réessayer.", "username_all_numeric": "PostNumber ne peut pas être composé uniquement de chiffres", "username_banned": "Ce PostNumber est interdit par la politique du serveur", - "username_invalid_chars": "PostNumber contient des caractères non valides. Utilisez uniquement des lettres minuscules, des chiffres, des tirets et des traits de soulignement.", + "username_invalid_chars": "PostNumber contient des caractères non valides. Utilisez uniquement des lettres minuscules et des chiffres.", "username_not_allowed": "Ce PostNumber n'est pas autorisé par la politique du serveur", + "postnumber_reserved": "Ce PostNumber est réservé et ne peut pas être utilisé pour l'inscription", "username_taken": "Ce PostNumber est déjà utilisé", "username_too_long": "PostNumber est trop long", "username_too_short": "PostNumber est trop court" diff --git a/translations/hu.json b/translations/hu.json index 4c5bb0846..178f2ed31 100644 --- a/translations/hu.json +++ b/translations/hu.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Túl sok kérést adott fel egy rövid időszak alatt. Várjon néhány percet, és próbálja újra.", "username_all_numeric": "PostNumber nem állhat pusztán számokból", "username_banned": "A PostNumber-t a kiszolgáló-házirend tiltja", - "username_invalid_chars": "PostNumber érvénytelen karaktereket tartalmaz. Csak kisbetűket, számokat, kötőjeleket és aláhúzásokat használjon.", + "username_invalid_chars": "PostNumber érvénytelen karaktereket tartalmaz. Csak kisbetűket és számokat használjon.", "username_not_allowed": "A PostNumber-t nem engedélyezi a kiszolgáló-házirend", + "postnumber_reserved": "Ez a PostNumber foglalt és nem használható regisztrációhoz", "username_taken": "PostNumber már foglalt", "username_too_long": "PostNumber túl hosszú", "username_too_short": "PostNumber túl rövid" diff --git a/translations/nb-NO.json b/translations/nb-NO.json index 00d0ce90a..3526ffbad 100644 --- a/translations/nb-NO.json +++ b/translations/nb-NO.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Du har kommet med for mange forespørsler på kort tid. Vent noen minutter og prøv igjen.", "username_all_numeric": "PostNumber kan ikke bare bestå av tall", "username_banned": "PostNumber er utestengt av serverpolicyen", - "username_invalid_chars": "PostNumber inneholder ugyldige tegn. Bruk bare små bokstaver, tall, bindestrek og understrek.", + "username_invalid_chars": "PostNumber inneholder ugyldige tegn. Bruk bare små bokstaver og tall.", "username_not_allowed": "PostNumber er ikke tillatt av serverpolicyen", + "postnumber_reserved": "Dette PostNumber er reservert og kan ikke brukes til registrering", "username_taken": "Dette PostNumber er allerede tatt", "username_too_long": "PostNumber er for langt", "username_too_short": "PostNumber er for kort" diff --git a/translations/pl.json b/translations/pl.json index 5d6fb4574..c25a36b71 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "W krótkim czasie wysłałeś zbyt wiele żądań. Poczekaj kilka minut i spróbuj ponownie.", "username_all_numeric": "PostNumber nie może składać się wyłącznie z cyfr", "username_banned": "PostNumber jest zablokowany przez politykę serwera", - "username_invalid_chars": "PostNumber zawiera nieprawidłowe znaki. Używaj tylko małych liter, cyfr, myślników i podkreśleń.", + "username_invalid_chars": "PostNumber zawiera nieprawidłowe znaki. Używaj tylko małych liter i cyfr.", "username_not_allowed": "PostNumber nie jest dozwolony przez politykę serwera", + "postnumber_reserved": "Ten PostNumber jest zarezerwowany i nie może być użyty do rejestracji", "username_taken": "Ten PostNumber jest już zajęty", "username_too_long": "PostNumber jest za długi", "username_too_short": "PostNumber jest za krótki" diff --git a/translations/pt.json b/translations/pt.json index 2b5cca778..af1981538 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -139,8 +139,9 @@ "rate_limit_exceeded": "Efetuou demasiadas solicitações num curto espaço de tempo. Aguarde alguns minutos e tente novamente.", "username_all_numeric": "O PostNumber não pode ser constituído apenas por números", "username_banned": "O PostNumber é proibido pela política do servidor", - "username_invalid_chars": "O PostNumber contém carateres inválidos. Utilize apenas letras minúsculas, números, traços e sublinhados.", + "username_invalid_chars": "O PostNumber contém carateres inválidos. Utilize apenas letras minúsculas e números.", "username_not_allowed": "O PostNumber não é permitido pela política do servidor", + "postnumber_reserved": "Este PostNumber está reservado e não pode ser utilizado para registo", "username_taken": "Este PostNumber já foi utilizado", "username_too_long": "O PostNumber é demasiado longo", "username_too_short": "O PostNumber é demasiado curto" diff --git a/translations/ru.json b/translations/ru.json index d8c8f88a2..0b49cd97f 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Вы делаете запросы слишком часто. Пожалуйста, подождите несколько минут и повторите попытку.", "username_all_numeric": "ПостНамбер не может состоять только из цифр", "username_banned": "ПостНамбер запрещён политикой сервера", - "username_invalid_chars": "ПостНамбер содержит недопустимые символы. Используйте только строчные буквы латиницы, цифры, тире и символы подчеркивания.", + "username_invalid_chars": "ПостНамбер содержит недопустимые символы. Используйте только строчные буквы латиницы и цифры.", "username_not_allowed": "ПостНамбер не разрешён политикой сервера", + "postnumber_reserved": "Этот ПостНамбер зарезервирован и не может быть использован для регистрации", "username_taken": "Этот ПостНамбер уже занят", "username_too_long": "ПостНамбер слишком длинный", "username_too_short": "ПостНамбер слишком короткий" diff --git a/translations/sv.json b/translations/sv.json index 27e18af24..9dabf04f2 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -119,7 +119,8 @@ "password_mismatch": "Lösenordsfälten matchar inte", "rate_limit_exceeded": "Du har gjort för många förfrågningar under en kort period. Vänta några minuter och försök igen.", "username_all_numeric": "PostNumber kan inte enbart bestå av siffror", - "username_invalid_chars": "PostNumber innehåller ogiltiga tecken. Använd endast små bokstäver, siffror, streck och understreck.", + "username_invalid_chars": "PostNumber innehåller ogiltiga tecken. Använd endast små bokstäver och siffror.", + "postnumber_reserved": "Detta PostNumber är reserverat och kan inte användas för registrering", "username_taken": "Detta PostNumber är redan upptaget", "username_too_long": "PostNumber är för långt", "username_too_short": "PostNumber är för kort" diff --git a/translations/uk.json b/translations/uk.json index 6fe626eda..6d77db91d 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "Ви зробили забагато запитів за короткий проміжок часу. Зачекайте кілька хвилин і повторіть спробу.", "username_all_numeric": "ПостНамбер не може складатися тільки з цифр", "username_banned": "ПостНамбер заборонений політикою сервера", - "username_invalid_chars": "ПостНамбер містить неприпустимі символи. Використовуйте лише малі букви, цифри, тире та підкреслення.", + "username_invalid_chars": "ПостНамбер містить неприпустимі символи. Використовуйте лише малі букви та цифри.", "username_not_allowed": "ПостНамбер не дозволений політикою сервера", + "postnumber_reserved": "Цей ПостНамбер зарезервований і не може бути використаний для реєстрації", "username_taken": "Цей ПостНамбер вже зайнятий", "username_too_long": "ПостНамбер задовгий", "username_too_short": "ПостНамбер закороткий" diff --git a/translations/zh-Hans.json b/translations/zh-Hans.json index 9a53cd805..bc21cfb21 100644 --- a/translations/zh-Hans.json +++ b/translations/zh-Hans.json @@ -144,8 +144,9 @@ "rate_limit_exceeded": "你在短时间内发出了过多请求。请于几分钟后重试。", "username_all_numeric": "PostNumber不能仅由数字组成", "username_banned": "由于服务器策略,PostNumber已被禁止", - "username_invalid_chars": "PostNumber包含无效字符。仅能使用小写字母、数字、短横线或下划线。", + "username_invalid_chars": "PostNumber包含无效字符。仅能使用小写字母和数字。", "username_not_allowed": "由于服务器策略,PostNumber不被允许", + "postnumber_reserved": "此PostNumber已被保留,无法用于注册", "username_taken": "此PostNumber已被使用", "username_too_long": "PostNumber太长", "username_too_short": "PostNumber太短"