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 this effectively is a username-existence oracle, the query is
rate-limited per requester, configurable through the new
`rate_limiting.username_availability` option.
This commit is contained in:
Quentin Gliech
2026-10-09 18:22:16 +00:00
committed by Devon Hudson
parent 46ea733ce4
commit dc69dd5391
9 changed files with 667 additions and 6 deletions
@@ -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 many `usernameAvailable` GraphQL queries are permitted
/// based on source address.
///
/// This query is an availability oracle usable during registration, so
/// this limit protects against username enumeration.
#[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(),
}
}
}
+124 -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,100 @@ 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. It intentionally acts as an availability oracle for
/// registration, unlike the `userByUsername` query which hides user
/// existence, 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 many `usernameAvailable` GraphQL queries are permitted\nbased on source address.\n\nThis query is an availability oracle usable during registration, so\nthis limit protects against username enumeration.",
"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 many `usernameAvailable` GraphQL queries are allowed,
# based on source address.
# This query is an availability oracle usable during registration, so this
# limit protects against username enumeration.
username_availability:
burst: 5
per_second: 1.0
```
## `telemetry`
+49
View File
@@ -1185,6 +1185,15 @@ 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. It intentionally acts as an availability oracle for
registration, unlike the `userByUsername` query which hides user
existence, so it is rate-limited per requester.
"""
usernameAvailable(username: String!): UsernameAvailability!
"""
Fetch a user by its username.
"""
userByUsername(username: String!): User
@@ -2470,6 +2479,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
"""