Integrate postnumber resolver across MAS flows
This commit is contained in:
@@ -231,6 +231,7 @@ impl Options {
|
|||||||
password_manager.clone(),
|
password_manager.clone(),
|
||||||
url_builder.clone(),
|
url_builder.clone(),
|
||||||
limiter.clone(),
|
limiter.clone(),
|
||||||
|
http_client.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = {
|
let state = {
|
||||||
|
|||||||
@@ -243,6 +243,20 @@ pub fn site_config_from_config(
|
|||||||
soft_limit: c.soft_limit,
|
soft_limit: c.soft_limit,
|
||||||
hard_limit: c.hard_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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::serde_as;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::ConfigurationSection;
|
use crate::ConfigurationSection;
|
||||||
|
|
||||||
@@ -27,6 +31,54 @@ const fn is_default_false(value: &bool) -> bool {
|
|||||||
*value == default_false()
|
*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<u64>")]
|
||||||
|
#[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
|
/// Configuration section to configure features related to account management
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
||||||
@@ -88,6 +140,11 @@ pub struct AccountConfig {
|
|||||||
/// is disabled.
|
/// is disabled.
|
||||||
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
|
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
|
||||||
pub registration_token_required: bool,
|
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<PostnumberValidationConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AccountConfig {
|
impl Default for AccountConfig {
|
||||||
@@ -102,6 +159,7 @@ impl Default for AccountConfig {
|
|||||||
account_deactivation_allowed: default_true(),
|
account_deactivation_allowed: default_true(),
|
||||||
login_with_email_allowed: default_false(),
|
login_with_email_allowed: default_false(),
|
||||||
registration_token_required: 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_true(&self.account_deactivation_allowed)
|
||||||
&& is_default_false(&self.login_with_email_allowed)
|
&& is_default_false(&self.login_with_email_allowed)
|
||||||
&& is_default_false(&self.registration_token_required)
|
&& is_default_false(&self.registration_token_required)
|
||||||
|
&& self.postnumber_validation.is_none()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ mod templates;
|
|||||||
mod upstream_oauth2;
|
mod upstream_oauth2;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
account::AccountConfig,
|
account::{AccountConfig, PostnumberValidationConfig, PostnumberValidationFailureMode},
|
||||||
branding::BrandingConfig,
|
branding::BrandingConfig,
|
||||||
captcha::{CaptchaConfig, CaptchaServiceKind},
|
captcha::{CaptchaConfig, CaptchaServiceKind},
|
||||||
clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig},
|
clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig},
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ pub use self::{
|
|||||||
},
|
},
|
||||||
policy_data::PolicyData,
|
policy_data::PolicyData,
|
||||||
site_config::{
|
site_config::{
|
||||||
CaptchaConfig, CaptchaService, SessionExpirationConfig, SessionLimitConfig, SiteConfig,
|
CaptchaConfig, CaptchaService, PostnumberValidationConfig, PostnumberValidationFailureMode,
|
||||||
|
SessionExpirationConfig, SessionLimitConfig, SiteConfig,
|
||||||
},
|
},
|
||||||
tokens::{
|
tokens::{
|
||||||
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
|
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
// Please see LICENSE files in the repository root for full details.
|
// 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 chrono::Duration;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -111,4 +111,27 @@ pub struct SiteConfig {
|
|||||||
|
|
||||||
/// Limits on the number of application sessions that each user can have
|
/// Limits on the number of application sessions that each user can have
|
||||||
pub session_limit: Option<SessionLimitConfig>,
|
pub session_limit: Option<SessionLimitConfig>,
|
||||||
|
|
||||||
|
/// Postnumber validation configuration
|
||||||
|
pub postnumber_validation: Option<PostnumberValidationConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ where
|
|||||||
Arc<PolicyFactory>: FromRef<S>,
|
Arc<PolicyFactory>: FromRef<S>,
|
||||||
SiteConfig: FromRef<S>,
|
SiteConfig: FromRef<S>,
|
||||||
AppVersion: FromRef<S>,
|
AppVersion: FromRef<S>,
|
||||||
|
reqwest::Client: FromRef<S>,
|
||||||
{
|
{
|
||||||
// We *always* want to explicitly set the possible responses, beacuse the
|
// We *always* want to explicitly set the possible responses, beacuse the
|
||||||
// infered ones are not necessarily correct
|
// infered ones are not necessarily correct
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ where
|
|||||||
SiteConfig: FromRef<S>,
|
SiteConfig: FromRef<S>,
|
||||||
AppVersion: FromRef<S>,
|
AppVersion: FromRef<S>,
|
||||||
Arc<PolicyFactory>: FromRef<S>,
|
Arc<PolicyFactory>: FromRef<S>,
|
||||||
|
reqwest::Client: FromRef<S>,
|
||||||
BoxRng: FromRequestParts<S>,
|
BoxRng: FromRequestParts<S>,
|
||||||
CallContext: FromRequestParts<S>,
|
CallContext: FromRequestParts<S>,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use serde::Deserialize;
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
SiteConfig,
|
||||||
admin::{
|
admin::{
|
||||||
call_context::CallContext,
|
call_context::CallContext,
|
||||||
model::User,
|
model::User,
|
||||||
@@ -26,14 +27,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn valid_username_character(c: char) -> bool {
|
fn valid_username_character(c: char) -> bool {
|
||||||
c.is_ascii_lowercase()
|
c.is_ascii_lowercase() || c.is_ascii_digit()
|
||||||
|| c.is_ascii_digit()
|
|
||||||
|| c == '='
|
|
||||||
|| c == '_'
|
|
||||||
|| c == '-'
|
|
||||||
|| c == '.'
|
|
||||||
|| c == '/'
|
|
||||||
|| c == '+'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: this should be shared with the graphql handler
|
// XXX: this should be shared with the graphql handler
|
||||||
@@ -42,12 +36,7 @@ fn username_valid(username: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not start with an underscore
|
// Should only contain lowercase ASCII letters and digits
|
||||||
if username.starts_with('_') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only contain valid characters
|
|
||||||
if !username.chars().all(valid_username_character) {
|
if !username.chars().all(valid_username_character) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -72,6 +61,12 @@ pub enum RouteError {
|
|||||||
|
|
||||||
#[error("Username is reserved by the homeserver")]
|
#[error("Username is reserved by the homeserver")]
|
||||||
UsernameReserved,
|
UsernameReserved,
|
||||||
|
|
||||||
|
#[error("Postnumber is reserved")]
|
||||||
|
PostnumberReserved,
|
||||||
|
|
||||||
|
#[error("Postnumber resolver unavailable")]
|
||||||
|
PostnumberResolverUnavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||||
@@ -81,9 +76,13 @@ impl IntoResponse for RouteError {
|
|||||||
let error = ErrorResponse::from_error(&self);
|
let error = ErrorResponse::from_error(&self);
|
||||||
let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_));
|
let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_));
|
||||||
let status = match self {
|
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::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()
|
(status, sentry_event_id, Json(error)).into_response()
|
||||||
}
|
}
|
||||||
@@ -103,6 +102,12 @@ pub struct Request {
|
|||||||
/// tokens (like with admin access) for them
|
/// tokens (like with admin access) for them
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
skip_homeserver_check: bool,
|
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 {
|
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||||
@@ -137,6 +142,8 @@ pub async fn handler(
|
|||||||
}: CallContext,
|
}: CallContext,
|
||||||
NoApi(mut rng): NoApi<BoxRng>,
|
NoApi(mut rng): NoApi<BoxRng>,
|
||||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||||
|
State(site_config): State<SiteConfig>,
|
||||||
|
State(http_client): State<reqwest::Client>,
|
||||||
Json(params): Json<Request>,
|
Json(params): Json<Request>,
|
||||||
) -> Result<(StatusCode, Json<SingleResponse<User>>), RouteError> {
|
) -> Result<(StatusCode, Json<SingleResponse<User>>), RouteError> {
|
||||||
if repo.user().exists(¶ms.username).await? {
|
if repo.user().exists(¶ms.username).await? {
|
||||||
@@ -148,6 +155,35 @@ pub async fn handler(
|
|||||||
return Err(RouteError::UsernameNotValid);
|
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
|
// Ask the homeserver if the username is available
|
||||||
let homeserver_available = homeserver
|
let homeserver_available = homeserver
|
||||||
.is_localpart_available(¶ms.username)
|
.is_localpart_available(¶ms.username)
|
||||||
@@ -323,4 +359,104 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(user.username, "bob");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ impl_from_ref!(mas_handlers::passwords::PasswordManager);
|
|||||||
impl_from_ref!(Arc<mas_policy::PolicyFactory>);
|
impl_from_ref!(Arc<mas_policy::PolicyFactory>);
|
||||||
impl_from_ref!(mas_data_model::SiteConfig);
|
impl_from_ref!(mas_data_model::SiteConfig);
|
||||||
impl_from_ref!(mas_data_model::AppVersion);
|
impl_from_ref!(mas_data_model::AppVersion);
|
||||||
|
impl_from_ref!(reqwest::Client);
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
|
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ struct GraphQLState {
|
|||||||
password_manager: PasswordManager,
|
password_manager: PasswordManager,
|
||||||
url_builder: UrlBuilder,
|
url_builder: UrlBuilder,
|
||||||
limiter: Limiter,
|
limiter: Limiter,
|
||||||
|
http_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -108,6 +109,10 @@ impl state::State for GraphQLState {
|
|||||||
&self.limiter
|
&self.limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn http_client(&self) -> &reqwest::Client {
|
||||||
|
&self.http_client
|
||||||
|
}
|
||||||
|
|
||||||
fn clock(&self) -> BoxClock {
|
fn clock(&self) -> BoxClock {
|
||||||
let clock = SystemClock::default();
|
let clock = SystemClock::default();
|
||||||
Box::new(clock)
|
Box::new(clock)
|
||||||
@@ -131,6 +136,7 @@ pub fn schema(
|
|||||||
password_manager: PasswordManager,
|
password_manager: PasswordManager,
|
||||||
url_builder: UrlBuilder,
|
url_builder: UrlBuilder,
|
||||||
limiter: Limiter,
|
limiter: Limiter,
|
||||||
|
http_client: reqwest::Client,
|
||||||
) -> Schema {
|
) -> Schema {
|
||||||
let state = GraphQLState {
|
let state = GraphQLState {
|
||||||
repository_factory,
|
repository_factory,
|
||||||
@@ -140,6 +146,7 @@ pub fn schema(
|
|||||||
password_manager,
|
password_manager,
|
||||||
url_builder,
|
url_builder,
|
||||||
limiter,
|
limiter,
|
||||||
|
http_client,
|
||||||
};
|
};
|
||||||
let state: BoxState = Box::new(state);
|
let state: BoxState = Box::new(state);
|
||||||
|
|
||||||
|
|||||||
@@ -440,14 +440,7 @@ impl DeactivateUserPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn valid_username_character(c: char) -> bool {
|
fn valid_username_character(c: char) -> bool {
|
||||||
c.is_ascii_lowercase()
|
c.is_ascii_lowercase() || c.is_ascii_digit()
|
||||||
|| c.is_ascii_digit()
|
|
||||||
|| c == '='
|
|
||||||
|| c == '_'
|
|
||||||
|| c == '-'
|
|
||||||
|| c == '.'
|
|
||||||
|| c == '/'
|
|
||||||
|| c == '+'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: this should probably be moved somewhere else
|
// XXX: this should probably be moved somewhere else
|
||||||
@@ -456,12 +449,7 @@ fn username_valid(username: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not start with an underscore
|
// Should only contain lowercase ASCII letters and digits
|
||||||
if username.starts_with('_') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only contain valid characters
|
|
||||||
if !username.chars().all(valid_username_character) {
|
if !username.chars().all(valid_username_character) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -497,6 +485,29 @@ impl UserMutations {
|
|||||||
return Ok(AddUserPayload::Invalid);
|
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
|
// Ask the homeserver if the username is available
|
||||||
let homeserver_available = state
|
let homeserver_available = state
|
||||||
.homeserver_connection()
|
.homeserver_connection()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub trait State {
|
|||||||
fn site_config(&self) -> &SiteConfig;
|
fn site_config(&self) -> &SiteConfig;
|
||||||
fn url_builder(&self) -> &UrlBuilder;
|
fn url_builder(&self) -> &UrlBuilder;
|
||||||
fn limiter(&self) -> &Limiter;
|
fn limiter(&self) -> &Limiter;
|
||||||
|
fn http_client(&self) -> &reqwest::Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type BoxState = Box<dyn State + Send + Sync + 'static>;
|
pub type BoxState = Box<dyn State + Send + Sync + 'static>;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ mod activity_tracker;
|
|||||||
mod captcha;
|
mod captcha;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod cleanup_tests;
|
mod cleanup_tests;
|
||||||
|
pub(crate) mod postnumber;
|
||||||
mod preferred_language;
|
mod preferred_language;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod session;
|
mod session;
|
||||||
@@ -395,7 +396,7 @@ where
|
|||||||
.route(mas_router::Logout::route(), post(self::views::logout::post))
|
.route(mas_router::Logout::route(), post(self::views::logout::post))
|
||||||
.route(
|
.route(
|
||||||
mas_router::Register::route(),
|
mas_router::Register::route(),
|
||||||
get(self::views::register::get),
|
get(self::views::register::get).post(self::views::register::post),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
mas_router::PasswordRegister::route(),
|
mas_router::PasswordRegister::route(),
|
||||||
|
|||||||
564
crates/handlers/src/postnumber.rs
Normal file
564
crates/handlers/src/postnumber.rs
Normal file
@@ -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<Counter<u64>> = 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<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub matched_categories: Vec<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub pattern_family: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<Option<PostnumberOutcome>, 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::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_check(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
endpoint: &Url,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<PostnumberOutcome, Error> {
|
||||||
|
// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,13 @@ pub(crate) fn setup() {
|
|||||||
.try_init();
|
.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(
|
pub(crate) async fn policy_factory(
|
||||||
server_name: &str,
|
server_name: &str,
|
||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
@@ -150,6 +157,7 @@ pub fn test_site_config() -> SiteConfig {
|
|||||||
login_with_email_allowed: true,
|
login_with_email_allowed: true,
|
||||||
plan_management_iframe_uri: None,
|
plan_management_iframe_uri: None,
|
||||||
session_limit: None,
|
session_limit: None,
|
||||||
|
postnumber_validation: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +191,7 @@ impl TestState {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let http_client = mas_http::reqwest_client();
|
let http_client = test_http_client();
|
||||||
|
|
||||||
// TODO: add more test keys to the store
|
// TODO: add more test keys to the store
|
||||||
let rsa =
|
let rsa =
|
||||||
@@ -228,6 +236,7 @@ impl TestState {
|
|||||||
password_manager: password_manager.clone(),
|
password_manager: password_manager.clone(),
|
||||||
url_builder: url_builder.clone(),
|
url_builder: url_builder.clone(),
|
||||||
limiter: limiter.clone(),
|
limiter: limiter.clone(),
|
||||||
|
http_client: http_client.clone(),
|
||||||
};
|
};
|
||||||
let state: crate::graphql::BoxState = Box::new(graphql_state);
|
let state: crate::graphql::BoxState = Box::new(graphql_state);
|
||||||
|
|
||||||
@@ -441,6 +450,7 @@ struct TestGraphQLState {
|
|||||||
password_manager: PasswordManager,
|
password_manager: PasswordManager,
|
||||||
url_builder: UrlBuilder,
|
url_builder: UrlBuilder,
|
||||||
limiter: Limiter,
|
limiter: Limiter,
|
||||||
|
http_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[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");
|
let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG");
|
||||||
Box::new(rng)
|
Box::new(rng)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn http_client(&self) -> &reqwest::Client {
|
||||||
|
&self.http_client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRef<TestState> for PgPool {
|
impl FromRef<TestState> for PgPool {
|
||||||
|
|||||||
@@ -314,13 +314,13 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_utils::setup;
|
use crate::test_utils::{setup, test_http_client};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_metadata_cache() {
|
async fn test_metadata_cache() {
|
||||||
setup();
|
setup();
|
||||||
let mock_server = MockServer::start().await;
|
let mock_server = MockServer::start().await;
|
||||||
let http_client = mas_http::reqwest_client();
|
let http_client = test_http_client();
|
||||||
|
|
||||||
let cache = MetadataCache::new();
|
let cache = MetadataCache::new();
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ mod tests {
|
|||||||
setup();
|
setup();
|
||||||
|
|
||||||
let mock_server = MockServer::start().await;
|
let mock_server = MockServer::start().await;
|
||||||
let http_client = mas_http::reqwest_client();
|
let http_client = test_http_client();
|
||||||
|
|
||||||
let expected_calls = 2;
|
let expected_calls = 2;
|
||||||
let mut calls = 0;
|
let mut calls = 0;
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ pub(crate) async fn get(
|
|||||||
State(templates): State<Templates>,
|
State(templates): State<Templates>,
|
||||||
State(url_builder): State<UrlBuilder>,
|
State(url_builder): State<UrlBuilder>,
|
||||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||||
|
State(site_config): State<SiteConfig>,
|
||||||
|
State(http_client): State<reqwest::Client>,
|
||||||
cookie_jar: CookieJar,
|
cookie_jar: CookieJar,
|
||||||
activity_tracker: BoundActivityTracker,
|
activity_tracker: BoundActivityTracker,
|
||||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||||
@@ -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
|
// 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
|
// that it's plain invalid (although that should have been caught by the
|
||||||
// policy), or just reserved by an application service
|
// policy), or just reserved by an application service
|
||||||
@@ -857,6 +901,7 @@ pub(crate) async fn post(
|
|||||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||||
State(url_builder): State<UrlBuilder>,
|
State(url_builder): State<UrlBuilder>,
|
||||||
State(site_config): State<SiteConfig>,
|
State(site_config): State<SiteConfig>,
|
||||||
|
State(http_client): State<reqwest::Client>,
|
||||||
Path(link_id): Path<Ulid>,
|
Path(link_id): Path<Ulid>,
|
||||||
Form(form): Form<ProtectedForm<FormData>>,
|
Form(form): Form<ProtectedForm<FormData>>,
|
||||||
) -> Result<Response, RouteError> {
|
) -> Result<Response, RouteError> {
|
||||||
@@ -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
|
form_state
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,28 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
|
||||||
|
use std::{net::IpAddr, sync::Arc};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Form, State},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Query;
|
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_data_model::{BoxClock, BoxRng, SiteConfig};
|
||||||
|
use mas_matrix::HomeserverConnection;
|
||||||
|
use mas_policy::Policy;
|
||||||
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};
|
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};
|
||||||
use mas_storage::BoxRepository;
|
use mas_storage::{BoxRepository, RepositoryAccess};
|
||||||
use mas_templates::{RegisterContext, TemplateContext, Templates};
|
use mas_templates::{
|
||||||
|
FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext,
|
||||||
|
Templates, ToFormState,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::shared::OptionalPostAuthAction;
|
use super::shared::OptionalPostAuthAction;
|
||||||
use crate::{BoundActivityTracker, PreferredLanguage};
|
use crate::{BoundActivityTracker, PreferredLanguage};
|
||||||
@@ -23,6 +35,125 @@ pub(crate) mod steps;
|
|||||||
|
|
||||||
pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie;
|
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<RegisterFormField>,
|
||||||
|
username: &str,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
ip_address: Option<IpAddr>,
|
||||||
|
repo: &mut BoxRepository,
|
||||||
|
policy: &mut Policy,
|
||||||
|
homeserver: &Arc<dyn HomeserverConnection>,
|
||||||
|
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
|
||||||
|
.map_err(InternalError::from_anyhow)?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
mut repo: &mut BoxRepository,
|
||||||
|
templates: &Templates,
|
||||||
|
) -> Result<String, InternalError> {
|
||||||
|
let post_action = action
|
||||||
|
.load_context(&mut 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_language(locale))?)
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "handlers.views.register.get", skip_all)]
|
#[tracing::instrument(name = "handlers.views.register.get", skip_all)]
|
||||||
pub(crate) async fn get(
|
pub(crate) async fn get(
|
||||||
mut rng: BoxRng,
|
mut rng: BoxRng,
|
||||||
@@ -78,18 +209,81 @@ pub(crate) async fn get(
|
|||||||
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
|
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ctx = RegisterContext::new(providers);
|
let content = render(
|
||||||
let post_action = query
|
locale,
|
||||||
.load_context(&mut repo)
|
RegisterContext::new(providers).with_csrf(csrf_token.form_value()).inner,
|
||||||
.await
|
query,
|
||||||
.map_err(InternalError::from_anyhow)?;
|
&mut repo,
|
||||||
if let Some(action) = post_action {
|
&templates,
|
||||||
ctx = ctx.with_post_action(action);
|
)
|
||||||
}
|
.await?;
|
||||||
|
|
||||||
let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale);
|
|
||||||
|
|
||||||
let content = templates.render_register(&ctx)?;
|
|
||||||
|
|
||||||
Ok((cookie_jar, Html(content)).into_response())
|
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<Templates>,
|
||||||
|
State(url_builder): State<UrlBuilder>,
|
||||||
|
State(site_config): State<SiteConfig>,
|
||||||
|
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||||
|
State(http_client): State<reqwest::Client>,
|
||||||
|
mut policy: Policy,
|
||||||
|
mut repo: BoxRepository,
|
||||||
|
activity_tracker: BoundActivityTracker,
|
||||||
|
user_agent: Option<axum_extra::typed_header::TypedHeader<headers::UserAgent>>,
|
||||||
|
cookie_jar: CookieJar,
|
||||||
|
Form(form): Form<ProtectedForm<UsernameForm>>,
|
||||||
|
) -> Result<Response, InternalError> {
|
||||||
|
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)
|
||||||
|
.with_csrf(csrf_token.form_value())
|
||||||
|
.inner,
|
||||||
|
form.action,
|
||||||
|
&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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,27 +181,18 @@ pub(crate) async fn post(
|
|||||||
state.add_error_on_form(FormError::Captcha);
|
state.add_error_on_form(FormError::Captcha);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut homeserver_denied_username = false;
|
super::validate_registration_username(
|
||||||
if form.username.is_empty() {
|
&mut state,
|
||||||
state.add_error_on_field(RegisterFormField::Username, FieldError::Required);
|
&form.username,
|
||||||
} else if repo.user().exists(&form.username).await? {
|
user_agent.clone(),
|
||||||
// The user already exists in the database
|
activity_tracker.ip(),
|
||||||
state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
|
&mut repo,
|
||||||
} else if !homeserver
|
&mut policy,
|
||||||
.is_localpart_available(&form.username)
|
&homeserver,
|
||||||
.await
|
&http_client,
|
||||||
.map_err(InternalError::from_anyhow)?
|
&site_config,
|
||||||
{
|
)
|
||||||
// The user already exists on the homeserver
|
.await?;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(email) = &email {
|
if let Some(email) = &email {
|
||||||
// Note that we don't check here if the email is already taken here, as
|
// 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,
|
message: violation.msg,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Some("username") => {
|
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("password") => state.add_error_on_field(
|
Some("password") => state.add_error_on_field(
|
||||||
RegisterFormField::Password,
|
RegisterFormField::Password,
|
||||||
FieldError::Policy {
|
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() {
|
if state.is_valid() {
|
||||||
// Check the rate limit if we are about to process the form
|
// Check the rate limit if we are about to process the form
|
||||||
if let Err(e) = limiter.check_registration(requester) {
|
if let Err(e) = limiter.check_registration(requester) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ pub(crate) async fn get(
|
|||||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||||
State(url_builder): State<UrlBuilder>,
|
State(url_builder): State<UrlBuilder>,
|
||||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||||
|
State(http_client): State<reqwest::Client>,
|
||||||
State(templates): State<Templates>,
|
State(templates): State<Templates>,
|
||||||
State(site_config): State<SiteConfig>,
|
State(site_config): State<SiteConfig>,
|
||||||
PreferredLanguage(lang): PreferredLanguage,
|
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
|
// Check if the registration token is required and was provided
|
||||||
let registration_token = if site_config.registration_token_required {
|
let registration_token = if site_config.registration_token_required {
|
||||||
if let Some(registration_token_id) = registration.user_registration_token_id {
|
if let Some(registration_token_id) = registration.user_registration_token_id {
|
||||||
|
|||||||
@@ -42,10 +42,17 @@ fn now() -> DateTime<Utc> {
|
|||||||
Utc::now()
|
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) {
|
async fn init_test() -> (reqwest::Client, MockServer, Url) {
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
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 mock_server = MockServer::start().await;
|
||||||
let issuer = Url::parse(&mock_server.uri()).expect("Couldn't parse URL");
|
let issuer = Url::parse(&mock_server.uri()).expect("Couldn't parse URL");
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,12 @@ pub struct PasswordRegister {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PasswordRegister {
|
impl PasswordRegister {
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_username(mut self, username: impl Into<String>) -> Self {
|
||||||
|
self.username = Some(username.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn and_then(mut self, action: PostAuthAction) -> Self {
|
pub fn and_then(mut self, action: PostAuthAction) -> Self {
|
||||||
self.post_auth_action = Some(action);
|
self.post_auth_action = Some(action);
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ impl FormField for RegisterFormField {
|
|||||||
/// Context used by the `register.html` template
|
/// Context used by the `register.html` template
|
||||||
#[derive(Serialize, Default)]
|
#[derive(Serialize, Default)]
|
||||||
pub struct RegisterContext {
|
pub struct RegisterContext {
|
||||||
|
form: FormState<RegisterFormField>,
|
||||||
providers: Vec<UpstreamOAuthProvider>,
|
providers: Vec<UpstreamOAuthProvider>,
|
||||||
next: Option<PostAuthContext>,
|
next: Option<PostAuthContext>,
|
||||||
}
|
}
|
||||||
@@ -661,6 +662,7 @@ impl TemplateContext for RegisterContext {
|
|||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
sample_list(vec![RegisterContext {
|
sample_list(vec![RegisterContext {
|
||||||
|
form: FormState::default(),
|
||||||
providers: Vec::new(),
|
providers: Vec::new(),
|
||||||
next: None,
|
next: None,
|
||||||
}])
|
}])
|
||||||
@@ -672,11 +674,18 @@ impl RegisterContext {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(providers: Vec<UpstreamOAuthProvider>) -> Self {
|
pub fn new(providers: Vec<UpstreamOAuthProvider>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
form: FormState::default(),
|
||||||
providers,
|
providers,
|
||||||
next: None,
|
next: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add an error on the registration form
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_form_state(self, form: FormState<RegisterFormField>) -> Self {
|
||||||
|
Self { form, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a post authentication action to the context
|
/// Add a post authentication action to the context
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_post_action(self, next: PostAuthContext) -> Self {
|
pub fn with_post_action(self, next: PostAuthContext) -> Self {
|
||||||
|
|||||||
@@ -358,6 +358,25 @@ account:
|
|||||||
# When enabled, users must provide a valid registration token during password
|
# When enabled, users must provide a valid registration token during password
|
||||||
# registration. This has no effect if password registration is disabled.
|
# registration. This has no effect if password registration is disabled.
|
||||||
registration_token_required: false
|
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`
|
## `captcha`
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// Ideally later on we could find a way to hydrate full React components instead
|
// 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.
|
// 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 */
|
/** Grab the nearest error message inserted by the templates by error kind and code */
|
||||||
function grabErrorMessage(
|
function grabErrorMessage(
|
||||||
|
|||||||
@@ -117,22 +117,22 @@ const AccountSessionsBrowsersRoute = AccountSessionsBrowsersRouteImport.update({
|
|||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AccountIndexRoute
|
|
||||||
'/reset-cross-signing': typeof ResetCrossSigningRouteWithChildren
|
'/reset-cross-signing': typeof ResetCrossSigningRouteWithChildren
|
||||||
'/clients/$id': typeof ClientsIdRoute
|
'/clients/$id': typeof ClientsIdRoute
|
||||||
'/devices/$': typeof DevicesSplatRoute
|
'/devices/$': typeof DevicesSplatRoute
|
||||||
'/reset-cross-signing/cancelled': typeof ResetCrossSigningCancelledRoute
|
'/reset-cross-signing/cancelled': typeof ResetCrossSigningCancelledRoute
|
||||||
'/reset-cross-signing/success': typeof ResetCrossSigningSuccessRoute
|
'/reset-cross-signing/success': typeof ResetCrossSigningSuccessRoute
|
||||||
'/sessions/$id': typeof SessionsIdRoute
|
'/sessions/$id': typeof SessionsIdRoute
|
||||||
|
'/': typeof AccountIndexRoute
|
||||||
'/reset-cross-signing/': typeof ResetCrossSigningIndexRoute
|
'/reset-cross-signing/': typeof ResetCrossSigningIndexRoute
|
||||||
'/sessions/browsers': typeof AccountSessionsBrowsersRoute
|
'/sessions/browsers': typeof AccountSessionsBrowsersRoute
|
||||||
'/emails/$id/in-use': typeof EmailsIdInUseRoute
|
'/emails/$id/in-use': typeof EmailsIdInUseRoute
|
||||||
'/emails/$id/verify': typeof EmailsIdVerifyRoute
|
'/emails/$id/verify': typeof EmailsIdVerifyRoute
|
||||||
'/password/change/success': typeof PasswordChangeSuccessRoute
|
'/password/change/success': typeof PasswordChangeSuccessRoute
|
||||||
'/plan/': typeof AccountPlanIndexRoute
|
'/plan': typeof AccountPlanIndexRoute
|
||||||
'/sessions/': typeof AccountSessionsIndexRoute
|
'/sessions': typeof AccountSessionsIndexRoute
|
||||||
'/password/change/': typeof PasswordChangeIndexRoute
|
'/password/change': typeof PasswordChangeIndexRoute
|
||||||
'/password/recovery/': typeof PasswordRecoveryIndexRoute
|
'/password/recovery': typeof PasswordRecoveryIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/clients/$id': typeof ClientsIdRoute
|
'/clients/$id': typeof ClientsIdRoute
|
||||||
@@ -174,22 +174,22 @@ export interface FileRoutesById {
|
|||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
|
||||||
| '/reset-cross-signing'
|
| '/reset-cross-signing'
|
||||||
| '/clients/$id'
|
| '/clients/$id'
|
||||||
| '/devices/$'
|
| '/devices/$'
|
||||||
| '/reset-cross-signing/cancelled'
|
| '/reset-cross-signing/cancelled'
|
||||||
| '/reset-cross-signing/success'
|
| '/reset-cross-signing/success'
|
||||||
| '/sessions/$id'
|
| '/sessions/$id'
|
||||||
|
| '/'
|
||||||
| '/reset-cross-signing/'
|
| '/reset-cross-signing/'
|
||||||
| '/sessions/browsers'
|
| '/sessions/browsers'
|
||||||
| '/emails/$id/in-use'
|
| '/emails/$id/in-use'
|
||||||
| '/emails/$id/verify'
|
| '/emails/$id/verify'
|
||||||
| '/password/change/success'
|
| '/password/change/success'
|
||||||
| '/plan/'
|
| '/plan'
|
||||||
| '/sessions/'
|
| '/sessions'
|
||||||
| '/password/change/'
|
| '/password/change'
|
||||||
| '/password/recovery/'
|
| '/password/recovery'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/clients/$id'
|
| '/clients/$id'
|
||||||
@@ -253,7 +253,7 @@ declare module '@tanstack/react-router' {
|
|||||||
'/_account': {
|
'/_account': {
|
||||||
id: '/_account'
|
id: '/_account'
|
||||||
path: ''
|
path: ''
|
||||||
fullPath: '/'
|
fullPath: ''
|
||||||
preLoaderRoute: typeof AccountRouteImport
|
preLoaderRoute: typeof AccountRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
@@ -309,28 +309,28 @@ declare module '@tanstack/react-router' {
|
|||||||
'/password/recovery/': {
|
'/password/recovery/': {
|
||||||
id: '/password/recovery/'
|
id: '/password/recovery/'
|
||||||
path: '/password/recovery'
|
path: '/password/recovery'
|
||||||
fullPath: '/password/recovery/'
|
fullPath: '/password/recovery'
|
||||||
preLoaderRoute: typeof PasswordRecoveryIndexRouteImport
|
preLoaderRoute: typeof PasswordRecoveryIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/password/change/': {
|
'/password/change/': {
|
||||||
id: '/password/change/'
|
id: '/password/change/'
|
||||||
path: '/password/change'
|
path: '/password/change'
|
||||||
fullPath: '/password/change/'
|
fullPath: '/password/change'
|
||||||
preLoaderRoute: typeof PasswordChangeIndexRouteImport
|
preLoaderRoute: typeof PasswordChangeIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_account/sessions/': {
|
'/_account/sessions/': {
|
||||||
id: '/_account/sessions/'
|
id: '/_account/sessions/'
|
||||||
path: '/sessions'
|
path: '/sessions'
|
||||||
fullPath: '/sessions/'
|
fullPath: '/sessions'
|
||||||
preLoaderRoute: typeof AccountSessionsIndexRouteImport
|
preLoaderRoute: typeof AccountSessionsIndexRouteImport
|
||||||
parentRoute: typeof AccountRoute
|
parentRoute: typeof AccountRoute
|
||||||
}
|
}
|
||||||
'/_account/plan/': {
|
'/_account/plan/': {
|
||||||
id: '/_account/plan/'
|
id: '/_account/plan/'
|
||||||
path: '/plan'
|
path: '/plan'
|
||||||
fullPath: '/plan/'
|
fullPath: '/plan'
|
||||||
preLoaderRoute: typeof AccountPlanIndexRouteImport
|
preLoaderRoute: typeof AccountPlanIndexRouteImport
|
||||||
parentRoute: typeof AccountRoute
|
parentRoute: typeof AccountRoute
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ violation contains {
|
|||||||
"field": "username", "code": "username-invalid-chars",
|
"field": "username", "code": "username-invalid-chars",
|
||||||
"msg": "username contains invalid characters",
|
"msg": "username contains invalid characters",
|
||||||
} if {
|
} if {
|
||||||
not regex.match(`^[a-z0-9.=_/+-]+$`, input.username)
|
not regex.match(`^[a-z0-9]+$`, input.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
violation contains {
|
violation contains {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
{{ _("mas.errors.username_banned") }}
|
{{ _("mas.errors.username_banned") }}
|
||||||
{% elif error.code == "username-not-allowed" %}
|
{% elif error.code == "username-not-allowed" %}
|
||||||
{{ _("mas.errors.username_not_allowed") }}
|
{{ _("mas.errors.username_not_allowed") }}
|
||||||
|
{% elif error.code == "postnumber-reserved" %}
|
||||||
|
{{ _("mas.errors.postnumber_reserved") }}
|
||||||
{% elif error.code == "email-domain-not-allowed" %}
|
{% elif error.code == "email-domain-not-allowed" %}
|
||||||
{{ _("mas.errors.email_domain_not_allowed") }}
|
{{ _("mas.errors.email_domain_not_allowed") }}
|
||||||
{% elif error.code == "email-domain-banned" %}
|
{% elif error.code == "email-domain-banned" %}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
{% from "components/idp_brand.html" import logo %}
|
{% from "components/idp_brand.html" import logo %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="GET" class="flex flex-col gap-10" action="{{ '/register/password' | prefix_url }}">
|
<form method="POST" class="flex flex-col gap-10" action="{{ '/register' | prefix_url }}">
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
{{ brand_logo.letro_logo() }}
|
{{ brand_logo.letro_logo() }}
|
||||||
@@ -28,8 +28,12 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if features.password_registration %}
|
{% if features.password_registration %}
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||||
|
|
||||||
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
|
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
|
||||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" data-choose-username />
|
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" data-choose-username />
|
||||||
|
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
|
||||||
|
{{ field.error(error={"kind": "policy", "code": "postnumber-reserved"}, hidden=true) }}
|
||||||
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
||||||
@username:{{ branding.server_name }}
|
@username:{{ branding.server_name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,6 +41,12 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="cpd-form-root">
|
<div class="cpd-form-root">
|
||||||
|
{% for error in form.errors %}
|
||||||
|
<div class="text-critical font-medium">
|
||||||
|
{{ errors.form_error_message(error=error) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% for key, value in next["params"] | default({}) | items %}
|
{% for key, value in next["params"] | default({}) | items %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -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) %}
|
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
|
||||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required data-choose-username />
|
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required data-choose-username />
|
||||||
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
|
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
|
||||||
|
{{ field.error(error={"kind": "policy", "code": "postnumber-reserved"}, hidden=true) }}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% if features.password_registration_email_required %}
|
{% if features.password_registration_email_required %}
|
||||||
|
|||||||
@@ -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) %}
|
{% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %}
|
||||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" value="{{ imported_localpart or '' }}" aria-describedby="{{ f.id }}-help" data-choose-username />
|
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" value="{{ imported_localpart or '' }}" aria-describedby="{{ f.id }}-help" data-choose-username />
|
||||||
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
|
{{ 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 %}
|
{% if f.errors is empty %}
|
||||||
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
||||||
|
|||||||
@@ -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.",
|
"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_all_numeric": "PostNumber se nemůže skládat pouze z čísel",
|
||||||
"username_banned": "PostNumber je zakázáno zásadami serveru",
|
"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",
|
"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_taken": "Toto PostNumber je již obsazeno",
|
||||||
"username_too_long": "PostNumber je příliš dlouhé",
|
"username_too_long": "PostNumber je příliš dlouhé",
|
||||||
"username_too_short": "PostNumber je příliš krátké"
|
"username_too_short": "PostNumber je příliš krátké"
|
||||||
|
|||||||
@@ -139,8 +139,9 @@
|
|||||||
"rate_limit_exceeded": "Du har indsendt for mange anmodninger på kort tid. Vent et par minutter, og prøv igen.",
|
"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_all_numeric": "PostNumber kan ikke udelukkende bestå af tal",
|
||||||
"username_banned": "PostNumber er forbudt af serverpolitikken",
|
"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",
|
"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_taken": "Dette PostNumber er allerede taget",
|
||||||
"username_too_long": "PostNumber er for langt",
|
"username_too_long": "PostNumber er for langt",
|
||||||
"username_too_short": "PostNumber er for kort"
|
"username_too_short": "PostNumber er for kort"
|
||||||
|
|||||||
@@ -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.",
|
"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_all_numeric": "PostNumber darf nicht nur aus Zahlen bestehen",
|
||||||
"username_banned": "PostNumber ist durch die Serverrichtlinie gesperrt",
|
"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",
|
"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_taken": "Dieser PostNumber ist bereits vergeben",
|
||||||
"username_too_long": "PostNumber ist zu lang",
|
"username_too_long": "PostNumber ist zu lang",
|
||||||
"username_too_short": "PostNumber ist zu kurz"
|
"username_too_short": "PostNumber ist zu kurz"
|
||||||
|
|||||||
@@ -388,7 +388,7 @@
|
|||||||
"context": "components/field.html:40:11-42",
|
"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."
|
"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": {
|
"@username_invalid_chars": {
|
||||||
"context": "components/field.html:36:11-49"
|
"context": "components/field.html:36:11-49"
|
||||||
},
|
},
|
||||||
@@ -397,6 +397,11 @@
|
|||||||
"context": "components/field.html:42:11-47",
|
"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."
|
"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": "This postNumber is already taken",
|
||||||
"@username_taken": {
|
"@username_taken": {
|
||||||
"context": "components/field.html:29:9-39"
|
"context": "components/field.html:29:9-39"
|
||||||
|
|||||||
@@ -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.",
|
"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_all_numeric": "PostNumber ei tohi koosneda vaid numbritest",
|
||||||
"username_banned": "PostNumber on serverireeglite alusel keelatud",
|
"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",
|
"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_taken": "Selline PostNumber on juba olemas",
|
||||||
"username_too_long": "PostNumber on liiga pikk",
|
"username_too_long": "PostNumber on liiga pikk",
|
||||||
"username_too_short": "PostNumber on liiga lühike"
|
"username_too_short": "PostNumber on liiga lühike"
|
||||||
|
|||||||
@@ -144,8 +144,9 @@
|
|||||||
"rate_limit_exceeded": "Olet tehnyt liian monta pyyntöä lyhyessä ajassa. Odota muutama minuutti ja yritä uudelleen.",
|
"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_all_numeric": "PostNumber ei voi koostua pelkästään numeroista",
|
||||||
"username_banned": "Palvelinkäytäntö kieltää tämän PostNumber-tunnuksen",
|
"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",
|
"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_taken": "Tämä PostNumber on jo käytössä.",
|
||||||
"username_too_long": "PostNumber on liian pitkä",
|
"username_too_long": "PostNumber on liian pitkä",
|
||||||
"username_too_short": "PostNumber on liian lyhyt"
|
"username_too_short": "PostNumber on liian lyhyt"
|
||||||
|
|||||||
@@ -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.",
|
"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_all_numeric": "PostNumber ne peut pas être composé uniquement de chiffres",
|
||||||
"username_banned": "Ce PostNumber est interdit par la politique du serveur",
|
"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",
|
"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_taken": "Ce PostNumber est déjà utilisé",
|
||||||
"username_too_long": "PostNumber est trop long",
|
"username_too_long": "PostNumber est trop long",
|
||||||
"username_too_short": "PostNumber est trop court"
|
"username_too_short": "PostNumber est trop court"
|
||||||
|
|||||||
@@ -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.",
|
"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_all_numeric": "PostNumber nem állhat pusztán számokból",
|
||||||
"username_banned": "A PostNumber-t a kiszolgáló-házirend tiltja",
|
"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",
|
"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_taken": "PostNumber már foglalt",
|
||||||
"username_too_long": "PostNumber túl hosszú",
|
"username_too_long": "PostNumber túl hosszú",
|
||||||
"username_too_short": "PostNumber túl rövid"
|
"username_too_short": "PostNumber túl rövid"
|
||||||
|
|||||||
@@ -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.",
|
"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_all_numeric": "PostNumber kan ikke bare bestå av tall",
|
||||||
"username_banned": "PostNumber er utestengt av serverpolicyen",
|
"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",
|
"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_taken": "Dette PostNumber er allerede tatt",
|
||||||
"username_too_long": "PostNumber er for langt",
|
"username_too_long": "PostNumber er for langt",
|
||||||
"username_too_short": "PostNumber er for kort"
|
"username_too_short": "PostNumber er for kort"
|
||||||
|
|||||||
@@ -144,8 +144,9 @@
|
|||||||
"rate_limit_exceeded": "W krótkim czasie wysłałeś zbyt wiele żądań. Poczekaj kilka minut i spróbuj ponownie.",
|
"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_all_numeric": "PostNumber nie może składać się wyłącznie z cyfr",
|
||||||
"username_banned": "PostNumber jest zablokowany przez politykę serwera",
|
"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",
|
"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_taken": "Ten PostNumber jest już zajęty",
|
||||||
"username_too_long": "PostNumber jest za długi",
|
"username_too_long": "PostNumber jest za długi",
|
||||||
"username_too_short": "PostNumber jest za krótki"
|
"username_too_short": "PostNumber jest za krótki"
|
||||||
|
|||||||
@@ -139,8 +139,9 @@
|
|||||||
"rate_limit_exceeded": "Efetuou demasiadas solicitações num curto espaço de tempo. Aguarde alguns minutos e tente novamente.",
|
"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_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_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",
|
"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_taken": "Este PostNumber já foi utilizado",
|
||||||
"username_too_long": "O PostNumber é demasiado longo",
|
"username_too_long": "O PostNumber é demasiado longo",
|
||||||
"username_too_short": "O PostNumber é demasiado curto"
|
"username_too_short": "O PostNumber é demasiado curto"
|
||||||
|
|||||||
@@ -144,8 +144,9 @@
|
|||||||
"rate_limit_exceeded": "Вы делаете запросы слишком часто. Пожалуйста, подождите несколько минут и повторите попытку.",
|
"rate_limit_exceeded": "Вы делаете запросы слишком часто. Пожалуйста, подождите несколько минут и повторите попытку.",
|
||||||
"username_all_numeric": "ПостНамбер не может состоять только из цифр",
|
"username_all_numeric": "ПостНамбер не может состоять только из цифр",
|
||||||
"username_banned": "ПостНамбер запрещён политикой сервера",
|
"username_banned": "ПостНамбер запрещён политикой сервера",
|
||||||
"username_invalid_chars": "ПостНамбер содержит недопустимые символы. Используйте только строчные буквы латиницы, цифры, тире и символы подчеркивания.",
|
"username_invalid_chars": "ПостНамбер содержит недопустимые символы. Используйте только строчные буквы латиницы и цифры.",
|
||||||
"username_not_allowed": "ПостНамбер не разрешён политикой сервера",
|
"username_not_allowed": "ПостНамбер не разрешён политикой сервера",
|
||||||
|
"postnumber_reserved": "Этот ПостНамбер зарезервирован и не может быть использован для регистрации",
|
||||||
"username_taken": "Этот ПостНамбер уже занят",
|
"username_taken": "Этот ПостНамбер уже занят",
|
||||||
"username_too_long": "ПостНамбер слишком длинный",
|
"username_too_long": "ПостНамбер слишком длинный",
|
||||||
"username_too_short": "ПостНамбер слишком короткий"
|
"username_too_short": "ПостНамбер слишком короткий"
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
"password_mismatch": "Lösenordsfälten matchar inte",
|
"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.",
|
"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_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_taken": "Detta PostNumber är redan upptaget",
|
||||||
"username_too_long": "PostNumber är för långt",
|
"username_too_long": "PostNumber är för långt",
|
||||||
"username_too_short": "PostNumber är för kort"
|
"username_too_short": "PostNumber är för kort"
|
||||||
|
|||||||
@@ -144,8 +144,9 @@
|
|||||||
"rate_limit_exceeded": "Ви зробили забагато запитів за короткий проміжок часу. Зачекайте кілька хвилин і повторіть спробу.",
|
"rate_limit_exceeded": "Ви зробили забагато запитів за короткий проміжок часу. Зачекайте кілька хвилин і повторіть спробу.",
|
||||||
"username_all_numeric": "ПостНамбер не може складатися тільки з цифр",
|
"username_all_numeric": "ПостНамбер не може складатися тільки з цифр",
|
||||||
"username_banned": "ПостНамбер заборонений політикою сервера",
|
"username_banned": "ПостНамбер заборонений політикою сервера",
|
||||||
"username_invalid_chars": "ПостНамбер містить неприпустимі символи. Використовуйте лише малі букви, цифри, тире та підкреслення.",
|
"username_invalid_chars": "ПостНамбер містить неприпустимі символи. Використовуйте лише малі букви та цифри.",
|
||||||
"username_not_allowed": "ПостНамбер не дозволений політикою сервера",
|
"username_not_allowed": "ПостНамбер не дозволений політикою сервера",
|
||||||
|
"postnumber_reserved": "Цей ПостНамбер зарезервований і не може бути використаний для реєстрації",
|
||||||
"username_taken": "Цей ПостНамбер вже зайнятий",
|
"username_taken": "Цей ПостНамбер вже зайнятий",
|
||||||
"username_too_long": "ПостНамбер задовгий",
|
"username_too_long": "ПостНамбер задовгий",
|
||||||
"username_too_short": "ПостНамбер закороткий"
|
"username_too_short": "ПостНамбер закороткий"
|
||||||
|
|||||||
@@ -144,8 +144,9 @@
|
|||||||
"rate_limit_exceeded": "你在短时间内发出了过多请求。请于几分钟后重试。",
|
"rate_limit_exceeded": "你在短时间内发出了过多请求。请于几分钟后重试。",
|
||||||
"username_all_numeric": "PostNumber不能仅由数字组成",
|
"username_all_numeric": "PostNumber不能仅由数字组成",
|
||||||
"username_banned": "由于服务器策略,PostNumber已被禁止",
|
"username_banned": "由于服务器策略,PostNumber已被禁止",
|
||||||
"username_invalid_chars": "PostNumber包含无效字符。仅能使用小写字母、数字、短横线或下划线。",
|
"username_invalid_chars": "PostNumber包含无效字符。仅能使用小写字母和数字。",
|
||||||
"username_not_allowed": "由于服务器策略,PostNumber不被允许",
|
"username_not_allowed": "由于服务器策略,PostNumber不被允许",
|
||||||
|
"postnumber_reserved": "此PostNumber已被保留,无法用于注册",
|
||||||
"username_taken": "此PostNumber已被使用",
|
"username_taken": "此PostNumber已被使用",
|
||||||
"username_too_long": "PostNumber太长",
|
"username_too_long": "PostNumber太长",
|
||||||
"username_too_short": "PostNumber太短"
|
"username_too_short": "PostNumber太短"
|
||||||
|
|||||||
Reference in New Issue
Block a user