Integrate postnumber resolver across MAS flows
This commit is contained in:
@@ -231,6 +231,7 @@ impl Options {
|
||||
password_manager.clone(),
|
||||
url_builder.clone(),
|
||||
limiter.clone(),
|
||||
http_client.clone(),
|
||||
);
|
||||
|
||||
let state = {
|
||||
|
||||
@@ -243,6 +243,20 @@ pub fn site_config_from_config(
|
||||
soft_limit: c.soft_limit,
|
||||
hard_limit: c.hard_limit,
|
||||
}),
|
||||
postnumber_validation: account_config.postnumber_validation.as_ref().map(|c| {
|
||||
mas_data_model::PostnumberValidationConfig {
|
||||
endpoint: c.endpoint.clone(),
|
||||
timeout: c.timeout,
|
||||
on_unavailable: match c.on_unavailable {
|
||||
mas_config::PostnumberValidationFailureMode::Open => {
|
||||
mas_data_model::PostnumberValidationFailureMode::Open
|
||||
}
|
||||
mas_config::PostnumberValidationFailureMode::Closed => {
|
||||
mas_data_model::PostnumberValidationFailureMode::Closed
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
use url::Url;
|
||||
|
||||
use crate::ConfigurationSection;
|
||||
|
||||
@@ -27,6 +31,54 @@ const fn is_default_false(value: &bool) -> bool {
|
||||
*value == default_false()
|
||||
}
|
||||
|
||||
fn default_postnumber_timeout() -> Duration {
|
||||
Duration::from_secs(2)
|
||||
}
|
||||
|
||||
fn is_default_postnumber_timeout(value: &Duration) -> bool {
|
||||
*value == default_postnumber_timeout()
|
||||
}
|
||||
|
||||
/// How MAS should behave when the postnumber resolver cannot be reached.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PostnumberValidationFailureMode {
|
||||
/// Allow the registration or admin action to proceed without resolver
|
||||
/// input.
|
||||
Open,
|
||||
/// Reject the registration or admin action when the resolver is
|
||||
/// unavailable.
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl Default for PostnumberValidationFailureMode {
|
||||
fn default() -> Self {
|
||||
Self::Closed
|
||||
}
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
||||
/// Configuration for the external postnumber resolver integration.
|
||||
pub struct PostnumberValidationConfig {
|
||||
/// Resolver endpoint used to validate user-facing postnumbers.
|
||||
pub endpoint: Url,
|
||||
|
||||
/// Per-request timeout when calling the resolver.
|
||||
#[serde_as(as = "serde_with::DurationSeconds<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
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
||||
@@ -88,6 +140,11 @@ pub struct AccountConfig {
|
||||
/// is disabled.
|
||||
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
|
||||
pub registration_token_required: bool,
|
||||
|
||||
/// Optional external resolver for validating user-facing postnumbers in
|
||||
/// registration flows.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub postnumber_validation: Option<PostnumberValidationConfig>,
|
||||
}
|
||||
|
||||
impl Default for AccountConfig {
|
||||
@@ -102,6 +159,7 @@ impl Default for AccountConfig {
|
||||
account_deactivation_allowed: default_true(),
|
||||
login_with_email_allowed: default_false(),
|
||||
registration_token_required: default_false(),
|
||||
postnumber_validation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +175,7 @@ impl AccountConfig {
|
||||
&& is_default_true(&self.account_deactivation_allowed)
|
||||
&& is_default_false(&self.login_with_email_allowed)
|
||||
&& is_default_false(&self.registration_token_required)
|
||||
&& self.postnumber_validation.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ mod templates;
|
||||
mod upstream_oauth2;
|
||||
|
||||
pub use self::{
|
||||
account::AccountConfig,
|
||||
account::{AccountConfig, PostnumberValidationConfig, PostnumberValidationFailureMode},
|
||||
branding::BrandingConfig,
|
||||
captcha::{CaptchaConfig, CaptchaServiceKind},
|
||||
clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig},
|
||||
|
||||
@@ -40,7 +40,8 @@ pub use self::{
|
||||
},
|
||||
policy_data::PolicyData,
|
||||
site_config::{
|
||||
CaptchaConfig, CaptchaService, SessionExpirationConfig, SessionLimitConfig, SiteConfig,
|
||||
CaptchaConfig, CaptchaService, PostnumberValidationConfig, PostnumberValidationFailureMode,
|
||||
SessionExpirationConfig, SessionLimitConfig, SiteConfig,
|
||||
},
|
||||
tokens::{
|
||||
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use std::num::NonZeroU64;
|
||||
use std::{num::NonZeroU64, time::Duration as StdDuration};
|
||||
|
||||
use chrono::Duration;
|
||||
use serde::Serialize;
|
||||
@@ -111,4 +111,27 @@ pub struct SiteConfig {
|
||||
|
||||
/// Limits on the number of application sessions that each user can have
|
||||
pub session_limit: Option<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>,
|
||||
SiteConfig: FromRef<S>,
|
||||
AppVersion: FromRef<S>,
|
||||
reqwest::Client: FromRef<S>,
|
||||
{
|
||||
// We *always* want to explicitly set the possible responses, beacuse the
|
||||
// infered ones are not necessarily correct
|
||||
|
||||
@@ -39,6 +39,7 @@ where
|
||||
SiteConfig: FromRef<S>,
|
||||
AppVersion: FromRef<S>,
|
||||
Arc<PolicyFactory>: FromRef<S>,
|
||||
reqwest::Client: FromRef<S>,
|
||||
BoxRng: FromRequestParts<S>,
|
||||
CallContext: FromRequestParts<S>,
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
SiteConfig,
|
||||
admin::{
|
||||
call_context::CallContext,
|
||||
model::User,
|
||||
@@ -26,14 +27,7 @@ use crate::{
|
||||
};
|
||||
|
||||
fn valid_username_character(c: char) -> bool {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| c == '='
|
||||
|| c == '_'
|
||||
|| c == '-'
|
||||
|| c == '.'
|
||||
|| c == '/'
|
||||
|| c == '+'
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit()
|
||||
}
|
||||
|
||||
// XXX: this should be shared with the graphql handler
|
||||
@@ -42,12 +36,7 @@ fn username_valid(username: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should not start with an underscore
|
||||
if username.starts_with('_') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should only contain valid characters
|
||||
// Should only contain lowercase ASCII letters and digits
|
||||
if !username.chars().all(valid_username_character) {
|
||||
return false;
|
||||
}
|
||||
@@ -72,6 +61,12 @@ pub enum RouteError {
|
||||
|
||||
#[error("Username is reserved by the homeserver")]
|
||||
UsernameReserved,
|
||||
|
||||
#[error("Postnumber is reserved")]
|
||||
PostnumberReserved,
|
||||
|
||||
#[error("Postnumber resolver unavailable")]
|
||||
PostnumberResolverUnavailable,
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
@@ -81,9 +76,13 @@ impl IntoResponse for RouteError {
|
||||
let error = ErrorResponse::from_error(&self);
|
||||
let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_));
|
||||
let status = match self {
|
||||
Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Internal(_) | Self::Homeserver(_) | Self::PostnumberResolverUnavailable => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
Self::UsernameNotValid => StatusCode::BAD_REQUEST,
|
||||
Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT,
|
||||
Self::UserAlreadyExists | Self::UsernameReserved | Self::PostnumberReserved => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
};
|
||||
(status, sentry_event_id, Json(error)).into_response()
|
||||
}
|
||||
@@ -103,6 +102,12 @@ pub struct Request {
|
||||
/// tokens (like with admin access) for them
|
||||
#[serde(default)]
|
||||
skip_homeserver_check: bool,
|
||||
|
||||
/// Explicitly claim a postnumber that is marked as reserved by the
|
||||
/// postnumber resolver. Has no effect when postnumber validation is
|
||||
/// not configured.
|
||||
#[serde(default)]
|
||||
claim_reserved_postnumber: bool,
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
@@ -137,6 +142,8 @@ pub async fn handler(
|
||||
}: CallContext,
|
||||
NoApi(mut rng): NoApi<BoxRng>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
Json(params): Json<Request>,
|
||||
) -> Result<(StatusCode, Json<SingleResponse<User>>), RouteError> {
|
||||
if repo.user().exists(¶ms.username).await? {
|
||||
@@ -148,6 +155,35 @@ pub async fn handler(
|
||||
return Err(RouteError::UsernameNotValid);
|
||||
}
|
||||
|
||||
// Postnumber validation via external resolver
|
||||
match crate::postnumber::check(
|
||||
&http_client,
|
||||
site_config.postnumber_validation.as_ref(),
|
||||
¶ms.username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => {
|
||||
// Valid – continue
|
||||
}
|
||||
Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => {
|
||||
if !params.claim_reserved_postnumber {
|
||||
return Err(RouteError::PostnumberReserved);
|
||||
}
|
||||
tracing::info!(
|
||||
username = %params.username,
|
||||
"Admin claiming reserved postnumber"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"postnumber resolver error during admin user creation"
|
||||
);
|
||||
return Err(RouteError::PostnumberResolverUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the homeserver if the username is available
|
||||
let homeserver_available = homeserver
|
||||
.is_localpart_available(¶ms.username)
|
||||
@@ -323,4 +359,104 @@ mod tests {
|
||||
|
||||
assert_eq!(user.username, "bob");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_add_user_postnumber_reserved(pool: PgPool) {
|
||||
setup();
|
||||
|
||||
let mock_server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/v1/check"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"normalized": "admin",
|
||||
"status": "RESERVED",
|
||||
"match_kind": "exact",
|
||||
"matched_terms": ["admin"],
|
||||
"matched_categories": [],
|
||||
"pattern_family": null
|
||||
})),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let mut site_config = crate::test_utils::test_site_config();
|
||||
site_config.postnumber_validation = Some(mas_data_model::PostnumberValidationConfig {
|
||||
endpoint: url::Url::parse(&format!("{}/", mock_server.uri())).unwrap(),
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
on_unavailable: mas_data_model::PostnumberValidationFailureMode::Closed,
|
||||
});
|
||||
|
||||
let mut state = TestState::from_pool_with_site_config(pool, site_config)
|
||||
.await
|
||||
.unwrap();
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
|
||||
// Reserved postnumber without claim flag → rejected
|
||||
let request = Request::post("/api/admin/v1/users")
|
||||
.bearer(&token)
|
||||
.json(serde_json::json!({
|
||||
"username": "admin",
|
||||
}));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CONFLICT);
|
||||
let body: serde_json::Value = response.json();
|
||||
assert_eq!(body["errors"][0]["title"], "Postnumber is reserved");
|
||||
|
||||
// Reserved postnumber with claim flag → allowed
|
||||
let request = Request::post("/api/admin/v1/users")
|
||||
.bearer(&token)
|
||||
.json(serde_json::json!({
|
||||
"username": "admin",
|
||||
"claim_reserved_postnumber": true,
|
||||
}));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
let body: serde_json::Value = response.json();
|
||||
assert_eq!(body["data"]["attributes"]["username"], "admin");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_add_user_postnumber_allowed(pool: PgPool) {
|
||||
setup();
|
||||
|
||||
let mock_server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/v1/check"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"normalized": "alice",
|
||||
"status": "ALLOW",
|
||||
"match_kind": "allow",
|
||||
"matched_terms": [],
|
||||
"matched_categories": [],
|
||||
"pattern_family": null
|
||||
})),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let mut site_config = crate::test_utils::test_site_config();
|
||||
site_config.postnumber_validation = Some(mas_data_model::PostnumberValidationConfig {
|
||||
endpoint: url::Url::parse(&format!("{}/", mock_server.uri())).unwrap(),
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
on_unavailable: mas_data_model::PostnumberValidationFailureMode::Closed,
|
||||
});
|
||||
|
||||
let mut state = TestState::from_pool_with_site_config(pool, site_config)
|
||||
.await
|
||||
.unwrap();
|
||||
let token = state.token_with_scope("urn:mas:admin").await;
|
||||
|
||||
// Allowed postnumber → success
|
||||
let request = Request::post("/api/admin/v1/users")
|
||||
.bearer(&token)
|
||||
.json(serde_json::json!({
|
||||
"username": "alice",
|
||||
}));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
let body: serde_json::Value = response.json();
|
||||
assert_eq!(body["data"]["attributes"]["username"], "alice");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ impl_from_ref!(mas_handlers::passwords::PasswordManager);
|
||||
impl_from_ref!(Arc<mas_policy::PolicyFactory>);
|
||||
impl_from_ref!(mas_data_model::SiteConfig);
|
||||
impl_from_ref!(mas_data_model::AppVersion);
|
||||
impl_from_ref!(reqwest::Client);
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
|
||||
|
||||
@@ -76,6 +76,7 @@ struct GraphQLState {
|
||||
password_manager: PasswordManager,
|
||||
url_builder: UrlBuilder,
|
||||
limiter: Limiter,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -108,6 +109,10 @@ impl state::State for GraphQLState {
|
||||
&self.limiter
|
||||
}
|
||||
|
||||
fn http_client(&self) -> &reqwest::Client {
|
||||
&self.http_client
|
||||
}
|
||||
|
||||
fn clock(&self) -> BoxClock {
|
||||
let clock = SystemClock::default();
|
||||
Box::new(clock)
|
||||
@@ -131,6 +136,7 @@ pub fn schema(
|
||||
password_manager: PasswordManager,
|
||||
url_builder: UrlBuilder,
|
||||
limiter: Limiter,
|
||||
http_client: reqwest::Client,
|
||||
) -> Schema {
|
||||
let state = GraphQLState {
|
||||
repository_factory,
|
||||
@@ -140,6 +146,7 @@ pub fn schema(
|
||||
password_manager,
|
||||
url_builder,
|
||||
limiter,
|
||||
http_client,
|
||||
};
|
||||
let state: BoxState = Box::new(state);
|
||||
|
||||
|
||||
@@ -440,14 +440,7 @@ impl DeactivateUserPayload {
|
||||
}
|
||||
|
||||
fn valid_username_character(c: char) -> bool {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| c == '='
|
||||
|| c == '_'
|
||||
|| c == '-'
|
||||
|| c == '.'
|
||||
|| c == '/'
|
||||
|| c == '+'
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit()
|
||||
}
|
||||
|
||||
// XXX: this should probably be moved somewhere else
|
||||
@@ -456,12 +449,7 @@ fn username_valid(username: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should not start with an underscore
|
||||
if username.starts_with('_') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should only contain valid characters
|
||||
// Should only contain lowercase ASCII letters and digits
|
||||
if !username.chars().all(valid_username_character) {
|
||||
return false;
|
||||
}
|
||||
@@ -497,6 +485,29 @@ impl UserMutations {
|
||||
return Ok(AddUserPayload::Invalid);
|
||||
}
|
||||
|
||||
// Postnumber (username) validation via external resolver
|
||||
match crate::postnumber::check(
|
||||
state.http_client(),
|
||||
state.site_config().postnumber_validation.as_ref(),
|
||||
&input.username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => {
|
||||
// Valid – continue
|
||||
}
|
||||
Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => {
|
||||
return Ok(AddUserPayload::Reserved);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"postnumber resolver error during addUser"
|
||||
);
|
||||
return Err(async_graphql::Error::new("Postnumber resolver unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the homeserver if the username is available
|
||||
let homeserver_available = state
|
||||
.homeserver_connection()
|
||||
|
||||
@@ -26,6 +26,7 @@ pub trait State {
|
||||
fn site_config(&self) -> &SiteConfig;
|
||||
fn url_builder(&self) -> &UrlBuilder;
|
||||
fn limiter(&self) -> &Limiter;
|
||||
fn http_client(&self) -> &reqwest::Client;
|
||||
}
|
||||
|
||||
pub type BoxState = Box<dyn State + Send + Sync + 'static>;
|
||||
|
||||
@@ -64,6 +64,7 @@ mod activity_tracker;
|
||||
mod captcha;
|
||||
#[cfg(test)]
|
||||
mod cleanup_tests;
|
||||
pub(crate) mod postnumber;
|
||||
mod preferred_language;
|
||||
mod rate_limit;
|
||||
mod session;
|
||||
@@ -395,7 +396,7 @@ where
|
||||
.route(mas_router::Logout::route(), post(self::views::logout::post))
|
||||
.route(
|
||||
mas_router::Register::route(),
|
||||
get(self::views::register::get),
|
||||
get(self::views::register::get).post(self::views::register::post),
|
||||
)
|
||||
.route(
|
||||
mas_router::PasswordRegister::route(),
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub(crate) fn test_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("failed to create test HTTP client")
|
||||
}
|
||||
|
||||
pub(crate) async fn policy_factory(
|
||||
server_name: &str,
|
||||
data: serde_json::Value,
|
||||
@@ -150,6 +157,7 @@ pub fn test_site_config() -> SiteConfig {
|
||||
login_with_email_allowed: true,
|
||||
plan_management_iframe_uri: None,
|
||||
session_limit: None,
|
||||
postnumber_validation: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +191,7 @@ impl TestState {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let http_client = mas_http::reqwest_client();
|
||||
let http_client = test_http_client();
|
||||
|
||||
// TODO: add more test keys to the store
|
||||
let rsa =
|
||||
@@ -228,6 +236,7 @@ impl TestState {
|
||||
password_manager: password_manager.clone(),
|
||||
url_builder: url_builder.clone(),
|
||||
limiter: limiter.clone(),
|
||||
http_client: http_client.clone(),
|
||||
};
|
||||
let state: crate::graphql::BoxState = Box::new(graphql_state);
|
||||
|
||||
@@ -441,6 +450,7 @@ struct TestGraphQLState {
|
||||
password_manager: PasswordManager,
|
||||
url_builder: UrlBuilder,
|
||||
limiter: Limiter,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -482,6 +492,10 @@ impl graphql::State for TestGraphQLState {
|
||||
let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG");
|
||||
Box::new(rng)
|
||||
}
|
||||
|
||||
fn http_client(&self) -> &reqwest::Client {
|
||||
&self.http_client
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<TestState> for PgPool {
|
||||
|
||||
@@ -314,13 +314,13 @@ mod tests {
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::test_utils::setup;
|
||||
use crate::test_utils::{setup, test_http_client};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metadata_cache() {
|
||||
setup();
|
||||
let mock_server = MockServer::start().await;
|
||||
let http_client = mas_http::reqwest_client();
|
||||
let http_client = test_http_client();
|
||||
|
||||
let cache = MetadataCache::new();
|
||||
|
||||
@@ -384,7 +384,7 @@ mod tests {
|
||||
setup();
|
||||
|
||||
let mock_server = MockServer::start().await;
|
||||
let http_client = mas_http::reqwest_client();
|
||||
let http_client = test_http_client();
|
||||
|
||||
let expected_calls = 2;
|
||||
let mut calls = 0;
|
||||
|
||||
@@ -235,6 +235,8 @@ pub(crate) async fn get(
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
cookie_jar: CookieJar,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
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
|
||||
// that it's plain invalid (although that should have been caught by the
|
||||
// policy), or just reserved by an application service
|
||||
@@ -857,6 +901,7 @@ pub(crate) async fn post(
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
Path(link_id): Path<Ulid>,
|
||||
Form(form): Form<ProtectedForm<FormData>>,
|
||||
) -> 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
|
||||
};
|
||||
|
||||
|
||||
@@ -3,16 +3,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use std::{net::IpAddr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
extract::{Form, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _};
|
||||
use mas_axum_utils::{
|
||||
InternalError, SessionInfoExt,
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt as _, ProtectedForm},
|
||||
};
|
||||
use mas_data_model::{BoxClock, BoxRng, SiteConfig};
|
||||
use mas_matrix::HomeserverConnection;
|
||||
use mas_policy::Policy;
|
||||
use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder};
|
||||
use mas_storage::BoxRepository;
|
||||
use mas_templates::{RegisterContext, TemplateContext, Templates};
|
||||
use mas_storage::{BoxRepository, RepositoryAccess};
|
||||
use mas_templates::{
|
||||
FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext,
|
||||
Templates, ToFormState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::shared::OptionalPostAuthAction;
|
||||
use crate::{BoundActivityTracker, PreferredLanguage};
|
||||
@@ -23,6 +35,125 @@ pub(crate) mod steps;
|
||||
|
||||
pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) struct UsernameForm {
|
||||
username: String,
|
||||
#[serde(flatten)]
|
||||
action: OptionalPostAuthAction,
|
||||
}
|
||||
|
||||
impl ToFormState for UsernameForm {
|
||||
type Field = RegisterFormField;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn validate_registration_username(
|
||||
state: &mut FormState<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)]
|
||||
pub(crate) async fn get(
|
||||
mut rng: BoxRng,
|
||||
@@ -78,18 +209,81 @@ pub(crate) async fn get(
|
||||
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
|
||||
}
|
||||
|
||||
let mut ctx = RegisterContext::new(providers);
|
||||
let post_action = query
|
||||
.load_context(&mut repo)
|
||||
.await
|
||||
.map_err(InternalError::from_anyhow)?;
|
||||
if let Some(action) = post_action {
|
||||
ctx = ctx.with_post_action(action);
|
||||
}
|
||||
|
||||
let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale);
|
||||
|
||||
let content = templates.render_register(&ctx)?;
|
||||
let content = render(
|
||||
locale,
|
||||
RegisterContext::new(providers).with_csrf(csrf_token.form_value()).inner,
|
||||
query,
|
||||
&mut repo,
|
||||
&templates,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handlers.views.register.post", skip_all)]
|
||||
pub(crate) async fn post(
|
||||
mut rng: BoxRng,
|
||||
clock: BoxClock,
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<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);
|
||||
}
|
||||
|
||||
let mut homeserver_denied_username = false;
|
||||
if form.username.is_empty() {
|
||||
state.add_error_on_field(RegisterFormField::Username, FieldError::Required);
|
||||
} else if repo.user().exists(&form.username).await? {
|
||||
// The user already exists in the database
|
||||
state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
|
||||
} else if !homeserver
|
||||
.is_localpart_available(&form.username)
|
||||
.await
|
||||
.map_err(InternalError::from_anyhow)?
|
||||
{
|
||||
// The user already exists on the homeserver
|
||||
tracing::warn!(
|
||||
username = &form.username,
|
||||
"Homeserver denied username provided by user"
|
||||
);
|
||||
|
||||
// We defer adding the error on the field, until we know whether we had another
|
||||
// error from the policy, to avoid showing both
|
||||
homeserver_denied_username = true;
|
||||
}
|
||||
super::validate_registration_username(
|
||||
&mut state,
|
||||
&form.username,
|
||||
user_agent.clone(),
|
||||
activity_tracker.ip(),
|
||||
&mut repo,
|
||||
&mut policy,
|
||||
&homeserver,
|
||||
&http_client,
|
||||
&site_config,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(email) = &email {
|
||||
// Note that we don't check here if the email is already taken here, as
|
||||
@@ -267,18 +258,7 @@ pub(crate) async fn post(
|
||||
message: violation.msg,
|
||||
},
|
||||
),
|
||||
Some("username") => {
|
||||
// If the homeserver denied the username, but we also had an error on the policy
|
||||
// side, we don't want to show both, so we reset the state here
|
||||
homeserver_denied_username = false;
|
||||
state.add_error_on_field(
|
||||
RegisterFormField::Username,
|
||||
FieldError::Policy {
|
||||
code: violation.variant.map(|c| c.as_str()),
|
||||
message: violation.msg,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some("username") => {}
|
||||
Some("password") => state.add_error_on_field(
|
||||
RegisterFormField::Password,
|
||||
FieldError::Policy {
|
||||
@@ -293,11 +273,6 @@ pub(crate) async fn post(
|
||||
}
|
||||
}
|
||||
|
||||
if homeserver_denied_username {
|
||||
// XXX: we may want to return different errors like "this username is reserved"
|
||||
state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
|
||||
}
|
||||
|
||||
if state.is_valid() {
|
||||
// Check the rate limit if we are about to process the form
|
||||
if let Err(e) = limiter.check_registration(requester) {
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(crate) async fn get(
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(templates): State<Templates>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
PreferredLanguage(lang): PreferredLanguage,
|
||||
@@ -120,6 +121,34 @@ pub(crate) async fn get(
|
||||
)));
|
||||
}
|
||||
|
||||
// Re-check postnumber reservation to close the TOCTOU window between
|
||||
// the initial form check and this final commit step.
|
||||
match crate::postnumber::check(
|
||||
&http_client,
|
||||
site_config.postnumber_validation.as_ref(),
|
||||
®istration.username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => {
|
||||
// Valid or not configured – continue
|
||||
}
|
||||
Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => {
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"This PostNumber is reserved and cannot be used for registration"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"postnumber resolver error during registration finalization"
|
||||
);
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"Could not verify PostNumber availability"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the registration token is required and was provided
|
||||
let registration_token = if site_config.registration_token_required {
|
||||
if let Some(registration_token_id) = registration.user_registration_token_id {
|
||||
|
||||
@@ -42,10 +42,17 @@ fn now() -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
fn test_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("failed to create test HTTP client")
|
||||
}
|
||||
|
||||
async fn init_test() -> (reqwest::Client, MockServer, Url) {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let client = mas_http::reqwest_client();
|
||||
let client = test_http_client();
|
||||
let mock_server = MockServer::start().await;
|
||||
let issuer = Url::parse(&mock_server.uri()).expect("Couldn't parse URL");
|
||||
|
||||
|
||||
@@ -340,6 +340,12 @@ pub struct PasswordRegister {
|
||||
}
|
||||
|
||||
impl PasswordRegister {
|
||||
#[must_use]
|
||||
pub fn with_username(mut self, username: impl Into<String>) -> Self {
|
||||
self.username = Some(username.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn and_then(mut self, action: PostAuthAction) -> Self {
|
||||
self.post_auth_action = Some(action);
|
||||
|
||||
@@ -647,6 +647,7 @@ impl FormField for RegisterFormField {
|
||||
/// Context used by the `register.html` template
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct RegisterContext {
|
||||
form: FormState<RegisterFormField>,
|
||||
providers: Vec<UpstreamOAuthProvider>,
|
||||
next: Option<PostAuthContext>,
|
||||
}
|
||||
@@ -661,6 +662,7 @@ impl TemplateContext for RegisterContext {
|
||||
Self: Sized,
|
||||
{
|
||||
sample_list(vec![RegisterContext {
|
||||
form: FormState::default(),
|
||||
providers: Vec::new(),
|
||||
next: None,
|
||||
}])
|
||||
@@ -672,11 +674,18 @@ impl RegisterContext {
|
||||
#[must_use]
|
||||
pub fn new(providers: Vec<UpstreamOAuthProvider>) -> Self {
|
||||
Self {
|
||||
form: FormState::default(),
|
||||
providers,
|
||||
next: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an error on the registration form
|
||||
#[must_use]
|
||||
pub fn with_form_state(self, form: FormState<RegisterFormField>) -> Self {
|
||||
Self { form, ..self }
|
||||
}
|
||||
|
||||
/// Add a post authentication action to the context
|
||||
#[must_use]
|
||||
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
|
||||
# registration. This has no effect if password registration is disabled.
|
||||
registration_token_required: false
|
||||
|
||||
# Optional external resolver for validating postnumbers (usernames) during
|
||||
# registration. When configured, every self-service and upstream-OAuth
|
||||
# registration will call this service. The Admin API also calls this,
|
||||
# but allows explicitly claiming reserved postnumbers via the
|
||||
# `claim_reserved_postnumber` flag.
|
||||
#
|
||||
# Omit this section entirely to disable postnumber validation.
|
||||
#postnumber_validation:
|
||||
# # URL of the postnumber-resolver service (required)
|
||||
# endpoint: "http://localhost:3001/"
|
||||
#
|
||||
# # Per-request timeout in seconds (default: 2)
|
||||
# timeout: 2
|
||||
#
|
||||
# # What to do when the resolver is unreachable:
|
||||
# # - "closed" (default): reject the registration
|
||||
# # - "open": allow the registration to proceed
|
||||
# on_unavailable: closed
|
||||
```
|
||||
|
||||
## `captcha`
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Ideally later on we could find a way to hydrate full React components instead
|
||||
// of doing this, as this can very quickly get out of hands.
|
||||
|
||||
const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/+-]+|@[a-z0-9.=_/+-]+(:.*)?)\s*$/g;
|
||||
const VALID_USERNAME_RE = /^\s*([a-z0-9]+|@[a-z0-9]+(:.*)?)\s*$/g;
|
||||
|
||||
/** Grab the nearest error message inserted by the templates by error kind and code */
|
||||
function grabErrorMessage(
|
||||
|
||||
@@ -117,22 +117,22 @@ const AccountSessionsBrowsersRoute = AccountSessionsBrowsersRouteImport.update({
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AccountIndexRoute
|
||||
'/reset-cross-signing': typeof ResetCrossSigningRouteWithChildren
|
||||
'/clients/$id': typeof ClientsIdRoute
|
||||
'/devices/$': typeof DevicesSplatRoute
|
||||
'/reset-cross-signing/cancelled': typeof ResetCrossSigningCancelledRoute
|
||||
'/reset-cross-signing/success': typeof ResetCrossSigningSuccessRoute
|
||||
'/sessions/$id': typeof SessionsIdRoute
|
||||
'/': typeof AccountIndexRoute
|
||||
'/reset-cross-signing/': typeof ResetCrossSigningIndexRoute
|
||||
'/sessions/browsers': typeof AccountSessionsBrowsersRoute
|
||||
'/emails/$id/in-use': typeof EmailsIdInUseRoute
|
||||
'/emails/$id/verify': typeof EmailsIdVerifyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessRoute
|
||||
'/plan/': typeof AccountPlanIndexRoute
|
||||
'/sessions/': typeof AccountSessionsIndexRoute
|
||||
'/password/change/': typeof PasswordChangeIndexRoute
|
||||
'/password/recovery/': typeof PasswordRecoveryIndexRoute
|
||||
'/plan': typeof AccountPlanIndexRoute
|
||||
'/sessions': typeof AccountSessionsIndexRoute
|
||||
'/password/change': typeof PasswordChangeIndexRoute
|
||||
'/password/recovery': typeof PasswordRecoveryIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/clients/$id': typeof ClientsIdRoute
|
||||
@@ -174,22 +174,22 @@ export interface FileRoutesById {
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/reset-cross-signing'
|
||||
| '/clients/$id'
|
||||
| '/devices/$'
|
||||
| '/reset-cross-signing/cancelled'
|
||||
| '/reset-cross-signing/success'
|
||||
| '/sessions/$id'
|
||||
| '/'
|
||||
| '/reset-cross-signing/'
|
||||
| '/sessions/browsers'
|
||||
| '/emails/$id/in-use'
|
||||
| '/emails/$id/verify'
|
||||
| '/password/change/success'
|
||||
| '/plan/'
|
||||
| '/sessions/'
|
||||
| '/password/change/'
|
||||
| '/password/recovery/'
|
||||
| '/plan'
|
||||
| '/sessions'
|
||||
| '/password/change'
|
||||
| '/password/recovery'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/clients/$id'
|
||||
@@ -253,7 +253,7 @@ declare module '@tanstack/react-router' {
|
||||
'/_account': {
|
||||
id: '/_account'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof AccountRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
@@ -309,28 +309,28 @@ declare module '@tanstack/react-router' {
|
||||
'/password/recovery/': {
|
||||
id: '/password/recovery/'
|
||||
path: '/password/recovery'
|
||||
fullPath: '/password/recovery/'
|
||||
fullPath: '/password/recovery'
|
||||
preLoaderRoute: typeof PasswordRecoveryIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/password/change/': {
|
||||
id: '/password/change/'
|
||||
path: '/password/change'
|
||||
fullPath: '/password/change/'
|
||||
fullPath: '/password/change'
|
||||
preLoaderRoute: typeof PasswordChangeIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_account/sessions/': {
|
||||
id: '/_account/sessions/'
|
||||
path: '/sessions'
|
||||
fullPath: '/sessions/'
|
||||
fullPath: '/sessions'
|
||||
preLoaderRoute: typeof AccountSessionsIndexRouteImport
|
||||
parentRoute: typeof AccountRoute
|
||||
}
|
||||
'/_account/plan/': {
|
||||
id: '/_account/plan/'
|
||||
path: '/plan'
|
||||
fullPath: '/plan/'
|
||||
fullPath: '/plan'
|
||||
preLoaderRoute: typeof AccountPlanIndexRouteImport
|
||||
parentRoute: typeof AccountRoute
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ violation contains {
|
||||
"field": "username", "code": "username-invalid-chars",
|
||||
"msg": "username contains invalid characters",
|
||||
} if {
|
||||
not regex.match(`^[a-z0-9.=_/+-]+$`, input.username)
|
||||
not regex.match(`^[a-z0-9]+$`, input.username)
|
||||
}
|
||||
|
||||
violation contains {
|
||||
|
||||
@@ -40,6 +40,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
{{ _("mas.errors.username_banned") }}
|
||||
{% elif error.code == "username-not-allowed" %}
|
||||
{{ _("mas.errors.username_not_allowed") }}
|
||||
{% elif error.code == "postnumber-reserved" %}
|
||||
{{ _("mas.errors.postnumber_reserved") }}
|
||||
{% elif error.code == "email-domain-not-allowed" %}
|
||||
{{ _("mas.errors.email_domain_not_allowed") }}
|
||||
{% elif error.code == "email-domain-banned" %}
|
||||
|
||||
@@ -12,7 +12,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
{% from "components/idp_brand.html" import logo %}
|
||||
|
||||
{% 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">
|
||||
<div class="brand-logo">
|
||||
{{ brand_logo.letro_logo() }}
|
||||
@@ -28,8 +28,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
</header>
|
||||
|
||||
{% if features.password_registration %}
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
|
||||
{% 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 />
|
||||
{{ 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">
|
||||
@username:{{ branding.server_name }}
|
||||
</div>
|
||||
@@ -37,6 +41,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
{% endif %}
|
||||
|
||||
<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 %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||
{% 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) %}
|
||||
<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": "postnumber-reserved"}, hidden=true) }}
|
||||
{% endcall %}
|
||||
|
||||
{% 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) %}
|
||||
<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": "postnumber-reserved"}, hidden=true) }}
|
||||
|
||||
{% if f.errors is empty %}
|
||||
<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.",
|
||||
"username_all_numeric": "PostNumber se nemůže skládat pouze z čísel",
|
||||
"username_banned": "PostNumber je zakázáno zásadami serveru",
|
||||
"username_invalid_chars": "PostNumber obsahuje neplatné znaky. Používejte pouze malá písmena, číslice, pomlčky a podtržítka.",
|
||||
"username_invalid_chars": "PostNumber obsahuje neplatné znaky. Používejte pouze malá písmena a číslice.",
|
||||
"username_not_allowed": "PostNumber není povoleno zásadami serveru",
|
||||
"postnumber_reserved": "Toto PostNumber je rezervováno a nelze ho použít k registraci",
|
||||
"username_taken": "Toto PostNumber je již obsazeno",
|
||||
"username_too_long": "PostNumber je příliš dlouhé",
|
||||
"username_too_short": "PostNumber je příliš krátké"
|
||||
|
||||
@@ -139,8 +139,9 @@
|
||||
"rate_limit_exceeded": "Du har indsendt for mange anmodninger på kort tid. Vent et par minutter, og prøv igen.",
|
||||
"username_all_numeric": "PostNumber kan ikke udelukkende bestå af tal",
|
||||
"username_banned": "PostNumber er forbudt af serverpolitikken",
|
||||
"username_invalid_chars": "PostNumber indeholder ugyldige tegn. Brug kun små bogstaver, tal, bindestreger og underscores.",
|
||||
"username_invalid_chars": "PostNumber indeholder ugyldige tegn. Brug kun små bogstaver og tal.",
|
||||
"username_not_allowed": "PostNumber er ikke tilladt af serverpolitikken",
|
||||
"postnumber_reserved": "Dette PostNumber er reserveret og kan ikke bruges til registrering",
|
||||
"username_taken": "Dette PostNumber er allerede taget",
|
||||
"username_too_long": "PostNumber er for langt",
|
||||
"username_too_short": "PostNumber er for kort"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Du hast in kurzer Zeit zu viele Anfragen gestellt. Warte bitte ein paar Minuten und versuch's nochmal.",
|
||||
"username_all_numeric": "PostNumber darf nicht nur aus Zahlen bestehen",
|
||||
"username_banned": "PostNumber ist durch die Serverrichtlinie gesperrt",
|
||||
"username_invalid_chars": "PostNumber enthält ungültige Zeichen. Verwende nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche.",
|
||||
"username_invalid_chars": "PostNumber enthält ungültige Zeichen. Verwende nur Kleinbuchstaben und Zahlen.",
|
||||
"username_not_allowed": "PostNumber ist gemäß der Serverrichtlinie nicht zulässig",
|
||||
"postnumber_reserved": "Diese PostNumber ist reserviert und kann nicht zur Registrierung verwendet werden",
|
||||
"username_taken": "Dieser PostNumber ist bereits vergeben",
|
||||
"username_too_long": "PostNumber ist zu lang",
|
||||
"username_too_short": "PostNumber ist zu kurz"
|
||||
|
||||
@@ -388,7 +388,7 @@
|
||||
"context": "components/field.html:40:11-42",
|
||||
"description": "Error message shown on registration, when the postNumber matches a pattern that is banned by the server policy."
|
||||
},
|
||||
"username_invalid_chars": "PostNumber contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.",
|
||||
"username_invalid_chars": "PostNumber contains invalid characters. Use lowercase letters and numbers only.",
|
||||
"@username_invalid_chars": {
|
||||
"context": "components/field.html:36:11-49"
|
||||
},
|
||||
@@ -397,6 +397,11 @@
|
||||
"context": "components/field.html:42:11-47",
|
||||
"description": "Error message shown on registration, when the postNumber *does not match* any of the patterns that are allowed by the server policy."
|
||||
},
|
||||
"postnumber_reserved": "This PostNumber is reserved and cannot be used for registration",
|
||||
"@postnumber_reserved": {
|
||||
"context": "components/field.html:44:11-47",
|
||||
"description": "Error message shown on registration, when the postNumber is reserved by the postnumber resolver."
|
||||
},
|
||||
"username_taken": "This postNumber is already taken",
|
||||
"@username_taken": {
|
||||
"context": "components/field.html:29:9-39"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Sa oled lühikese aja jooksul teinud liiga palju päringuid. Palun oota paar minutit ja proovi uuesti.",
|
||||
"username_all_numeric": "PostNumber ei tohi koosneda vaid numbritest",
|
||||
"username_banned": "PostNumber on serverireeglite alusel keelatud",
|
||||
"username_invalid_chars": "PostNumber sisaldab keelatud tähemärke. Palun kasuta vaid väiketähti, numbreid, sidekriipsu ja alakriipsu.",
|
||||
"username_invalid_chars": "PostNumber sisaldab keelatud tähemärke. Palun kasuta vaid väiketähti ja numbreid.",
|
||||
"username_not_allowed": "PostNumber pole serveri reeglite alusel lubatud",
|
||||
"postnumber_reserved": "See PostNumber on reserveeritud ja seda ei saa registreerimiseks kasutada",
|
||||
"username_taken": "Selline PostNumber on juba olemas",
|
||||
"username_too_long": "PostNumber on liiga pikk",
|
||||
"username_too_short": "PostNumber on liiga lühike"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Olet tehnyt liian monta pyyntöä lyhyessä ajassa. Odota muutama minuutti ja yritä uudelleen.",
|
||||
"username_all_numeric": "PostNumber ei voi koostua pelkästään numeroista",
|
||||
"username_banned": "Palvelinkäytäntö kieltää tämän PostNumber-tunnuksen",
|
||||
"username_invalid_chars": "PostNumber sisältää virheellisiä merkkejä. Käytä vain pieniä kirjaimia, numeroita, viivoja ja alaviivoja.",
|
||||
"username_invalid_chars": "PostNumber sisältää virheellisiä merkkejä. Käytä vain pieniä kirjaimia ja numeroita.",
|
||||
"username_not_allowed": "Palvelinkäytäntö ei salli tätä PostNumber-tunnusta",
|
||||
"postnumber_reserved": "Tämä PostNumber on varattu eikä sitä voi käyttää rekisteröitymiseen",
|
||||
"username_taken": "Tämä PostNumber on jo käytössä.",
|
||||
"username_too_long": "PostNumber on liian pitkä",
|
||||
"username_too_short": "PostNumber on liian lyhyt"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Vous avez effectué trop de requêtes sur une courte période. Veuillez patienter quelques minutes et réessayer.",
|
||||
"username_all_numeric": "PostNumber ne peut pas être composé uniquement de chiffres",
|
||||
"username_banned": "Ce PostNumber est interdit par la politique du serveur",
|
||||
"username_invalid_chars": "PostNumber contient des caractères non valides. Utilisez uniquement des lettres minuscules, des chiffres, des tirets et des traits de soulignement.",
|
||||
"username_invalid_chars": "PostNumber contient des caractères non valides. Utilisez uniquement des lettres minuscules et des chiffres.",
|
||||
"username_not_allowed": "Ce PostNumber n'est pas autorisé par la politique du serveur",
|
||||
"postnumber_reserved": "Ce PostNumber est réservé et ne peut pas être utilisé pour l'inscription",
|
||||
"username_taken": "Ce PostNumber est déjà utilisé",
|
||||
"username_too_long": "PostNumber est trop long",
|
||||
"username_too_short": "PostNumber est trop court"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Túl sok kérést adott fel egy rövid időszak alatt. Várjon néhány percet, és próbálja újra.",
|
||||
"username_all_numeric": "PostNumber nem állhat pusztán számokból",
|
||||
"username_banned": "A PostNumber-t a kiszolgáló-házirend tiltja",
|
||||
"username_invalid_chars": "PostNumber érvénytelen karaktereket tartalmaz. Csak kisbetűket, számokat, kötőjeleket és aláhúzásokat használjon.",
|
||||
"username_invalid_chars": "PostNumber érvénytelen karaktereket tartalmaz. Csak kisbetűket és számokat használjon.",
|
||||
"username_not_allowed": "A PostNumber-t nem engedélyezi a kiszolgáló-házirend",
|
||||
"postnumber_reserved": "Ez a PostNumber foglalt és nem használható regisztrációhoz",
|
||||
"username_taken": "PostNumber már foglalt",
|
||||
"username_too_long": "PostNumber túl hosszú",
|
||||
"username_too_short": "PostNumber túl rövid"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Du har kommet med for mange forespørsler på kort tid. Vent noen minutter og prøv igjen.",
|
||||
"username_all_numeric": "PostNumber kan ikke bare bestå av tall",
|
||||
"username_banned": "PostNumber er utestengt av serverpolicyen",
|
||||
"username_invalid_chars": "PostNumber inneholder ugyldige tegn. Bruk bare små bokstaver, tall, bindestrek og understrek.",
|
||||
"username_invalid_chars": "PostNumber inneholder ugyldige tegn. Bruk bare små bokstaver og tall.",
|
||||
"username_not_allowed": "PostNumber er ikke tillatt av serverpolicyen",
|
||||
"postnumber_reserved": "Dette PostNumber er reservert og kan ikke brukes til registrering",
|
||||
"username_taken": "Dette PostNumber er allerede tatt",
|
||||
"username_too_long": "PostNumber er for langt",
|
||||
"username_too_short": "PostNumber er for kort"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "W krótkim czasie wysłałeś zbyt wiele żądań. Poczekaj kilka minut i spróbuj ponownie.",
|
||||
"username_all_numeric": "PostNumber nie może składać się wyłącznie z cyfr",
|
||||
"username_banned": "PostNumber jest zablokowany przez politykę serwera",
|
||||
"username_invalid_chars": "PostNumber zawiera nieprawidłowe znaki. Używaj tylko małych liter, cyfr, myślników i podkreśleń.",
|
||||
"username_invalid_chars": "PostNumber zawiera nieprawidłowe znaki. Używaj tylko małych liter i cyfr.",
|
||||
"username_not_allowed": "PostNumber nie jest dozwolony przez politykę serwera",
|
||||
"postnumber_reserved": "Ten PostNumber jest zarezerwowany i nie może być użyty do rejestracji",
|
||||
"username_taken": "Ten PostNumber jest już zajęty",
|
||||
"username_too_long": "PostNumber jest za długi",
|
||||
"username_too_short": "PostNumber jest za krótki"
|
||||
|
||||
@@ -139,8 +139,9 @@
|
||||
"rate_limit_exceeded": "Efetuou demasiadas solicitações num curto espaço de tempo. Aguarde alguns minutos e tente novamente.",
|
||||
"username_all_numeric": "O PostNumber não pode ser constituído apenas por números",
|
||||
"username_banned": "O PostNumber é proibido pela política do servidor",
|
||||
"username_invalid_chars": "O PostNumber contém carateres inválidos. Utilize apenas letras minúsculas, números, traços e sublinhados.",
|
||||
"username_invalid_chars": "O PostNumber contém carateres inválidos. Utilize apenas letras minúsculas e números.",
|
||||
"username_not_allowed": "O PostNumber não é permitido pela política do servidor",
|
||||
"postnumber_reserved": "Este PostNumber está reservado e não pode ser utilizado para registo",
|
||||
"username_taken": "Este PostNumber já foi utilizado",
|
||||
"username_too_long": "O PostNumber é demasiado longo",
|
||||
"username_too_short": "O PostNumber é demasiado curto"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Вы делаете запросы слишком часто. Пожалуйста, подождите несколько минут и повторите попытку.",
|
||||
"username_all_numeric": "ПостНамбер не может состоять только из цифр",
|
||||
"username_banned": "ПостНамбер запрещён политикой сервера",
|
||||
"username_invalid_chars": "ПостНамбер содержит недопустимые символы. Используйте только строчные буквы латиницы, цифры, тире и символы подчеркивания.",
|
||||
"username_invalid_chars": "ПостНамбер содержит недопустимые символы. Используйте только строчные буквы латиницы и цифры.",
|
||||
"username_not_allowed": "ПостНамбер не разрешён политикой сервера",
|
||||
"postnumber_reserved": "Этот ПостНамбер зарезервирован и не может быть использован для регистрации",
|
||||
"username_taken": "Этот ПостНамбер уже занят",
|
||||
"username_too_long": "ПостНамбер слишком длинный",
|
||||
"username_too_short": "ПостНамбер слишком короткий"
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
"password_mismatch": "Lösenordsfälten matchar inte",
|
||||
"rate_limit_exceeded": "Du har gjort för många förfrågningar under en kort period. Vänta några minuter och försök igen.",
|
||||
"username_all_numeric": "PostNumber kan inte enbart bestå av siffror",
|
||||
"username_invalid_chars": "PostNumber innehåller ogiltiga tecken. Använd endast små bokstäver, siffror, streck och understreck.",
|
||||
"username_invalid_chars": "PostNumber innehåller ogiltiga tecken. Använd endast små bokstäver och siffror.",
|
||||
"postnumber_reserved": "Detta PostNumber är reserverat och kan inte användas för registrering",
|
||||
"username_taken": "Detta PostNumber är redan upptaget",
|
||||
"username_too_long": "PostNumber är för långt",
|
||||
"username_too_short": "PostNumber är för kort"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "Ви зробили забагато запитів за короткий проміжок часу. Зачекайте кілька хвилин і повторіть спробу.",
|
||||
"username_all_numeric": "ПостНамбер не може складатися тільки з цифр",
|
||||
"username_banned": "ПостНамбер заборонений політикою сервера",
|
||||
"username_invalid_chars": "ПостНамбер містить неприпустимі символи. Використовуйте лише малі букви, цифри, тире та підкреслення.",
|
||||
"username_invalid_chars": "ПостНамбер містить неприпустимі символи. Використовуйте лише малі букви та цифри.",
|
||||
"username_not_allowed": "ПостНамбер не дозволений політикою сервера",
|
||||
"postnumber_reserved": "Цей ПостНамбер зарезервований і не може бути використаний для реєстрації",
|
||||
"username_taken": "Цей ПостНамбер вже зайнятий",
|
||||
"username_too_long": "ПостНамбер задовгий",
|
||||
"username_too_short": "ПостНамбер закороткий"
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
"rate_limit_exceeded": "你在短时间内发出了过多请求。请于几分钟后重试。",
|
||||
"username_all_numeric": "PostNumber不能仅由数字组成",
|
||||
"username_banned": "由于服务器策略,PostNumber已被禁止",
|
||||
"username_invalid_chars": "PostNumber包含无效字符。仅能使用小写字母、数字、短横线或下划线。",
|
||||
"username_invalid_chars": "PostNumber包含无效字符。仅能使用小写字母和数字。",
|
||||
"username_not_allowed": "由于服务器策略,PostNumber不被允许",
|
||||
"postnumber_reserved": "此PostNumber已被保留,无法用于注册",
|
||||
"username_taken": "此PostNumber已被使用",
|
||||
"username_too_long": "PostNumber太长",
|
||||
"username_too_short": "PostNumber太短"
|
||||
|
||||
Reference in New Issue
Block a user