diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index aaf6a98ef..f0be50649 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -213,6 +213,8 @@ impl Options { homeserver_connection.clone(), site_config.clone(), password_manager.clone(), + http_client_factory.clone(), + url_builder.clone(), ); let state = { diff --git a/crates/handlers/src/captcha.rs b/crates/handlers/src/captcha.rs index b4a3a4396..db9d25dea 100644 --- a/crates/handlers/src/captcha.rs +++ b/crates/handlers/src/captcha.rs @@ -14,6 +14,7 @@ use std::net::IpAddr; +use async_graphql::InputObject; use axum::BoxError; use hyper::Request; use mas_axum_utils::http_client_factory::HttpClientFactory; @@ -58,8 +59,11 @@ pub enum Error { RequestFailed(#[source] BoxError), } +/// Form (or GraphQL input) containing a CAPTCHA provider's response +/// for one of the providers. #[allow(clippy::struct_field_names)] -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, InputObject)] +#[graphql(input_name = "CaptchaForm")] #[serde(rename_all = "kebab-case")] pub struct Form { g_recaptcha_response: Option, diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index f56d50943..98b2c8495 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -35,11 +35,13 @@ use futures_util::TryStreamExt; use headers::{authorization::Bearer, Authorization, ContentType, HeaderValue}; use hyper::header::CACHE_CONTROL; use mas_axum_utils::{ - cookies::CookieJar, sentry::SentryEventID, FancyError, SessionInfo, SessionInfoExt, + cookies::CookieJar, http_client_factory::HttpClientFactory, sentry::SentryEventID, FancyError, + SessionInfo, SessionInfoExt, }; -use mas_data_model::{BrowserSession, Session, SiteConfig, User}; +use mas_data_model::{BrowserSession, Session, SiteConfig, User, UserAgent}; use mas_matrix::HomeserverConnection; use mas_policy::{InstantiateError, Policy, PolicyFactory}; +use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng, Clock, RepositoryError, SystemClock}; use mas_storage_pg::PgRepository; use opentelemetry_semantic_conventions::trace::{GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME}; @@ -59,8 +61,11 @@ use self::{ model::{CreationEvent, Node}, mutations::Mutation, query::Query, + state::GraphQLCookieJar, +}; +use crate::{ + impl_from_error_for_route, passwords::PasswordManager, BoundActivityTracker, PreferredLanguage, }; -use crate::{impl_from_error_for_route, passwords::PasswordManager, BoundActivityTracker}; #[cfg(test)] mod tests; @@ -71,6 +76,8 @@ struct GraphQLState { policy_factory: Arc, site_config: SiteConfig, password_manager: PasswordManager, + http_client_factory: HttpClientFactory, + url_builder: UrlBuilder, } #[async_trait] @@ -111,6 +118,14 @@ impl state::State for GraphQLState { let rng = ChaChaRng::from_rng(rng).expect("Failed to seed rng"); Box::new(rng) } + + fn http_client_factory(&self) -> &HttpClientFactory { + &self.http_client_factory + } + + fn url_builder(&self) -> &UrlBuilder { + &self.url_builder + } } #[must_use] @@ -120,6 +135,8 @@ pub fn schema( homeserver_connection: impl HomeserverConnection + 'static, site_config: SiteConfig, password_manager: PasswordManager, + http_client_factory: HttpClientFactory, + url_builder: UrlBuilder, ) -> Schema { let state = GraphQLState { pool: pool.clone(), @@ -127,6 +144,8 @@ pub fn schema( homeserver_connection: Arc::new(homeserver_connection), site_config, password_manager, + http_client_factory, + url_builder, }; let state: BoxState = Box::new(state); @@ -281,31 +300,39 @@ async fn get_requester( pub async fn post( AxumState(schema): AxumState, + PreferredLanguage(locale): PreferredLanguage, clock: BoxClock, repo: BoxRepository, activity_tracker: BoundActivityTracker, cookie_jar: CookieJar, content_type: Option>, authorization: Option>>, + user_agent: Option>, body: Body, ) -> Result { let body = body.into_data_stream(); let token = authorization .as_ref() .map(|TypedHeader(Authorization(bearer))| bearer.token()); - let (session_info, _cookie_jar) = cookie_jar.session_info(); + let (session_info, cookie_jar) = cookie_jar.session_info(); let requester = get_requester(&clock, &activity_tracker, repo, session_info, token).await?; let content_type = content_type.map(|TypedHeader(h)| h.to_string()); + let gql_cookie_jar = Arc::new(GraphQLCookieJar::new(cookie_jar)); + let request = async_graphql::http::receive_body( content_type, body.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) .into_async_read(), MultipartOptions::default(), ) - .await? - .data(requester); // XXX: this should probably return another error response? + .await? // XXX: this should probably return another error response? + .data(requester) + .data(user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()))) + .data(locale) + .data(activity_tracker) + .data(gql_cookie_jar.clone()); let span = span_for_graphql_request(&request); let response = schema.execute(request).instrument(span).await; @@ -318,7 +345,10 @@ pub async fn post( let headers = response.http_headers.clone(); - Ok((headers, cache_control, Json(response))) + // unwrap: the cookie jar only has one reference (ours) after the request + let cookie_jar = Arc::into_inner(gql_cookie_jar).unwrap().into_inner(); + + Ok((headers, cache_control, cookie_jar, Json(response))) } pub async fn get( @@ -328,16 +358,22 @@ pub async fn get( activity_tracker: BoundActivityTracker, cookie_jar: CookieJar, authorization: Option>>, + user_agent: Option>, RawQuery(query): RawQuery, ) -> Result { let token = authorization .as_ref() .map(|TypedHeader(Authorization(bearer))| bearer.token()); - let (session_info, _cookie_jar) = cookie_jar.session_info(); + let (session_info, cookie_jar) = cookie_jar.session_info(); let requester = get_requester(&clock, &activity_tracker, repo, session_info, token).await?; - let request = - async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester); + let gql_cookie_jar = Arc::new(GraphQLCookieJar::new(cookie_jar)); + + let request = async_graphql::http::parse_query_string(&query.unwrap_or_default())? + .data(requester) + .data(activity_tracker) + .data(user_agent) + .data(gql_cookie_jar.clone()); let span = span_for_graphql_request(&request); let response = schema.execute(request).instrument(span).await; @@ -350,7 +386,10 @@ pub async fn get( let headers = response.http_headers.clone(); - Ok((headers, cache_control, Json(response))) + // unwrap: the cookie jar only has one reference (ours) after the request + let cookie_jar = Arc::into_inner(gql_cookie_jar).unwrap().into_inner(); + + Ok((headers, cache_control, cookie_jar, Json(response))) } pub async fn playground() -> impl IntoResponse { diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 7affdb20e..c8c19ac67 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -12,19 +12,26 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::str::FromStr; + use anyhow::Context as _; -use async_graphql::{Context, Description, Enum, InputObject, Object, ID}; +use async_graphql::{Context, Description, Enum, InputObject, Object, SimpleObject, ID}; +use lettre::Address; +use mas_axum_utils::SessionInfoExt; use mas_storage::{ - job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob}, + job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob, VerifyEmailJob}, user::UserRepository, }; use tracing::{info, warn}; use zeroize::Zeroizing; -use crate::graphql::{ - model::{NodeType, User}, - state::ContextExt, - Requester, UserId, +use crate::{ + captcha, + graphql::{ + model::{NodeType, User}, + state::ContextExt, + Requester, UserId, + }, }; #[derive(Default)] @@ -331,6 +338,98 @@ impl SetPasswordPayload { } } +/// The input for the `registerUser` mutation. +#[derive(InputObject)] +pub struct RegisterUserInput { + /// The desired username to be registered. + username: String, + + /// E-mail address to register on the account. + /// A verification e-mail will be sent here. + email: String, + + /// Password to set on the account, used for logging in. + password: String, + + captcha: captcha::Form, + + /// Accept the terms of service + accept_terms: bool, +} + +#[derive(Copy, Clone, Enum, Eq, PartialEq)] +enum RegisterField { + Email, + Username, +} + +#[derive(SimpleObject)] +struct RegisterViolation { + /// The field that this violation applies to, or `None` if the violation + /// is general. + field: Option, + /// A human-readable message describing the violation. + message: String, +} + +impl RegisterViolation { + pub fn new(field: impl Into>, message: impl Into) -> Self { + let field = field.into(); + let message = message.into(); + Self { field, message } + } +} + +/// The return type for the `registerUser` mutation. +#[derive(Description, SimpleObject)] +struct RegisterUserPayload { + status: RegisterUserStatus, + + /// Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + /// this is a list of violations preventing the registration. + violations: Vec, +} + +/// The status of the `registerUser` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum RegisterUserStatus { + /// The user was registered. + Allowed, + + /// The username is not valid. + InvalidUsername, + + /// The username is already in use or is otherwise reserved. + UsernameNotAvailable, + + /// The supplied password does not meet complexity requirements. + InvalidPassword, + + /// Must accept terms of service to register. + MustAcceptTerms, + + /// The supplied e-mail address is not valid. + InvalidEmail, + + /// Self-registration is not enabled. + SelfRegistrationDisabled, + + /// The CAPTCHA challenge response is not valid. + InvalidCaptcha, + + /// Local policy prevents this registration. + PolicyViolation, +} + +impl From for Result { + fn from(val: RegisterUserStatus) -> Self { + Ok(RegisterUserPayload { + status: val, + violations: Vec::new(), + }) + } +} + fn valid_username_character(c: char) -> bool { c.is_ascii_lowercase() || c.is_ascii_digit() @@ -766,4 +865,156 @@ impl UserMutations { status: SetPasswordStatus::Allowed, }) } + + /// Register a user. If enabled, can be used by anonymous requesters to + /// create an account. May require a CAPTCHA challenge to be completed. + #[allow(clippy::too_many_lines)] + async fn register_user( + &self, + ctx: &Context<'_>, + input: RegisterUserInput, + ) -> Result { + let state = ctx.state(); + let site_config = state.site_config(); + if !site_config.password_registration_enabled { + return RegisterUserStatus::SelfRegistrationDisabled.into(); + } + + let activity_tracker = ctx.activity_tracker(); + let cookie_jar = ctx.cookie_jar(); + let http_client_factory = state.http_client_factory(); + let url_builder = state.url_builder(); + let mut repo = state.repository().await?; + let mut policy = state.policy().await?; + let clock = state.clock(); + let homeserver = state.homeserver_connection(); + let password_manager = state.password_manager(); + let mut rng = state.rng(); + + // Validate the captcha + let passed_captcha = input + .captcha + .verify( + activity_tracker, + http_client_factory, + url_builder.public_hostname(), + site_config.captcha.as_ref(), + ) + .await + .is_ok(); + + if !passed_captcha { + return RegisterUserStatus::InvalidCaptcha.into(); + } + + if input.username.is_empty() || !username_valid(&input.username) { + return RegisterUserStatus::InvalidUsername.into(); + } else if repo.user().exists(&input.username).await? { + // The user already exists in the database + return RegisterUserStatus::UsernameNotAvailable.into(); + } else if !homeserver.is_localpart_available(&input.username).await? { + // The user already exists on the homeserver + // XXX: we may want to return different errors like "this username is reserved" + tracing::warn!( + username = &input.username, + "User tried to register with a reserved username" + ); + + return RegisterUserStatus::UsernameNotAvailable.into(); + } + + if input.email.is_empty() || Address::from_str(&input.email).is_err() { + return RegisterUserStatus::InvalidEmail.into(); + } + + if input.password.is_empty() + || !password_manager.is_password_complex_enough(&input.password)? + { + return RegisterUserStatus::InvalidPassword.into(); + } + + // If the site has terms of service, the user must accept them + if site_config.tos_uri.is_some() && !input.accept_terms { + return RegisterUserStatus::MustAcceptTerms.into(); + } + + let res = policy + .evaluate_register(&input.username, &input.email) + .await?; + + if !res.violations.is_empty() { + let mut violations = Vec::new(); + for violation in res.violations { + match violation.field.as_deref() { + Some("email") => { + violations + .push(RegisterViolation::new(RegisterField::Email, violation.msg)); + } + Some("username") => { + violations.push(RegisterViolation::new( + RegisterField::Username, + violation.msg, + )); + } + _ => { + violations.push(RegisterViolation::new(None, violation.msg)); + } + } + } + + return Ok(RegisterUserPayload { + status: RegisterUserStatus::PolicyViolation, + violations, + }); + } + + let user = repo.user().add(&mut rng, &clock, input.username).await?; + + if let Some(tos_uri) = &site_config.tos_uri { + repo.user_terms() + .accept_terms(&mut rng, &clock, &user, tos_uri.clone()) + .await?; + } + + let password = Zeroizing::new(input.password.into_bytes()); + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + let user_password = repo + .user_password() + .add(&mut rng, &clock, &user, version, hashed_password, None) + .await?; + + let user_email = repo + .user_email() + .add(&mut rng, &clock, &user, input.email) + .await?; + + let session = repo + .browser_session() + .add(&mut rng, &clock, &user, ctx.user_agent().cloned()) + .await?; + + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &session, &user_password) + .await?; + + repo.job() + .schedule_job( + VerifyEmailJob::new(&user_email).with_language(ctx.preferred_locale().to_string()), + ) + .await?; + + repo.job() + .schedule_job(ProvisionUserJob::new(&user)) + .await?; + + repo.save().await?; + + activity_tracker + .record_browser_session(&clock, &session) + .await; + + cookie_jar.with(|jar| jar.set_session(&session)); + + RegisterUserStatus::Allowed.into() + } } diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index 86797015c..9d57a8a20 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -12,12 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -use mas_data_model::SiteConfig; +use std::sync::{Arc, Mutex}; + +use mas_axum_utils::{cookies::CookieJar, http_client_factory::HttpClientFactory}; +use mas_data_model::{SiteConfig, UserAgent}; +use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; use mas_policy::Policy; +use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryError}; -use crate::{graphql::Requester, passwords::PasswordManager}; +use crate::{graphql::Requester, passwords::PasswordManager, BoundActivityTracker}; #[async_trait::async_trait] pub trait State { @@ -28,6 +33,8 @@ pub trait State { fn clock(&self) -> BoxClock; fn rng(&self) -> BoxRng; fn site_config(&self) -> &SiteConfig; + fn http_client_factory(&self) -> &HttpClientFactory; + fn url_builder(&self) -> &UrlBuilder; } pub type BoxState = Box; @@ -36,6 +43,20 @@ pub trait ContextExt { fn state(&self) -> &BoxState; fn requester(&self) -> &Requester; + + /// Get the parsed user agent of the client making the request. + /// Not guaranteed to be present. + fn user_agent(&self) -> Option<&UserAgent>; + + /// Get the preferred language/locale of the client making the request. + fn preferred_locale(&self) -> &DataLocale; + + /// Get the activity tracker bound to the requester. + fn activity_tracker(&self) -> &BoundActivityTracker; + + /// Get a wrapper for the cookie jar, which can be used to view and set + /// cookies. + fn cookie_jar(&self) -> &GraphQLCookieJar; } impl ContextExt for async_graphql::Context<'_> { @@ -46,4 +67,68 @@ impl ContextExt for async_graphql::Context<'_> { fn requester(&self) -> &Requester { self.data_unchecked() } + + fn user_agent(&self) -> Option<&UserAgent> { + self.data_unchecked::>().as_ref() + } + + fn preferred_locale(&self) -> &DataLocale { + self.data_unchecked() + } + + fn activity_tracker(&self) -> &BoundActivityTracker { + self.data_unchecked() + } + + fn cookie_jar(&self) -> &GraphQLCookieJar { + // This Arc should never be cloned, as the request must not have any strong + // references to it after the request is finished. This way, the request + // handling code can unwrap the Arc afterwards and send the cookies to the HTTP + // client. + self.data_unchecked::>() + } +} + +pub struct GraphQLCookieJar { + /// The underlying cookie jar. + /// The cookie jar is always present, + /// the option is just so we can borrow it temporarily but it should always + /// be returned immediately. + jar: Mutex>, +} + +impl GraphQLCookieJar { + /// Create a new wrapper for the cookie jar + pub fn new(jar: CookieJar) -> Self { + Self { + jar: Mutex::new(Some(jar)), + } + } + + /// Unwrap the cookie jar + pub fn into_inner(self) -> CookieJar { + // unwrap: the cookie jar is always present and we don't care about handling + // poisoned mutexes + self.jar.into_inner().unwrap().unwrap() + } + + /// Operate on the cookie jar, by taking it and replacing it with a new + /// (modified) one. + pub fn with(&self, f: impl FnOnce(CookieJar) -> CookieJar) { + // unwrap: poisoned mutexes are not worth handling + let mut jar_guard = self.jar.lock().unwrap(); + // unwrap: the cookie jar is always present + let jar = jar_guard.take().unwrap(); + *jar_guard = Some(f(jar)); + } + + /// Access (read-only) the cookie jar + #[allow(dead_code)] + pub fn inspect(&self, f: impl FnOnce(&CookieJar) -> T) -> T { + // unwrap: poisoned mutexes are not worth handling + let jar_guard = self.jar.lock().unwrap(); + // unwrap: the cookie jar is always present + let jar = jar_guard.as_ref().unwrap(); + f(jar) + } } diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index 23e083ac1..4c1e254ba 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -786,3 +786,46 @@ async fn test_add_user(pool: PgPool) { }) ); } + +/// Test the registerUser mutation +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_register_user(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // We should now be able to call the registerUser mutation + let request = Request::post("/graphql") + .json(serde_json::json!({ + "query": r#" + mutation { + registerUser(input: {username: "alice", email: "alice@example.org", captcha: {}, password: "correct horse battery staple", acceptTerms: true}) { + status + } + } + "#, + })); + 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, + serde_json::json!({ + "registerUser": { + "status": "ALLOWED", + } + }) + ); + + // Check the user exists in the database + let _ = state + .repository() + .await + .unwrap() + .user() + .find_by_username("alice") + .await + .unwrap() + .expect("alice doesn't exist"); +} diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 28375cbee..4fd3a1536 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -117,6 +117,7 @@ where BoxClock: FromRequestParts, Encrypter: FromRef, CookieJar: FromRequestParts, + PreferredLanguage: FromRequestParts, { let mut router = Router::new() .route( diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 497e11170..c62fd71de 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -206,6 +206,8 @@ impl TestState { rng: Arc::clone(&rng), clock: Arc::clone(&clock), password_manager: password_manager.clone(), + http_client_factory: http_client_factory.clone(), + url_builder: url_builder.clone(), }; let state: crate::graphql::BoxState = Box::new(graphql_state); @@ -368,6 +370,8 @@ struct TestGraphQLState { clock: Arc, rng: Arc>, password_manager: PasswordManager, + http_client_factory: HttpClientFactory, + url_builder: UrlBuilder, } #[async_trait] @@ -405,6 +409,13 @@ impl graphql::State for TestGraphQLState { let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG"); Box::new(rng) } + + fn http_client_factory(&self) -> &HttpClientFactory { + &self.http_client_factory + } + fn url_builder(&self) -> &UrlBuilder { + &self.url_builder + } } impl FromRef for PgPool { diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 46a14a2ba..a3e00ee8c 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -312,6 +312,16 @@ type CaptchaConfig { id: ID! } +""" +Form (or GraphQL input) containing a CAPTCHA provider's response +for one of the providers. +""" +input CaptchaForm { + gRecaptchaResponse: String + hCaptchaResponse: String + cfTurnstileResponse: String +} + """ Which Captcha service is being used """ @@ -804,6 +814,11 @@ type Mutation { """ setPasswordByRecovery(input: SetPasswordByRecoveryInput!): SetPasswordPayload! """ + Register a user. If enabled, can be used by anonymous requesters to + create an account. May require a CAPTCHA challenge to be completed. + """ + registerUser(input: RegisterUserInput!): RegisterUserPayload! + """ Create a new arbitrary OAuth 2.0 Session. Only available for administrators. @@ -1119,6 +1134,95 @@ type Query { viewerSession: ViewerSession! } +""" +The input for the `registerUser` mutation. +""" +input RegisterUserInput { + """ + The desired username to be registered. + """ + username: String! + """ + E-mail address to register on the account. + A verification e-mail will be sent here. + """ + email: String! + """ + Password to set on the account, used for logging in. + """ + password: String! + captcha: CaptchaForm! + """ + Accept the terms of service + """ + acceptTerms: Boolean! +} + +""" +The return type for the `registerUser` mutation. +""" +type RegisterUserPayload { + status: RegisterUserStatus! + """ + Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + this is a list of miscellaneous violations not related to a specific + field. + """ + miscViolations: [String!]! + """ + Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + this is a list of violations related to the username. + """ + usernameViolations: [String!]! + """ + Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + this is a list of violations related to the e-mail address. + """ + emailViolations: [String!]! +} + +""" +The status of the `registerUser` mutation. +""" +enum RegisterUserStatus { + """ + The user was registered. + """ + ALLOWED + """ + The username is not valid. + """ + INVALID_USERNAME + """ + The username is already in use or is otherwise reserved. + """ + USERNAME_NOT_AVAILABLE + """ + The supplied password does not meet complexity requirements. + """ + INVALID_PASSWORD + """ + Must accept terms of service to register. + """ + MUST_ACCEPT_TERMS + """ + The supplied e-mail address is not valid. + """ + INVALID_EMAIL + """ + Self-registration is not enabled. + """ + SELF_REGISTRATION_DISABLED + """ + The CAPTCHA challenge response is not valid. + """ + INVALID_CAPTCHA + """ + Local policy prevents this registration. + """ + POLICY_VIOLATION +} + """ The input for the `removeEmail` mutation """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index d14a4ba55..2085baa08 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -221,6 +221,16 @@ export type CaptchaConfig = { siteKey: Scalars['String']['output']; }; +/** + * Form (or GraphQL input) containing a CAPTCHA provider's response + * for one of the providers. + */ +export type CaptchaForm = { + cfTurnstileResponse?: InputMaybe; + gRecaptchaResponse?: InputMaybe; + hCaptchaResponse?: InputMaybe; +}; + /** Which Captcha service is being used */ export enum CaptchaService { CloudflareTurnstile = 'CLOUDFLARE_TURNSTILE', @@ -501,6 +511,11 @@ export type Mutation = { endOauth2Session: EndOAuth2SessionPayload; /** Lock a user. This is only available to administrators. */ lockUser: LockUserPayload; + /** + * Register a user. If enabled, can be used by anonymous requesters to + * create an account. May require a CAPTCHA challenge to be completed. + */ + registerUser: RegisterUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; /** Send a verification code for an email address */ @@ -580,6 +595,12 @@ export type MutationLockUserArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationRegisterUserArgs = { + input: RegisterUserInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationRemoveEmailArgs = { input: RemoveEmailInput; @@ -874,6 +895,66 @@ export type QueryUsersArgs = { state?: InputMaybe; }; +/** The input for the `registerUser` mutation. */ +export type RegisterUserInput = { + /** Accept the terms of service */ + acceptTerms: Scalars['Boolean']['input']; + captcha: CaptchaForm; + /** + * E-mail address to register on the account. + * A verification e-mail will be sent here. + */ + email: Scalars['String']['input']; + /** Password to set on the account, used for logging in. */ + password: Scalars['String']['input']; + /** The desired username to be registered. */ + username: Scalars['String']['input']; +}; + +/** The return type for the `registerUser` mutation. */ +export type RegisterUserPayload = { + __typename?: 'RegisterUserPayload'; + /** + * Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + * this is a list of violations related to the e-mail address. + */ + emailViolations: Array; + /** + * Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + * this is a list of miscellaneous violations not related to a specific + * field. + */ + miscViolations: Array; + status: RegisterUserStatus; + /** + * Set when the `status` is [`RegisterUserStatus::PolicyViolation`], + * this is a list of violations related to the username. + */ + usernameViolations: Array; +}; + +/** The status of the `registerUser` mutation. */ +export enum RegisterUserStatus { + /** The user was registered. */ + Allowed = 'ALLOWED', + /** The CAPTCHA challenge response is not valid. */ + InvalidCaptcha = 'INVALID_CAPTCHA', + /** The supplied e-mail address is not valid. */ + InvalidEmail = 'INVALID_EMAIL', + /** The supplied password does not meet complexity requirements. */ + InvalidPassword = 'INVALID_PASSWORD', + /** The username is not valid. */ + InvalidUsername = 'INVALID_USERNAME', + /** Must accept terms of service to register. */ + MustAcceptTerms = 'MUST_ACCEPT_TERMS', + /** Local policy prevents this registration. */ + PolicyViolation = 'POLICY_VIOLATION', + /** Self-registration is not enabled. */ + SelfRegistrationDisabled = 'SELF_REGISTRATION_DISABLED', + /** The username is already in use or is otherwise reserved. */ + UsernameNotAvailable = 'USERNAME_NOT_AVAILABLE' +} + /** The input for the `removeEmail` mutation */ export type RemoveEmailInput = { /** The ID of the email address to remove */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index db64b98c2..babd33dfb 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -1381,6 +1381,29 @@ export default { } ] }, + { + "name": "registerUser", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "RegisterUserPayload", + "ofType": null + } + }, + "args": [ + { + "name": "input", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + } + } + ] + }, { "name": "removeEmail", "type": { @@ -2426,6 +2449,75 @@ export default { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "RegisterUserPayload", + "fields": [ + { + "name": "emailViolations", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + } + } + }, + "args": [] + }, + { + "name": "miscViolations", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + } + } + }, + "args": [] + }, + { + "name": "status", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + }, + "args": [] + }, + { + "name": "usernameViolations", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + } + } + }, + "args": [] + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "RemoveEmailPayload",