Add a usernameAvailable GraphQL query for anonymous lookups

Exposes a new query telling whether a given username is available for
registration, and if not, why: already taken, or rejected by the
registration policy. A username reserved on the homeserver side is
deliberately reported as taken, mirroring the ambiguity of the
registration form, which doesn't tell the two apart either.

Because the query reveals whether a username exists, it is rate-limited
per requester, configurable through the new
`rate_limiting.username_availability` option.
This commit is contained in:
Quentin Gliech authored and GitHub committed 2026-10-16 11:34:22 +02:00
1 parent a50de86da6
commit 24683fb938
9 files changed
+665 -6

No files matched your search

@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -38,6 +39,14 @@ pub struct RateLimitingConfig {
/// This can protect against brute-forcing the user code.
#[serde(default = "default_device_code_link")]
pub device_code_link: RateLimiterConfiguration,
/// Controls how often the registration form can check whether a username
/// is available, based on source address.
///
/// The form checks the username as it is typed. This limit makes it
/// harder to use those checks to find out which usernames exist.
#[serde(default = "default_username_availability")]
pub username_availability: RateLimiterConfiguration,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -174,6 +183,10 @@ impl ConfigurationSection for RateLimitingConfig {
return Err(error_on_field(error, "registration").into());
}
if let Some(error) = error_on_limiter(&self.username_availability) {
return Err(error_on_field(error, "username_availability").into());
}
if let Some(error) = error_on_limiter(&self.login.per_ip) {
return Err(error_on_nested_field(error, "login", "per_ip").into());
}
@@ -275,6 +288,13 @@ fn default_device_code_link() -> RateLimiterConfiguration {
}
}
fn default_username_availability() -> RateLimiterConfiguration {
RateLimiterConfiguration {
burst: NonZeroU32::new(5).unwrap(),
per_second: 1.0,
}
}
impl Default for RateLimitingConfig {
fn default() -> Self {
RateLimitingConfig {
@@ -283,6 +303,7 @@ impl Default for RateLimitingConfig {
account_recovery: AccountRecoveryRateLimitingConfig::default(),
email_authentication: EmailauthenticationRateLimitingConfig::default(),
device_code_link: default_device_code_link(),
username_availability: default_username_availability(),
}
}
}
+123 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -5,7 +6,7 @@
// Please see LICENSE files in the repository root for full details.
use async_graphql::{
Context, Enum, ID, Object,
Context, Enum, ID, Object, SimpleObject,
connection::{Connection, Edge, OpaqueCursor, query},
};
use mas_storage::{Pagination, user::UserFilter};
@@ -16,6 +17,34 @@ use crate::graphql::{
state::ContextExt as _,
};
/// Why a username is not available for registration.
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum UsernameUnavailableReason {
/// The username is already in use, either by an existing MAS user or
/// reserved by the homeserver.
Taken,
/// The username does not pass the registration policy. See
/// `violationCodes` for the specific reasons.
Invalid,
}
/// The result of a username availability check.
#[derive(SimpleObject)]
struct UsernameAvailability {
/// Whether the username is available for registration.
available: bool,
/// If the username is not available, the reason why.
reason: Option<UsernameUnavailableReason>,
/// If the username is invalid, the list of policy violation codes (e.g.
/// `username-too-long`, `username-all-numeric`, `username-banned`,
/// `username-not-allowed`, `username-invalid-chars`), so that clients can
/// localize the error message.
///
/// This may be an empty list, when the registration policy rejected the
/// username for a reason that has no code attached to it.
violation_codes: Option<Vec<String>>,
}
#[derive(Default)]
pub struct UserQuery;
@@ -45,6 +74,99 @@ impl UserQuery {
Ok(user.map(User))
}
/// Check whether a username is available for registration.
///
/// This query is accessible to anonymous users, as it is used during the
/// registration flow. Unlike `userByUsername`, it reveals whether a
/// username exists, so it is rate-limited per requester.
async fn username_available(
&self,
ctx: &Context<'_>,
username: String,
) -> Result<UsernameAvailability, async_graphql::Error> {
let state = ctx.state();
let requester = ctx.requester();
if !state.site_config().password_registration_enabled {
return Err(async_graphql::Error::new(
"Password registration is disabled on this server",
));
}
if let Err(e) = state
.limiter()
.check_username_availability(requester.fingerprint())
{
tracing::warn!(error = &e as &dyn std::error::Error);
return Err(async_graphql::Error::new("Too many requests"));
}
// Check if the username exists in the MAS database
let mut repo = state.repository().await?;
let exists = repo.user().exists(&username).await?;
repo.cancel().await?;
if exists {
return Ok(UsernameAvailability {
available: false,
reason: Some(UsernameUnavailableReason::Taken),
violation_codes: None,
});
}
// Run the same registration policy the register views use, so that this
// query doesn't report a username as available when registration would
// actually reject it
let mut policy = state.policy().await?;
let res = policy
.evaluate_register(mas_policy::RegisterInput {
registration_method: mas_policy::RegistrationMethod::Password,
username: &username,
email: None,
requester: requester.for_policy(),
})
.await?;
// Any violation means registration would be rejected, even if it isn't
// about the username itself (e.g. a banned requester) or has no code we
// know about. The codes we do recognise are only there to let clients
// localize the error, so they may well be empty.
if !res.violations.is_empty() {
let violation_codes: Vec<String> = res
.violations
.into_iter()
.filter(|violation| violation.field.as_deref() == Some("username"))
.filter_map(|violation| {
violation.variant.map(|variant| variant.as_str().to_owned())
})
.collect();
return Ok(UsernameAvailability {
available: false,
reason: Some(UsernameUnavailableReason::Invalid),
violation_codes: Some(violation_codes),
});
}
// Check if the username is available on the homeserver
let homeserver = state.homeserver_connection();
let homeserver_available = homeserver.is_localpart_available(&username).await?;
if !homeserver_available {
return Ok(UsernameAvailability {
available: false,
reason: Some(UsernameUnavailableReason::Taken),
violation_codes: None,
});
}
Ok(UsernameAvailability {
available: true,
reason: None,
violation_codes: None,
})
}
/// Fetch a user by its username.
async fn user_by_username(
&self,
+390 -1
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
@@ -22,7 +23,12 @@ use oauth2_types::{
use sqlx::PgPool;
use zeroize::Zeroizing;
use crate::test_utils::{self, CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup};
use crate::{
SiteConfig,
test_utils::{
self, CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup, test_site_config,
},
};
async fn create_test_client(state: &TestState) -> Client {
let mut repo = state.repository().await.unwrap();
@@ -1084,3 +1090,386 @@ async fn test_deactivate_user_rejected_wrong_password(pool: PgPool) {
response.data
);
}
const USERNAME_AVAILABLE_QUERY: &str = r"
query($username: String!) {
usernameAvailable(username: $username) {
available
reason
violationCodes
}
}
";
/// Test that the `usernameAvailable` query reports an available username.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "carol" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"],
serde_json::json!({
"available": true,
"reason": null,
"violationCodes": null,
})
);
}
/// Test that the `usernameAvailable` query reports a username already taken
/// by an existing MAS user.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_taken(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
create_test_user(&state, "alice").await;
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "alice" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"],
serde_json::json!({
"available": false,
"reason": "TAKEN",
"violationCodes": null,
})
);
}
/// Test that the `usernameAvailable` query matches existing MAS users
/// case-insensitively.
///
/// The MAS-side existence check is a `LOWER(username) = LOWER($1)` match and
/// runs before the registration policy, so a differently-cased spelling of an
/// existing username reports `TAKEN` rather than the `username-invalid-chars`
/// violation the policy would raise for the uppercase letter.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_different_case(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
create_test_user(&state, "alice").await;
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "Alice" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"],
serde_json::json!({
"available": false,
"reason": "TAKEN",
"violationCodes": null,
})
);
}
/// Test that the `usernameAvailable` query rejects an empty username.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_empty(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"]["available"],
serde_json::json!(false)
);
assert_eq!(
response.data["usernameAvailable"]["reason"].as_str(),
Some("INVALID")
);
}
/// Test that the `usernameAvailable` query reports a username reserved on
/// the homeserver as `TAKEN`, like an existing MAS user.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_reserved(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
state.homeserver_connection.reserve_localpart("bob").await;
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "bob" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"],
serde_json::json!({
"available": false,
"reason": "TAKEN",
"violationCodes": null,
})
);
}
/// Test that the `usernameAvailable` query runs the registration policy and
/// reports the violation codes for an all-numeric username.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_invalid_all_numeric(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "1234567" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"]["reason"].as_str(),
Some("INVALID")
);
let codes = response.data["usernameAvailable"]["violationCodes"]
.as_array()
.expect("violationCodes to be an array");
assert!(
codes
.iter()
.any(|c| c.as_str() == Some("username-all-numeric")),
"{:?}",
codes
);
}
/// Test that the `usernameAvailable` query reports a username as unavailable
/// when the registration policy rejects the registration for a reason that
/// isn't about the username, and therefore has no violation code.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_banned_requester(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config_and_policy_data(
pool,
test_site_config(),
serde_json::json!({
"requester": {
"banned_ips": ["1.2.3.4"],
},
}),
)
.await
.unwrap();
// The policy crashes on a null user agent when formatting the requester,
// so make sure we send one
let request = Request::post("/graphql")
.client_ip([1, 2, 3, 4].into())
.header("user-agent", "Test/1.0")
.json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "frank" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"],
serde_json::json!({
"available": false,
"reason": "INVALID",
"violationCodes": [],
})
);
}
/// Test that the `usernameAvailable` query reports an overly long username as
/// invalid, with the violation code the registration policy raises.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_too_long(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let username = "a".repeat(300);
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": username },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(
response.data["usernameAvailable"]["reason"].as_str(),
Some("INVALID")
);
assert_eq!(
response.data["usernameAvailable"]["violationCodes"],
serde_json::json!(["username-too-long"])
);
}
/// Test that the `usernameAvailable` query errors out when password
/// registration is disabled on the server.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_registration_disabled(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config(
pool,
SiteConfig {
password_registration_enabled: false,
..test_site_config()
},
)
.await
.unwrap();
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "dave" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert_eq!(
response.errors[0]["message"].as_str(),
Some("Password registration is disabled on this server"),
"{:?}",
response.errors
);
assert!(response.data["usernameAvailable"].is_null());
}
/// Test that the `usernameAvailable` query errors out when the homeserver call
/// fails, rather than reporting the username as available.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_homeserver_failure(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
state.homeserver_connection.fail_localpart_availability();
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "grace" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(!response.errors.is_empty());
assert!(response.data["usernameAvailable"].is_null());
}
/// Test that the `usernameAvailable` query is rate-limited per requester.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_rate_limited(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// The default configuration allows a burst of 5 requests.
for _ in 0..5 {
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "eve" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
}
// The next one should be rate limited.
let request = Request::post("/graphql").json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "eve" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert_eq!(
response.errors[0]["message"].as_str(),
Some("Too many requests"),
"{:?}",
response.errors
);
}
/// Test that the `usernameAvailable` rate limit is keyed on the client IP, so
/// that one client can't exhaust another client's budget.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_username_available_rate_limited_per_ip(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Exhaust the burst of 5 requests for the first client
for _ in 0..5 {
let request = Request::post("/graphql")
.client_ip([1, 2, 3, 4].into())
.json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "eve" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
}
let request = Request::post("/graphql")
.client_ip([1, 2, 3, 4].into())
.json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "eve" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert_eq!(
response.errors[0]["message"].as_str(),
Some("Too many requests"),
"{:?}",
response.errors
);
// The second client still has its own budget
let request = Request::post("/graphql")
.client_ip([5, 6, 7, 8].into())
.json(serde_json::json!({
"query": USERNAME_AVAILABLE_QUERY,
"variables": { "username": "eve" },
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: GraphQLResponse = response.json();
assert!(response.errors.is_empty(), "{:?}", response.errors);
}
+30
View File
@@ -39,6 +39,12 @@ pub enum RegistrationLimitedError {
Requester(RequesterFingerprint),
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum UsernameAvailabilityLimitedError {
#[error("Too many username availability checks for requester {0}")]
Requester(RequesterFingerprint),
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum EmailAuthenticationLimitedError {
#[error("Too many email authentication requests for requester {0}")]
@@ -129,6 +135,7 @@ struct LimiterInner {
email_authentication_emails_per_session: KeyedRateLimiter<Ulid>,
email_authentication_attempt_per_session: KeyedRateLimiter<Ulid>,
device_code_link_per_requester: KeyedRateLimiter<RequesterFingerprint>,
username_availability_per_requester: KeyedRateLimiter<RequesterFingerprint>,
}
impl LimiterInner {
@@ -156,6 +163,9 @@ impl LimiterInner {
config.email_authentication.attempt_per_session.to_quota()?,
),
device_code_link_per_requester: RateLimiter::keyed(config.device_code_link.to_quota()?),
username_availability_per_requester: RateLimiter::keyed(
config.username_availability.to_quota()?,
),
})
}
}
@@ -202,6 +212,9 @@ impl Limiter {
.email_authentication_attempt_per_session
.retain_recent();
this.inner.device_code_link_per_requester.retain_recent();
this.inner
.username_availability_per_requester
.retain_recent();
interval.tick().await;
}
@@ -275,6 +288,23 @@ impl Limiter {
Ok(())
}
/// Check if a username availability check can be performed
///
/// # Errors
///
/// Returns an error if the operation is rate limited.
pub fn check_username_availability(
&self,
requester: RequesterFingerprint,
) -> Result<(), UsernameAvailabilityLimitedError> {
self.inner
.username_availability_per_requester
.check_key(&requester)
.map_err(|_| UsernameAvailabilityLimitedError::Requester(requester))?;
Ok(())
}
/// Check if an email can be sent to the address for an email
/// authentication session
///
+12 -1
View File
@@ -170,6 +170,17 @@ impl TestState {
pub async fn from_pool_with_site_config(
pool: PgPool,
site_config: SiteConfig,
) -> Result<Self, anyhow::Error> {
Self::from_pool_with_site_config_and_policy_data(pool, site_config, serde_json::json!({}))
.await
}
/// Create a new test state from the given database pool, site config and
/// policy data
pub async fn from_pool_with_site_config_and_policy_data(
pool: PgPool,
site_config: SiteConfig,
policy_data: serde_json::Value,
) -> Result<Self, anyhow::Error> {
// Make sure the rustls crypto provider and tracing are set up, as
// building the HTTP client below requires a process-level provider to
@@ -224,7 +235,7 @@ impl TestState {
server_name: site_config.server_name.clone(),
session_limit: site_config.session_limit.clone(),
},
serde_json::json!({}),
policy_data,
)
.await?;
+21 -3
View File
@@ -1,10 +1,14 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::collections::{HashMap, HashSet};
use std::{
collections::{HashMap, HashSet},
sync::atomic::{AtomicBool, Ordering},
};
use anyhow::Context;
use async_trait::async_trait;
@@ -24,12 +28,13 @@ pub struct MockUser {
pub locked: bool,
}
/// A mock implementation of a [`HomeserverConnection`], which never fails and
/// doesn't do anything.
/// A mock implementation of a [`HomeserverConnection`], which doesn't do
/// anything and only fails when explicitly told to.
pub struct HomeserverConnection {
homeserver: String,
users: RwLock<HashMap<String, MockUser>>,
reserved_localparts: RwLock<HashSet<&'static str>>,
fail_localpart_availability: AtomicBool,
}
impl HomeserverConnection {
@@ -46,6 +51,7 @@ impl HomeserverConnection {
homeserver: homeserver.into(),
users: RwLock::new(HashMap::new()),
reserved_localparts: RwLock::new(HashSet::new()),
fail_localpart_availability: AtomicBool::new(false),
}
}
@@ -53,6 +59,13 @@ impl HomeserverConnection {
self.reserved_localparts.write().await.insert(localpart);
}
/// Make [`crate::HomeserverConnection::is_localpart_available`] fail, to
/// exercise homeserver failures.
pub fn fail_localpart_availability(&self) {
self.fail_localpart_availability
.store(true, Ordering::Relaxed);
}
/// Like `query_user` but get the raw test state of the user.
///
/// # Errors
@@ -125,6 +138,11 @@ impl crate::HomeserverConnection for HomeserverConnection {
}
async fn is_localpart_available(&self, localpart: &str) -> Result<bool, anyhow::Error> {
anyhow::ensure!(
!self.fail_localpart_availability.load(Ordering::Relaxed),
"Mock homeserver failure while checking localpart availability"
);
if self.reserved_localparts.read().await.contains(localpart) {
return Ok(false);
}
+12
View File
@@ -2033,6 +2033,18 @@
"$ref": "#/definitions/RateLimiterConfiguration"
}
]
},
"username_availability": {
"description": "Controls how often the registration form can check whether a username\nis available, based on source address.\n\nThe form checks the username as it is typed. This limit makes it\nharder to use those checks to find out which usernames exist.",
"default": {
"burst": 5,
"per_second": 1.0
},
"allOf": [
{
"$ref": "#/definitions/RateLimiterConfiguration"
}
]
}
}
},
+8
View File
@@ -648,6 +648,14 @@ rate_limiting:
device_code_link:
burst: 10
per_second: 0.016666
# Limits how often the registration form can check whether a username is
# available, based on source address.
# The form checks the username as it is typed. This limit makes it harder to
# use those checks to find out which usernames exist.
username_availability:
burst: 5
per_second: 1.0
```
## `telemetry`
+48
View File
@@ -1185,6 +1185,14 @@ type Query {
"""
user(id: ID!): User
"""
Check whether a username is available for registration.
This query is accessible to anonymous users, as it is used during the
registration flow. Unlike `userByUsername`, it reveals whether a
username exists, so it is rate-limited per requester.
"""
usernameAvailable(username: String!): UsernameAvailability!
"""
Fetch a user by its username.
"""
userByUsername(username: String!): User
@@ -2470,6 +2478,46 @@ enum UserState {
LOCKED
}
"""
The result of a username availability check.
"""
type UsernameAvailability {
"""
Whether the username is available for registration.
"""
available: Boolean!
"""
If the username is not available, the reason why.
"""
reason: UsernameUnavailableReason
"""
If the username is invalid, the list of policy violation codes (e.g.
`username-too-long`, `username-all-numeric`, `username-banned`,
`username-not-allowed`, `username-invalid-chars`), so that clients can
localize the error message.
This may be an empty list, when the registration policy rejected the
username for a reason that has no code attached to it.
"""
violationCodes: [String!]
}
"""
Why a username is not available for registration.
"""
enum UsernameUnavailableReason {
"""
The username is already in use, either by an existing MAS user or
reserved by the homeserver.
"""
TAKEN
"""
The username does not pass the registration policy. See
`violationCodes` for the specific reasons.
"""
INVALID
}
"""
Represents the current viewer
"""