Integrate postnumber resolver across MAS flows

This commit is contained in:
Letro Bot
2026-04-08 17:58:30 +03:30
parent 6cfaf99300
commit def6fa6539
47 changed files with 1311 additions and 126 deletions

View File

@@ -231,6 +231,7 @@ impl Options {
password_manager.clone(),
url_builder.clone(),
limiter.clone(),
http_client.clone(),
);
let state = {

View File

@@ -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
}
},
}
}),
})
}

View File

@@ -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()
}
}

View File

@@ -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},

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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>,
{

View File

@@ -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(&params.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(),
&params.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(&params.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");
}
}

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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()

View File

@@ -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>;

View File

@@ -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(),

View 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"
);
}
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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
};

View File

@@ -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())
}

View File

@@ -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) {

View File

@@ -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(),
&registration.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 {

View File

@@ -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");

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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`

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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" %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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é"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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úsokat használjon.",
"username_invalid_chars": "PostNumber érvénytelen karaktereket tartalmaz. Csak kisbetűket és smokat 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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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": "ПостНамбер слишком короткий"

View File

@@ -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"

View File

@@ -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": "ПостНамбер закороткий"

View File

@@ -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太短"