diff --git a/Cargo.lock b/Cargo.lock index 4222007d9..96508d285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3141,6 +3141,7 @@ dependencies = [ "axum", "bytes", "camino", + "chrono", "clap", "console", "dialoguer", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 55def8cea..49dc86737 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,6 +18,7 @@ anyhow.workspace = true axum.workspace = true bytes.workspace = true camino.workspace = true +chrono.workspace = true clap.workspace = true console = "0.15.11" dialoguer = { version = "0.11.0", default-features = false, features = [ diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index b52c56162..d2f13f0c5 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -7,6 +7,7 @@ use std::{collections::BTreeMap, process::ExitCode}; use anyhow::Context; +use chrono::Duration; use clap::{ArgAction, CommandFactory, Parser}; use console::{Alignment, Style, Term, pad_str, style}; use dialoguer::{Confirm, FuzzySelect, Input, Password, theme::ColorfulTheme}; @@ -28,7 +29,10 @@ use mas_storage::{ user::{BrowserSessionFilter, UserEmailRepository, UserPasswordRepository, UserRepository}, }; use mas_storage_pg::{DatabaseError, PgRepository}; -use rand::{RngCore, SeedableRng}; +use rand::{ + RngCore, SeedableRng, + distributions::{Alphanumeric, DistString as _}, +}; use sqlx::{Acquire, types::Uuid}; use tracing::{error, info, info_span, warn}; use zeroize::Zeroizing; @@ -95,6 +99,29 @@ enum Subcommand { admin: bool, }, + /// Create a new user registration token + IssueUserRegistrationToken { + /// Specific token string to use. If not provided, a random token will + /// be generated. + #[arg(long)] + token: Option, + + /// Maximum number of times this token can be used. + /// If not provided, the token can be used only once, unless the + /// `--unlimited` flag is set. + #[arg(long, group = "token-usage-limit")] + usage_limit: Option, + + /// Allow the token to be used an unlimited number of times. + #[arg(long, action = ArgAction::SetTrue, group = "token-usage-limit")] + unlimited: bool, + + /// Time in seconds after which the token expires. + /// If not provided, the token never expires. + #[arg(long)] + expires_in: Option, + }, + /// Trigger a provisioning job for all users ProvisionAllUsers, @@ -330,6 +357,46 @@ impl Options { Ok(ExitCode::SUCCESS) } + SC::IssueUserRegistrationToken { + token, + usage_limit, + unlimited, + expires_in, + } => { + let _span = info_span!("cli.manage.add_user_registration_token").entered(); + + let usage_limit = match (usage_limit, unlimited) { + (Some(usage_limit), false) => Some(usage_limit), + (None, false) => Some(1), + (None, true) => None, + (Some(_), true) => unreachable!(), // This should be handled by the clap group + }; + + let database_config = DatabaseConfig::extract_or_default(figment)?; + let mut conn = database_connection_from_config(&database_config).await?; + let txn = conn.begin().await?; + let mut repo = PgRepository::from_conn(txn); + + // Calculate expiration time if provided + let expires_at = + expires_in.map(|seconds| clock.now() + Duration::seconds(seconds.into())); + + // Generate a token if not provided + let token_str = token.unwrap_or_else(|| Alphanumeric.sample_string(&mut rng, 12)); + + // Create the token + let registration_token = repo + .user_registration_token() + .add(&mut rng, &clock, token_str, usage_limit, expires_at) + .await?; + + repo.into_inner().commit().await?; + + info!(%registration_token.id, "Created user registration token: {}", registration_token.token); + + Ok(ExitCode::SUCCESS) + } + SC::ProvisionAllUsers => { let _span = info_span!("cli.manage.provision_all_users").entered(); let database_config = DatabaseConfig::extract_or_default(figment)?; diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 3588b6895..70bffa3cd 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -211,6 +211,7 @@ pub fn site_config_from_config( password_login_enabled: password_config.enabled(), password_registration_enabled: password_config.enabled() && account_config.password_registration_enabled, + registration_token_required: account_config.registration_token_required, email_change_allowed: account_config.email_change_allowed, displayname_change_allowed: account_config.displayname_change_allowed, password_change_allowed: password_config.enabled() diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index 28733c7ef..a9d51afbb 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -72,6 +72,15 @@ pub struct AccountConfig { /// This has no effect if password login is disabled. #[serde(default = "default_false", skip_serializing_if = "is_default_false")] pub login_with_email_allowed: bool, + + /// Whether registration tokens are required for password registrations. + /// Defaults to `false`. + /// + /// When enabled, users must provide a valid registration token during + /// password registration. This has no effect if password registration + /// is disabled. + #[serde(default = "default_false", skip_serializing_if = "is_default_false")] + pub registration_token_required: bool, } impl Default for AccountConfig { @@ -84,6 +93,7 @@ impl Default for AccountConfig { password_recovery_enabled: default_false(), account_deactivation_allowed: default_true(), login_with_email_allowed: default_false(), + registration_token_required: default_false(), } } } @@ -98,6 +108,7 @@ impl AccountConfig { && is_default_false(&self.password_recovery_enabled) && is_default_true(&self.account_deactivation_allowed) && is_default_false(&self.login_with_email_allowed) + && is_default_false(&self.registration_token_required) } } diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 8477222c5..af4d0be37 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -50,6 +50,6 @@ pub use self::{ users::{ Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, - UserRecoveryTicket, UserRegistration, UserRegistrationPassword, + UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, }, }; diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index de07a03c5..e9cf6ba0e 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -64,6 +64,9 @@ pub struct SiteConfig { /// Whether password registration is enabled. pub password_registration_enabled: bool, + /// Whether registration tokens are required for password registrations. + pub registration_token_required: bool, + /// Whether users can change their email. pub email_change_allowed: bool, diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 7e40f4df2..fc6ed2695 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -201,6 +201,52 @@ pub struct UserRegistrationPassword { pub version: u16, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserRegistrationToken { + pub id: Ulid, + pub token: String, + pub usage_limit: Option, + pub times_used: u32, + pub created_at: DateTime, + pub last_used_at: Option>, + pub expires_at: Option>, + pub revoked_at: Option>, +} + +impl UserRegistrationToken { + /// Returns `true` if the token is still valid and can be used + #[must_use] + pub fn is_valid(&self, now: DateTime) -> bool { + // Check if revoked + if self.revoked_at.is_some() { + return false; + } + + // Check if expired + if let Some(expires_at) = self.expires_at { + if now >= expires_at { + return false; + } + } + + // Check if usage limit exceeded + if let Some(usage_limit) = self.usage_limit { + if self.times_used >= usage_limit { + return false; + } + } + + true + } + + /// Returns `true` if the token can still be used (not expired and under + /// usage limit) + #[must_use] + pub fn can_be_used(&self, now: DateTime) -> bool { + self.is_valid(now) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UserRegistration { pub id: Ulid, @@ -208,6 +254,7 @@ pub struct UserRegistration { pub display_name: Option, pub terms_url: Option, pub email_authentication_id: Option, + pub user_registration_token_id: Option, pub password: Option, pub post_auth_action: Option, pub ip_address: Option, diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index f0187e246..6890848ac 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -73,6 +73,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { description: Some("Manage browser sessions of users".to_owned()), ..Tag::default() }) + .tag(Tag { + name: "user-registration-token".to_owned(), + description: Some("Manage user registration tokens".to_owned()), + ..Tag::default() + }) .tag(Tag { name: "upstream-oauth-link".to_owned(), description: Some( diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 31478d47e..03fd72ad0 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -602,3 +602,89 @@ impl PolicyData { }] } } + +/// A registration token +#[derive(Serialize, JsonSchema)] +pub struct UserRegistrationToken { + #[serde(skip)] + id: Ulid, + + /// The token string + token: String, + + /// Whether the token is valid + valid: bool, + + /// Maximum number of times this token can be used + usage_limit: Option, + + /// Number of times this token has been used + times_used: u32, + + /// When the token was created + created_at: DateTime, + + /// When the token was last used. If null, the token has never been used. + last_used_at: Option>, + + /// When the token expires. If null, the token never expires. + expires_at: Option>, + + /// When the token was revoked. If null, the token is not revoked. + revoked_at: Option>, +} + +impl UserRegistrationToken { + pub fn new(token: mas_data_model::UserRegistrationToken, now: DateTime) -> Self { + Self { + id: token.id, + valid: token.is_valid(now), + token: token.token, + usage_limit: token.usage_limit, + times_used: token.times_used, + created_at: token.created_at, + last_used_at: token.last_used_at, + expires_at: token.expires_at, + revoked_at: token.revoked_at, + } + } +} + +impl Resource for UserRegistrationToken { + const KIND: &'static str = "user-registration_token"; + const PATH: &'static str = "/api/admin/v1/user-registration-tokens"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl UserRegistrationToken { + /// Samples of registration tokens + pub fn samples() -> [Self; 2] { + [ + Self { + id: Ulid::from_bytes([0x01; 16]), + token: "abc123def456".to_owned(), + valid: true, + usage_limit: Some(10), + times_used: 5, + created_at: DateTime::default(), + last_used_at: Some(DateTime::default()), + expires_at: Some(DateTime::default() + chrono::Duration::days(30)), + revoked_at: None, + }, + Self { + id: Ulid::from_bytes([0x02; 16]), + token: "xyz789abc012".to_owned(), + valid: false, + usage_limit: None, + times_used: 0, + created_at: DateTime::default(), + last_used_at: None, + expires_at: None, + revoked_at: Some(DateTime::default()), + }, + ] + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 789bb6759..022aabdc3 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -23,6 +23,7 @@ mod oauth2_sessions; mod policy_data; mod upstream_oauth_links; mod user_emails; +mod user_registration_tokens; mod user_sessions; mod users; @@ -119,6 +120,31 @@ where "/user-sessions/{id}", get_with(self::user_sessions::get, self::user_sessions::get_doc), ) + .api_route( + "/user-registration-tokens", + get_with( + self::user_registration_tokens::list, + self::user_registration_tokens::list_doc, + ) + .post_with( + self::user_registration_tokens::add, + self::user_registration_tokens::add_doc, + ), + ) + .api_route( + "/user-registration-tokens/{id}", + get_with( + self::user_registration_tokens::get, + self::user_registration_tokens::get_doc, + ), + ) + .api_route( + "/user-registration-tokens/{id}/revoke", + post_with( + self::user_registration_tokens::revoke, + self::user_registration_tokens::revoke_doc, + ), + ) .api_route( "/upstream-oauth-links", get_with( diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs new file mode 100644 index 000000000..fdb958f58 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs @@ -0,0 +1,199 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::{DateTime, Utc}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::BoxRng; +use rand::distributions::{Alphanumeric, DistString}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::UserRegistrationToken, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/user-registration-tokens` +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "AddUserRegistrationTokenRequest")] +pub struct Request { + /// The token string. If not provided, a random token will be generated. + token: Option, + + /// Maximum number of times this token can be used. If not provided, the + /// token can be used an unlimited number of times. + usage_limit: Option, + + /// When the token expires. If not provided, the token never expires. + expires_at: Option>, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("addUserRegistrationToken") + .summary("Create a new user registration token") + .tag("user-registration-token") + .response_with::<201, Json>, _>(|t| { + let [sample, ..] = UserRegistrationToken::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("A new user registration token was created") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.post", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + // Generate a random token if none was provided + let token = params + .token + .unwrap_or_else(|| Alphanumeric.sample_string(&mut rng, 12)); + + let registration_token = repo + .user_registration_token() + .add( + &mut rng, + &clock, + token, + params.usage_limit, + params.expires_at, + ) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical(UserRegistrationToken::new( + registration_token, + clock.now(), + ))), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/user-registration-tokens") + .bearer(&token) + .json(serde_json::json!({ + "token": "test_token_123", + "usage_limit": 5, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_token_123", + "valid": true, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_auto_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/user-registration-tokens") + .bearer(&token) + .json(serde_json::json!({ + "usage_limit": 1 + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0QMGC989M0XSFVF2X", + "attributes": { + "token": "42oTpLoieH5I", + "valid": true, + "usage_limit": 1, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0QMGC989M0XSFVF2X" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0QMGC989M0XSFVF2X" + } + } + "#); + } +} diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs new file mode 100644 index 000000000..833e3b17c --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs @@ -0,0 +1,174 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::UserRegistrationToken, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Registration token with ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getUserRegistrationToken") + .summary("Get a user registration token") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = UserRegistrationToken::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Registration token was found") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Registration token was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.get", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let token = repo + .user_registration_token() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + Ok(Json(SingleResponse::new_canonical( + UserRegistrationToken::new(token, clock.now()), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_token_123".to_owned(), + Some(5), + None, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_token_123", + "valid": true, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_nonexistent_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Use a fixed ID for the test to ensure consistent snapshots + let nonexistent_id = Ulid::from_string("00000000000000000000000000").unwrap(); + let request = Request::get(format!( + "/api/admin/v1/user-registration-tokens/{nonexistent_id}" + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs new file mode 100644 index 000000000..85a1a1945 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs @@ -0,0 +1,1174 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{ + Json, + extract::{Query, rejection::QueryRejection}, + response::IntoResponse, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::{Page, user::UserRegistrationTokenFilter}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserRegistrationToken}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "RegistrationTokenFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve tokens that have (or have not) been used at least once + #[serde(rename = "filter[used]")] + used: Option, + + /// Retrieve tokens that are (or are not) revoked + #[serde(rename = "filter[revoked]")] + revoked: Option, + + /// Retrieve tokens that are (or are not) expired + #[serde(rename = "filter[expired]")] + expired: Option, + + /// Retrieve tokens that are (or are not) valid + /// + /// Valid means that the token has not expired, is not revoked, and has not + /// reached its usage limit. + #[serde(rename = "filter[valid]")] + valid: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(used) = self.used { + write!(f, "{sep}filter[used]={used}")?; + sep = '&'; + } + if let Some(revoked) = self.revoked { + write!(f, "{sep}filter[revoked]={revoked}")?; + sep = '&'; + } + if let Some(expired) = self.expired { + write!(f, "{sep}filter[expired]={expired}")?; + sep = '&'; + } + if let Some(valid) = self.valid { + write!(f, "{sep}filter[valid]={valid}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listUserRegistrationTokens") + .summary("List user registration tokens") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + let tokens = UserRegistrationToken::samples(); + let pagination = mas_storage::Pagination::first(tokens.len()); + let page = Page { + edges: tokens.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of registration tokens") + .example(PaginatedResponse::new( + page, + pagination, + 42, + UserRegistrationToken::PATH, + )) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.registration_tokens.list", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UserRegistrationToken::PATH); + let now = clock.now(); + let mut filter = UserRegistrationTokenFilter::new(now); + + if let Some(used) = params.used { + filter = filter.with_been_used(used); + } + + if let Some(revoked) = params.revoked { + filter = filter.with_revoked(revoked); + } + + if let Some(expired) = params.expired { + filter = filter.with_expired(expired); + } + + if let Some(valid) = params.valid { + filter = filter.with_valid(valid); + } + + let page = repo + .user_registration_token() + .list(filter, pagination) + .await?; + let count = repo.user_registration_token().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(|token| UserRegistrationToken::new(token, now)), + pagination, + count, + &base, + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_storage::Clock as _; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + async fn create_test_tokens(state: &mut TestState) { + let mut repo = state.repository().await.unwrap(); + + // Token 1: Never used, not revoked + repo.user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_unused".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + + // Token 2: Used, not revoked + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_used".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + repo.user_registration_token() + .use_token(&state.clock, token) + .await + .unwrap(); + + // Token 3: Never used, revoked + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_revoked".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + repo.user_registration_token() + .revoke(&state.clock, token) + .await + .unwrap(); + + // Token 4: Used, revoked + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_used_revoked".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + let token = repo + .user_registration_token() + .use_token(&state.clock, token) + .await + .unwrap(); + repo.user_registration_token() + .revoke(&state.clock, token) + .await + .unwrap(); + + // Token 5: Expired token + let expires_at = state.clock.now() - Duration::try_days(1).unwrap(); + repo.user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_expired".to_owned(), + Some(5), + Some(expires_at), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_all_tokens(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + let request = Request::get("/api/admin/v1/user-registration-tokens") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_used(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for used tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[used]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[used]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[used]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[used]=true&page[last]=10" + } + } + "#); + + // Filter for unused tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[used]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[used]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[used]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[used]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_revoked(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for revoked tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[revoked]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&page[last]=10" + } + } + "#); + + // Filter for non-revoked tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[revoked]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[revoked]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[revoked]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[revoked]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_expired(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for expired tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[expired]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[expired]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[expired]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[expired]=true&page[last]=10" + } + } + "#); + + // Filter for non-expired tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[expired]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 4 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[expired]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[expired]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[expired]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_valid(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for valid tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[valid]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[valid]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[valid]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[valid]=true&page[last]=10" + } + } + "#); + + // Filter for invalid tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[valid]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[valid]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[valid]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[valid]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_combined_filters(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for used AND revoked tokens + let request = Request::get( + "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true", + ) + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_pagination(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Request with pagination (2 per page) + let request = Request::get("/api/admin/v1/user-registration-tokens?page[first]=2") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[first]=2", + "first": "/api/admin/v1/user-registration-tokens?page[first]=2", + "last": "/api/admin/v1/user-registration-tokens?page[last]=2", + "next": "/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG07HNEZXNQM2KNBNF6&page[first]=2" + } + } + "#); + + // Request second page + let request = Request::get("/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG07HNEZXNQM2KNBNF6&page[first]=2") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG07HNEZXNQM2KNBNF6&page[first]=2", + "first": "/api/admin/v1/user-registration-tokens?page[first]=2", + "last": "/api/admin/v1/user-registration-tokens?page[last]=2", + "next": "/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=2" + } + } + "#); + + // Request last item + let request = Request::get("/api/admin/v1/user-registration-tokens?page[last]=1") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[last]=1", + "first": "/api/admin/v1/user-registration-tokens?page[first]=1", + "last": "/api/admin/v1/user-registration-tokens?page[last]=1", + "prev": "/api/admin/v1/user-registration-tokens?page[before]=01FSHN9AG0S3ZJD8CXQ7F11KXN&page[last]=1" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invalid_filter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + + // Try with invalid filter value + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[used]=invalid") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + + let body: serde_json::Value = response.json(); + assert!( + body["errors"][0]["title"] + .as_str() + .unwrap() + .contains("Invalid filter parameters") + ); + } +} diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs new file mode 100644 index 000000000..ea149d517 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -0,0 +1,16 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod add; +mod get; +mod list; +mod revoke; + +pub use self::{ + add::{doc as add_doc, handler as add}, + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, + revoke::{doc as revoke_doc, handler as revoke}, +}; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs new file mode 100644 index 000000000..e649cfef8 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs @@ -0,0 +1,217 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserRegistrationToken}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Registration token with ID {0} not found")] + NotFound(Ulid), + + #[error("Registration token with ID {0} is already revoked")] + AlreadyRevoked(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyRevoked(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("revokeUserRegistrationToken") + .summary("Revoke a user registration token") + .description("Calling this endpoint will revoke the user registration token, preventing it from being used for new registrations.") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + // Get the revoked token sample + let [_, revoked_token] = UserRegistrationToken::samples(); + let id = revoked_token.id(); + let response = SingleResponse::new(revoked_token, format!("/api/admin/v1/user-registration-tokens/{id}/revoke")); + t.description("Registration token was revoked").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyRevoked(Ulid::nil())); + t.description("Token is already revoked").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Registration token was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.revoke", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let token = repo + .user_registration_token() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the token is already revoked + if token.revoked_at.is_some() { + return Err(RouteError::AlreadyRevoked(id)); + } + + // Revoke the token + let token = repo.user_registration_token().revoke(&clock, token).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + UserRegistrationToken::new(token, clock.now()), + format!("/api/admin/v1/user-registration-tokens/{id}/revoke"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_storage::Clock as _; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_token_456".to_owned(), + Some(5), + None, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/user-registration-tokens/{}/revoke", + registration_token.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The revoked_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["revoked_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_already_revoked_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_token_789".to_owned(), + None, + None, + ) + .await + .unwrap(); + + // Revoke the token first + let registration_token = repo + .user_registration_token() + .revoke(&state.clock, registration_token) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/user-registration-tokens/{}/revoke", + registration_token.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!( + "Registration token with ID {} is already revoked", + registration_token.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_unknown_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post( + "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081/revoke", + ) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Registration token with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 4b3a842c8..0605d6cd6 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -392,6 +392,11 @@ where get(self::views::register::steps::verify_email::get) .post(self::views::register::steps::verify_email::post), ) + .route( + mas_router::RegisterToken::route(), + get(self::views::register::steps::registration_token::get) + .post(self::views::register::steps::registration_token::post), + ) .route( mas_router::RegisterDisplayName::route(), get(self::views::register::steps::display_name::get) diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index cdbc981d1..81dbf1740 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -136,6 +136,7 @@ pub fn test_site_config() -> SiteConfig { imprint: None, password_login_enabled: true, password_registration_enabled: true, + registration_token_required: false, email_change_allowed: true, displayname_change_allowed: true, password_change_allowed: true, diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index fd0736b29..55fb47e71 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -13,6 +13,7 @@ use axum::{ use axum_extra::TypedHeader; use chrono::Duration; use mas_axum_utils::{InternalError, SessionInfoExt as _, cookies::CookieJar}; +use mas_data_model::SiteConfig; use mas_matrix::HomeserverConnection; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ @@ -51,6 +52,7 @@ pub(crate) async fn get( State(url_builder): State, State(homeserver): State>, State(templates): State, + State(site_config): State, PreferredLanguage(lang): PreferredLanguage, cookie_jar: CookieJar, Path(id): Path, @@ -118,6 +120,37 @@ pub(crate) async fn get( ))); } + // 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 { + let registration_token = repo + .user_registration_token() + .lookup(registration_token_id) + .await? + .context("Could not load the registration token") + .map_err(InternalError::from_anyhow)?; + + if !registration_token.is_valid(clock.now()) { + // XXX: the registration token isn't valid anymore, we should + // have a better error in this case? + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Registration token used is no longer valid" + ))); + } + + Some(registration_token) + } else { + // Else redirect to the registration token page + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterToken::new(registration.id)), + ) + .into_response()); + } + } else { + None + }; + // For now, we require an email address on the registration, but this might // change in the future let email_authentication_id = registration @@ -174,12 +207,19 @@ pub(crate) async fn get( .into_response()); } - // Everuthing is good, let's complete the registration + // Everything is good, let's complete the registration let registration = repo .user_registration() .complete(&clock, registration) .await?; + // If we used a registration token, we need to mark it as used + if let Some(registration_token) = registration_token { + repo.user_registration_token() + .use_token(&clock, registration_token) + .await?; + } + // Consume the registration session let cookie_jar = registrations .consume_session(®istration)? diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs index 1b090abb9..ae57f5a0c 100644 --- a/crates/handlers/src/views/register/steps/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -5,4 +5,5 @@ pub(crate) mod display_name; pub(crate) mod finish; +pub(crate) mod registration_token; pub(crate) mod verify_email; diff --git a/crates/handlers/src/views/register/steps/registration_token.rs b/crates/handlers/src/views/register/steps/registration_token.rs new file mode 100644 index 000000000..eacf343a3 --- /dev/null +++ b/crates/handlers/src/views/register/steps/registration_token.rs @@ -0,0 +1,201 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context as _; +use axum::{ + Form, + extract::{Path, State}, + response::{Html, IntoResponse, Response}, +}; +use mas_axum_utils::{ + InternalError, + cookies::CookieJar, + csrf::{CsrfExt as _, ProtectedForm}, +}; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{ + FieldError, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField, + TemplateContext as _, Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{PreferredLanguage, views::shared::OptionalPostAuthAction}; + +#[derive(Deserialize, Serialize)] +pub(crate) struct RegistrationTokenForm { + #[serde(default)] + token: String, +} + +impl ToFormState for RegistrationTokenForm { + type Field = mas_templates::RegisterStepsRegistrationTokenFormField; +} + +#[tracing::instrument( + name = "handlers.views.register.steps.registration_token.get", + fields(user_registration.id = %id), + skip_all, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration") + .map_err(InternalError::from_anyhow)?; + + // If the registration is completed, we can go to the registration destination + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + // If the registration already has a token, skip this step + if registration.user_registration_token_id.is_some() { + let destination = mas_router::RegisterDisplayName::new(registration.id); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); + } + + let ctx = RegisterStepsRegistrationTokenContext::new() + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_registration_token(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument( + name = "handlers.views.register.steps.registration_token.post", + fields(user_registration.id = %id), + skip_all, +)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration") + .map_err(InternalError::from_anyhow)?; + + // If the registration is completed, we can go to the registration destination + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + let form = cookie_jar.verify_form(&clock, form)?; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + // Validate the token + let token = form.token.trim(); + if token.is_empty() { + let ctx = RegisterStepsRegistrationTokenContext::new() + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsRegistrationTokenFormField::Token, + FieldError::Required, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_registration_token(&ctx)?), + ) + .into_response()); + } + + // Look up the token + let Some(registration_token) = repo.user_registration_token().find_by_token(token).await? + else { + let ctx = RegisterStepsRegistrationTokenContext::new() + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsRegistrationTokenFormField::Token, + FieldError::Invalid, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_registration_token(&ctx)?), + ) + .into_response()); + }; + + // Check if the token is still valid + if !registration_token.is_valid(clock.now()) { + tracing::warn!("Registration token isn't valid (expired or already used)"); + let ctx = RegisterStepsRegistrationTokenContext::new() + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsRegistrationTokenFormField::Token, + FieldError::Invalid, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_registration_token(&ctx)?), + ) + .into_response()); + } + + // Associate the token with the registration + let registration = repo + .user_registration() + .set_registration_token(registration, ®istration_token) + .await?; + + repo.save().await?; + + // Continue to the next step + let destination = mas_router::RegisterFinish::new(registration.id); + Ok((cookie_jar, url_builder.redirect(&destination)).into_response()) +} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index a7efeade9..896f17a52 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -382,6 +382,30 @@ impl From> for PasswordRegister { } } +/// `GET|POST /register/steps/{id}/token` +#[derive(Debug, Clone)] +pub struct RegisterToken { + id: Ulid, +} + +impl RegisterToken { + #[must_use] + pub fn new(id: Ulid) -> Self { + Self { id } + } +} + +impl Route for RegisterToken { + type Query = (); + fn route() -> &'static str { + "/register/steps/{id}/token" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/register/steps/{}/token", self.id).into() + } +} + /// `GET|POST /register/steps/{id}/display-name` #[derive(Debug, Clone)] pub struct RegisterDisplayName { diff --git a/crates/storage-pg/.sqlx/query-5133f9c5ba06201433be4ec784034d222975d084d0a9ebe7f1b6b865ab2e09ef.json b/crates/storage-pg/.sqlx/query-5133f9c5ba06201433be4ec784034d222975d084d0a9ebe7f1b6b865ab2e09ef.json new file mode 100644 index 000000000..227398475 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-5133f9c5ba06201433be4ec784034d222975d084d0a9ebe7f1b6b865ab2e09ef.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET times_used = times_used + 1,\n last_used_at = $2\n WHERE user_registration_token_id = $1 AND revoked_at IS NULL\n RETURNING times_used\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "times_used", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5133f9c5ba06201433be4ec784034d222975d084d0a9ebe7f1b6b865ab2e09ef" +} diff --git a/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json b/crates/storage-pg/.sqlx/query-5bb3ad7486365e0798e103b072514e66b5b69a347dce91135e158a5eba1d1426.json similarity index 77% rename from crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json rename to crates/storage-pg/.sqlx/query-5bb3ad7486365e0798e103b072514e66b5b69a347dce91135e158a5eba1d1426.json index 6ee03e2d7..bad355b81 100644 --- a/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json +++ b/crates/storage-pg/.sqlx/query-5bb3ad7486365e0798e103b072514e66b5b69a347dce91135e158a5eba1d1426.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_registration_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , post_auth_action\n , username\n , display_name\n , terms_url\n , email_authentication_id\n , hashed_password\n , hashed_password_version\n , created_at\n , completed_at\n FROM user_registrations\n WHERE user_registration_id = $1\n ", + "query": "\n SELECT user_registration_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , post_auth_action\n , username\n , display_name\n , terms_url\n , email_authentication_id\n , user_registration_token_id\n , hashed_password\n , hashed_password_version\n , created_at\n , completed_at\n FROM user_registrations\n WHERE user_registration_id = $1\n ", "describe": { "columns": [ { @@ -45,21 +45,26 @@ }, { "ordinal": 8, + "name": "user_registration_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, "name": "hashed_password", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "hashed_password_version", "type_info": "Int4" }, { - "ordinal": 10, + "ordinal": 11, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "completed_at", "type_info": "Timestamptz" } @@ -80,9 +85,10 @@ true, true, true, + true, false, true ] }, - "hash": "6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777" + "hash": "5bb3ad7486365e0798e103b072514e66b5b69a347dce91135e158a5eba1d1426" } diff --git a/crates/storage-pg/.sqlx/query-860e01cd660b450439d63c5ee31ade59f478b0b096b4bc90c89fb9c26b467dd2.json b/crates/storage-pg/.sqlx/query-860e01cd660b450439d63c5ee31ade59f478b0b096b4bc90c89fb9c26b467dd2.json new file mode 100644 index 000000000..a8909316d --- /dev/null +++ b/crates/storage-pg/.sqlx/query-860e01cd660b450439d63c5ee31ade59f478b0b096b4bc90c89fb9c26b467dd2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET user_registration_token_id = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "860e01cd660b450439d63c5ee31ade59f478b0b096b4bc90c89fb9c26b467dd2" +} diff --git a/crates/storage-pg/.sqlx/query-89edaec8661e435c3b71bb9b995cd711eb78a4d39608e897432d6124cd135938.json b/crates/storage-pg/.sqlx/query-89edaec8661e435c3b71bb9b995cd711eb78a4d39608e897432d6124cd135938.json new file mode 100644 index 000000000..f04a39a7f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-89edaec8661e435c3b71bb9b995cd711eb78a4d39608e897432d6124cd135938.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_registration_tokens\n (user_registration_token_id, token, usage_limit, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int4", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "89edaec8661e435c3b71bb9b995cd711eb78a4d39608e897432d6124cd135938" +} diff --git a/crates/storage-pg/.sqlx/query-b3568613352efae1125a88565d886157d96866f7ef9b09b03a45ba4322664bd0.json b/crates/storage-pg/.sqlx/query-b3568613352efae1125a88565d886157d96866f7ef9b09b03a45ba4322664bd0.json new file mode 100644 index 000000000..9acc3f81a --- /dev/null +++ b/crates/storage-pg/.sqlx/query-b3568613352efae1125a88565d886157d96866f7ef9b09b03a45ba4322664bd0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET revoked_at = $2\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "b3568613352efae1125a88565d886157d96866f7ef9b09b03a45ba4322664bd0" +} diff --git a/crates/storage-pg/.sqlx/query-d0355d4e98bec6120f17d8cf81ac8c30ed19e9cebd0c8e7c7918b1c3ca0e3cba.json b/crates/storage-pg/.sqlx/query-d0355d4e98bec6120f17d8cf81ac8c30ed19e9cebd0c8e7c7918b1c3ca0e3cba.json new file mode 100644 index 000000000..2e20ac5ca --- /dev/null +++ b/crates/storage-pg/.sqlx/query-d0355d4e98bec6120f17d8cf81ac8c30ed19e9cebd0c8e7c7918b1c3ca0e3cba.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_registration_token_id,\n token,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_registration_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "usage_limit", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "times_used", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + true, + true + ] + }, + "hash": "d0355d4e98bec6120f17d8cf81ac8c30ed19e9cebd0c8e7c7918b1c3ca0e3cba" +} diff --git a/crates/storage-pg/.sqlx/query-fca331753aeccddbad96d06fc9d066dcefebe978a7af477bb6b55faa1d31e9b1.json b/crates/storage-pg/.sqlx/query-fca331753aeccddbad96d06fc9d066dcefebe978a7af477bb6b55faa1d31e9b1.json new file mode 100644 index 000000000..c5e2c6953 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-fca331753aeccddbad96d06fc9d066dcefebe978a7af477bb6b55faa1d31e9b1.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_registration_token_id,\n token,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_registration_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "usage_limit", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "times_used", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + true, + true + ] + }, + "hash": "fca331753aeccddbad96d06fc9d066dcefebe978a7af477bb6b55faa1d31e9b1" +} diff --git a/crates/storage-pg/migrations/20250602212100_user_registration_tokens.sql b/crates/storage-pg/migrations/20250602212100_user_registration_tokens.sql new file mode 100644 index 000000000..2f9ec3cd2 --- /dev/null +++ b/crates/storage-pg/migrations/20250602212100_user_registration_tokens.sql @@ -0,0 +1,57 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Add a table for storing user registration tokens +CREATE TABLE "user_registration_tokens" ( + "user_registration_token_id" UUID PRIMARY KEY, + + -- The token string that users need to provide during registration + "token" TEXT NOT NULL UNIQUE, + + -- Optional limit on how many times this token can be used + "usage_limit" INTEGER, + + -- How many times this token has been used + "times_used" INTEGER NOT NULL DEFAULT 0, + + -- When the token was created + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + + -- When the token was last used + "last_used_at" TIMESTAMP WITH TIME ZONE, + + -- Optional expiration time for the token + "expires_at" TIMESTAMP WITH TIME ZONE, + + -- When the token was revoked + "revoked_at" TIMESTAMP WITH TIME ZONE +); + +-- Create a few indices on the table, as we use those for filtering +-- They are safe to create non-concurrently, as the table is empty at this point +CREATE INDEX "user_registration_tokens_usage_limit_idx" + ON "user_registration_tokens" ("usage_limit"); + +CREATE INDEX "user_registration_tokens_times_used_idx" + ON "user_registration_tokens" ("times_used"); + +CREATE INDEX "user_registration_tokens_created_at_idx" + ON "user_registration_tokens" ("created_at"); + +CREATE INDEX "user_registration_tokens_last_used_at_idx" + ON "user_registration_tokens" ("last_used_at"); + +CREATE INDEX "user_registration_tokens_expires_at_idx" + ON "user_registration_tokens" ("expires_at"); + +CREATE INDEX "user_registration_tokens_revoked_at_idx" + ON "user_registration_tokens" ("revoked_at"); + +-- Add foreign key reference to registration tokens in user registrations +-- A second migration will add the index for this foreign key +ALTER TABLE "user_registrations" + ADD COLUMN "user_registration_token_id" UUID + REFERENCES "user_registration_tokens" ("user_registration_token_id") + ON DELETE SET NULL; \ No newline at end of file diff --git a/crates/storage-pg/migrations/20250602212101_idx_user_registration_token.sql b/crates/storage-pg/migrations/20250602212101_idx_user_registration_token.sql new file mode 100644 index 000000000..a25d6358a --- /dev/null +++ b/crates/storage-pg/migrations/20250602212101_idx_user_registration_token.sql @@ -0,0 +1,9 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +CREATE INDEX CONCURRENTLY + user_registrations_user_registration_token_id_fk + ON user_registrations (user_registration_token_id); \ No newline at end of file diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 76067b2fa..6692c7a75 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -139,3 +139,16 @@ pub enum UpstreamOAuthLinks { HumanAccountName, CreatedAt, } + +#[derive(sea_query::Iden)] +pub enum UserRegistrationTokens { + Table, + UserRegistrationTokenId, + Token, + UsageLimit, + TimesUsed, + CreatedAt, + LastUsedAt, + ExpiresAt, + RevokedAt, +} diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index c6668c2e4..8dc02b9bb 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -26,7 +26,11 @@ use mas_storage::{ UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository, }, - user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, + user::{ + BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, + UserRecoveryRepository, UserRegistrationRepository, UserRegistrationTokenRepository, + UserRepository, UserTermsRepository, + }, }; use sqlx::{PgConnection, PgPool, Postgres, Transaction}; use tracing::Instrument; @@ -55,8 +59,8 @@ use crate::{ }, user::{ PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository, - PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRepository, - PgUserTermsRepository, + PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRegistrationTokenRepository, + PgUserRepository, PgUserTermsRepository, }, }; @@ -232,22 +236,26 @@ where fn user_recovery<'c>( &'c mut self, - ) -> Box + 'c> { + ) -> Box + 'c> { Box::new(PgUserRecoveryRepository::new(self.conn.as_mut())) } - fn user_terms<'c>( - &'c mut self, - ) -> Box + 'c> { + fn user_terms<'c>(&'c mut self) -> Box + 'c> { Box::new(PgUserTermsRepository::new(self.conn.as_mut())) } fn user_registration<'c>( &'c mut self, - ) -> Box + 'c> { + ) -> Box + 'c> { Box::new(PgUserRegistrationRepository::new(self.conn.as_mut())) } + fn user_registration_token<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgUserRegistrationTokenRepository::new(self.conn.as_mut())) + } + fn browser_session<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 659a2172a..8e755188d 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -32,6 +32,7 @@ mod email; mod password; mod recovery; mod registration; +mod registration_token; mod session; mod terms; @@ -41,7 +42,8 @@ mod tests; pub use self::{ email::PgUserEmailRepository, password::PgUserPasswordRepository, recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository, - session::PgBrowserSessionRepository, terms::PgUserTermsRepository, + registration_token::PgUserRegistrationTokenRepository, session::PgBrowserSessionRepository, + terms::PgUserTermsRepository, }; /// An implementation of [`UserRepository`] for a PostgreSQL connection diff --git a/crates/storage-pg/src/user/registration.rs b/crates/storage-pg/src/user/registration.rs index 5d578ab79..7f123b361 100644 --- a/crates/storage-pg/src/user/registration.rs +++ b/crates/storage-pg/src/user/registration.rs @@ -7,7 +7,9 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{UserEmailAuthentication, UserRegistration, UserRegistrationPassword}; +use mas_data_model::{ + UserEmailAuthentication, UserRegistration, UserRegistrationPassword, UserRegistrationToken, +}; use mas_storage::{Clock, user::UserRegistrationRepository}; use rand::RngCore; use sqlx::PgConnection; @@ -40,6 +42,7 @@ struct UserRegistrationLookup { display_name: Option, terms_url: Option, email_authentication_id: Option, + user_registration_token_id: Option, hashed_password: Option, hashed_password_version: Option, created_at: DateTime, @@ -94,6 +97,7 @@ impl TryFrom for UserRegistration { display_name: value.display_name, terms_url, email_authentication_id: value.email_authentication_id.map(Ulid::from), + user_registration_token_id: value.user_registration_token_id.map(Ulid::from), password, created_at: value.created_at, completed_at: value.completed_at, @@ -126,6 +130,7 @@ impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { , display_name , terms_url , email_authentication_id + , user_registration_token_id , hashed_password , hashed_password_version , created_at @@ -200,6 +205,7 @@ impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { display_name: None, terms_url: None, email_authentication_id: None, + user_registration_token_id: None, password: None, }) } @@ -351,6 +357,41 @@ impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { Ok(user_registration) } + #[tracing::instrument( + name = "db.user_registration.set_registration_token", + skip_all, + fields( + db.query.text, + %user_registration.id, + %user_registration_token.id, + ), + err, + )] + async fn set_registration_token( + &mut self, + mut user_registration: UserRegistration, + user_registration_token: &UserRegistrationToken, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET user_registration_token_id = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + Uuid::from(user_registration_token.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.user_registration_token_id = Some(user_registration_token.id); + + Ok(user_registration) + } + #[tracing::instrument( name = "db.user_registration.complete", skip_all, diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs new file mode 100644 index 000000000..02b03038d --- /dev/null +++ b/crates/storage-pg/src/user/registration_token.rs @@ -0,0 +1,719 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::UserRegistrationToken; +use mas_storage::{ + Clock, Page, Pagination, + user::{UserRegistrationTokenFilter, UserRegistrationTokenRepository}, +}; +use rand::RngCore; +use sea_query::{Condition, Expr, PostgresQueryBuilder, Query, enum_def}; +use sea_query_binder::SqlxBinder; +use sqlx::PgConnection; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{ + DatabaseInconsistencyError, + errors::DatabaseError, + filter::{Filter, StatementExt}, + iden::UserRegistrationTokens, + pagination::QueryBuilderExt, + tracing::ExecuteExt, +}; + +/// An implementation of [`mas_storage::user::UserRegistrationTokenRepository`] +/// for a PostgreSQL connection +pub struct PgUserRegistrationTokenRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgUserRegistrationTokenRepository<'c> { + /// Create a new [`PgUserRegistrationTokenRepository`] from an active + /// PostgreSQL connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +#[enum_def] +struct UserRegistrationTokenLookup { + user_registration_token_id: Uuid, + token: String, + usage_limit: Option, + times_used: i32, + created_at: DateTime, + last_used_at: Option>, + expires_at: Option>, + revoked_at: Option>, +} + +impl Filter for UserRegistrationTokenFilter { + #[expect(clippy::too_many_lines)] + fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { + sea_query::Condition::all() + .add_option(self.has_been_used().map(|has_been_used| { + if has_been_used { + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::TimesUsed, + )) + .gt(0) + } else { + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::TimesUsed, + )) + .eq(0) + } + })) + .add_option(self.is_revoked().map(|is_revoked| { + if is_revoked { + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::RevokedAt, + )) + .is_not_null() + } else { + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::RevokedAt, + )) + .is_null() + } + })) + .add_option(self.is_expired().map(|is_expired| { + if is_expired { + Condition::all() + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )) + .is_not_null(), + ) + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )) + .lt(Expr::val(self.now())), + ) + } else { + Condition::any() + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )) + .is_null(), + ) + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )) + .gte(Expr::val(self.now())), + ) + } + })) + .add_option(self.is_valid().map(|is_valid| { + let valid = Condition::all() + // Has not reached its usage limit + .add( + Condition::any() + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::UsageLimit, + )) + .is_null(), + ) + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::TimesUsed, + )) + .lt(Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::UsageLimit, + ))), + ), + ) + // Has not been revoked + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::RevokedAt, + )) + .is_null(), + ) + // Has not expired + .add( + Condition::any() + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )) + .is_null(), + ) + .add( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )) + .gte(Expr::val(self.now())), + ), + ); + + if is_valid { valid } else { valid.not() } + })) + } +} + +impl TryFrom for UserRegistrationToken { + type Error = DatabaseInconsistencyError; + + fn try_from(res: UserRegistrationTokenLookup) -> Result { + let id = Ulid::from(res.user_registration_token_id); + + let usage_limit = res + .usage_limit + .map(u32::try_from) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("user_registration_tokens") + .column("usage_limit") + .row(id) + .source(e) + })?; + + let times_used = res.times_used.try_into().map_err(|e| { + DatabaseInconsistencyError::on("user_registration_tokens") + .column("times_used") + .row(id) + .source(e) + })?; + + Ok(UserRegistrationToken { + id, + token: res.token, + usage_limit, + times_used, + created_at: res.created_at, + last_used_at: res.last_used_at, + expires_at: res.expires_at, + revoked_at: res.revoked_at, + }) + } +} + +#[async_trait] +impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.user_registration_token.list", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn list( + &mut self, + filter: UserRegistrationTokenFilter, + pagination: Pagination, + ) -> Result, Self::Error> { + let (sql, values) = Query::select() + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::UserRegistrationTokenId, + )), + UserRegistrationTokenLookupIden::UserRegistrationTokenId, + ) + .expr_as( + Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Token)), + UserRegistrationTokenLookupIden::Token, + ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::UsageLimit, + )), + UserRegistrationTokenLookupIden::UsageLimit, + ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::TimesUsed, + )), + UserRegistrationTokenLookupIden::TimesUsed, + ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::CreatedAt, + )), + UserRegistrationTokenLookupIden::CreatedAt, + ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::LastUsedAt, + )), + UserRegistrationTokenLookupIden::LastUsedAt, + ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::ExpiresAt, + )), + UserRegistrationTokenLookupIden::ExpiresAt, + ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::RevokedAt, + )), + UserRegistrationTokenLookupIden::RevokedAt, + ) + .from(UserRegistrationTokens::Table) + .apply_filter(filter) + .generate_pagination( + ( + UserRegistrationTokens::Table, + UserRegistrationTokens::UserRegistrationTokenId, + ), + pagination, + ) + .build_sqlx(PostgresQueryBuilder); + + let tokens = sqlx::query_as_with::<_, UserRegistrationTokenLookup, _>(&sql, values) + .traced() + .fetch_all(&mut *self.conn) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + + let page = pagination.process(tokens); + + Ok(page) + } + + #[tracing::instrument( + name = "db.user_registration_token.count", + skip_all, + fields( + db.query.text, + user_registration_token.filter = ?filter, + ), + err, + )] + async fn count(&mut self, filter: UserRegistrationTokenFilter) -> Result { + let (sql, values) = Query::select() + .expr( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::UserRegistrationTokenId, + )) + .count(), + ) + .from(UserRegistrationTokens::Table) + .apply_filter(filter) + .build_sqlx(PostgresQueryBuilder); + + let count: i64 = sqlx::query_scalar_with(&sql, values) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count + .try_into() + .map_err(DatabaseError::to_invalid_operation) + } + + #[tracing::instrument( + name = "db.user_registration_token.lookup", + skip_all, + fields( + db.query.text, + user_registration_token.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserRegistrationTokenLookup, + r#" + SELECT user_registration_token_id, + token, + usage_limit, + times_used, + created_at, + last_used_at, + expires_at, + revoked_at + FROM user_registration_tokens + WHERE user_registration_token_id = $1 + "#, + Uuid::from(id) + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { + return Ok(None); + }; + + Ok(Some(res.try_into()?)) + } + + #[tracing::instrument( + name = "db.user_registration_token.find_by_token", + skip_all, + fields( + db.query.text, + token = %token, + ), + err, + )] + async fn find_by_token( + &mut self, + token: &str, + ) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserRegistrationTokenLookup, + r#" + SELECT user_registration_token_id, + token, + usage_limit, + times_used, + created_at, + last_used_at, + expires_at, + revoked_at + FROM user_registration_tokens + WHERE token = $1 + "#, + token + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { + return Ok(None); + }; + + Ok(Some(res.try_into()?)) + } + + #[tracing::instrument( + name = "db.user_registration_token.add", + skip_all, + fields( + db.query.text, + user_registration_token.token = %token, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn mas_storage::Clock, + token: String, + usage_limit: Option, + expires_at: Option>, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + + let usage_limit_i32 = usage_limit + .map(i32::try_from) + .transpose() + .map_err(DatabaseError::to_invalid_operation)?; + + sqlx::query!( + r#" + INSERT INTO user_registration_tokens + (user_registration_token_id, token, usage_limit, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5) + "#, + Uuid::from(id), + &token, + usage_limit_i32, + created_at, + expires_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserRegistrationToken { + id, + token, + usage_limit, + times_used: 0, + created_at, + last_used_at: None, + expires_at, + revoked_at: None, + }) + } + + #[tracing::instrument( + name = "db.user_registration_token.use_token", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn use_token( + &mut self, + clock: &dyn Clock, + token: UserRegistrationToken, + ) -> Result { + let now = clock.now(); + let new_times_used = sqlx::query_scalar!( + r#" + UPDATE user_registration_tokens + SET times_used = times_used + 1, + last_used_at = $2 + WHERE user_registration_token_id = $1 AND revoked_at IS NULL + RETURNING times_used + "#, + Uuid::from(token.id), + now, + ) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + let new_times_used = new_times_used + .try_into() + .map_err(DatabaseError::to_invalid_operation)?; + + Ok(UserRegistrationToken { + times_used: new_times_used, + last_used_at: Some(now), + ..token + }) + } + + #[tracing::instrument( + name = "db.user_registration_token.revoke", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + mut token: UserRegistrationToken, + ) -> Result { + let revoked_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE user_registration_tokens + SET revoked_at = $2 + WHERE user_registration_token_id = $1 + "#, + Uuid::from(token.id), + revoked_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + token.revoked_at = Some(revoked_at); + + Ok(token) + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use mas_storage::{ + Clock as _, Pagination, clock::MockClock, user::UserRegistrationTokenFilter, + }; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use sqlx::PgPool; + + use crate::PgRepository; + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_list_and_count(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create different types of tokens + // 1. A regular token + let _token1 = repo + .user_registration_token() + .add(&mut rng, &clock, "token1".to_owned(), None, None) + .await + .unwrap(); + + // 2. A token that has been used + let token2 = repo + .user_registration_token() + .add(&mut rng, &clock, "token2".to_owned(), None, None) + .await + .unwrap(); + let token2 = repo + .user_registration_token() + .use_token(&clock, token2) + .await + .unwrap(); + + // 3. A token that is expired + let past_time = clock.now() - Duration::days(1); + let token3 = repo + .user_registration_token() + .add(&mut rng, &clock, "token3".to_owned(), None, Some(past_time)) + .await + .unwrap(); + + // 4. A token that is revoked + let token4 = repo + .user_registration_token() + .add(&mut rng, &clock, "token4".to_owned(), None, None) + .await + .unwrap(); + let token4 = repo + .user_registration_token() + .revoke(&clock, token4) + .await + .unwrap(); + + // Test list with empty filter + let empty_filter = UserRegistrationTokenFilter::new(clock.now()); + let page = repo + .user_registration_token() + .list(empty_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 4); + + // Test count with empty filter + let count = repo + .user_registration_token() + .count(empty_filter) + .await + .unwrap(); + assert_eq!(count, 4); + + // Test has_been_used filter + let used_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(true); + let page = repo + .user_registration_token() + .list(used_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 1); + assert_eq!(page.edges[0].id, token2.id); + + // Test unused filter + let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false); + let page = repo + .user_registration_token() + .list(unused_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 3); + + // Test is_expired filter + let expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(true); + let page = repo + .user_registration_token() + .list(expired_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 1); + assert_eq!(page.edges[0].id, token3.id); + + let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false); + let page = repo + .user_registration_token() + .list(not_expired_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 3); + + // Test is_revoked filter + let revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(true); + let page = repo + .user_registration_token() + .list(revoked_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 1); + assert_eq!(page.edges[0].id, token4.id); + + let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false); + let page = repo + .user_registration_token() + .list(not_revoked_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 3); + + // Test is_valid filter + let valid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(true); + let page = repo + .user_registration_token() + .list(valid_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 2); + + let invalid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(false); + let page = repo + .user_registration_token() + .list(invalid_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 2); + + // Test combined filters + let combined_filter = UserRegistrationTokenFilter::new(clock.now()) + .with_been_used(false) + .with_revoked(true); + let page = repo + .user_registration_token() + .list(combined_filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 1); + assert_eq!(page.edges[0].id, token4.id); + + // Test pagination + let page = repo + .user_registration_token() + .list(empty_filter, Pagination::first(2)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 2); + } +} diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 93c43d469..a02edb4ad 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -26,7 +26,8 @@ use crate::{ }, user::{ BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRecoveryRepository, UserRegistrationRepository, UserRepository, UserTermsRepository, + UserRecoveryRepository, UserRegistrationRepository, UserRegistrationTokenRepository, + UserRepository, UserTermsRepository, }, }; @@ -148,6 +149,11 @@ pub trait RepositoryAccess: Send { &'c mut self, ) -> Box + 'c>; + /// Get an [`UserRegistrationTokenRepository`] + fn user_registration_token<'c>( + &'c mut self, + ) -> Box + 'c>; + /// Get an [`UserTermsRepository`] fn user_terms<'c>(&'c mut self) -> Box + 'c>; @@ -249,7 +255,8 @@ mod impls { }, user::{ BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRegistrationRepository, UserRepository, UserTermsRepository, + UserRegistrationRepository, UserRegistrationTokenRepository, UserRepository, + UserTermsRepository, }, }; @@ -348,6 +355,15 @@ mod impls { )) } + fn user_registration_token<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new( + self.inner.user_registration_token(), + &mut self.mapper, + )) + } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { Box::new(MapErr::new(self.inner.user_terms(), &mut self.mapper)) } @@ -512,6 +528,12 @@ mod impls { (**self).user_registration() } + fn user_registration_token<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).user_registration_token() + } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { (**self).user_terms() } diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 395c6e615..17852f0e9 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -17,6 +17,7 @@ mod email; mod password; mod recovery; mod registration; +mod registration_token; mod session; mod terms; @@ -25,6 +26,7 @@ pub use self::{ password::UserPasswordRepository, recovery::UserRecoveryRepository, registration::UserRegistrationRepository, + registration_token::{UserRegistrationTokenFilter, UserRegistrationTokenRepository}, session::{BrowserSessionFilter, BrowserSessionRepository}, terms::UserTermsRepository, }; diff --git a/crates/storage/src/user/registration.rs b/crates/storage/src/user/registration.rs index 3932db622..49fd01fdc 100644 --- a/crates/storage/src/user/registration.rs +++ b/crates/storage/src/user/registration.rs @@ -6,7 +6,7 @@ use std::net::IpAddr; use async_trait::async_trait; -use mas_data_model::{UserEmailAuthentication, UserRegistration}; +use mas_data_model::{UserEmailAuthentication, UserRegistration, UserRegistrationToken}; use rand_core::RngCore; use ulid::Ulid; use url::Url; @@ -138,6 +138,25 @@ pub trait UserRegistrationRepository: Send + Sync { version: u16, ) -> Result; + /// Set the registration token of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `user_registration_token`: The [`UserRegistrationToken`] to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_registration_token( + &mut self, + user_registration: UserRegistration, + user_registration_token: &UserRegistrationToken, + ) -> Result; + /// Complete a [`UserRegistration`] /// /// Returns the updated [`UserRegistration`] @@ -190,6 +209,11 @@ repository_impl!(UserRegistrationRepository: hashed_password: String, version: u16, ) -> Result; + async fn set_registration_token( + &mut self, + user_registration: UserRegistration, + user_registration_token: &UserRegistrationToken, + ) -> Result; async fn complete( &mut self, clock: &dyn Clock, diff --git a/crates/storage/src/user/registration_token.rs b/crates/storage/src/user/registration_token.rs new file mode 100644 index 000000000..60f65a73f --- /dev/null +++ b/crates/storage/src/user/registration_token.rs @@ -0,0 +1,258 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::UserRegistrationToken; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::{Clock, repository_impl}; + +/// A filter to apply when listing [`UserRegistrationToken`]s +#[derive(Debug, Clone, Copy)] +pub struct UserRegistrationTokenFilter { + now: DateTime, + has_been_used: Option, + is_revoked: Option, + is_expired: Option, + is_valid: Option, +} + +impl UserRegistrationTokenFilter { + /// Create a new empty filter + #[must_use] + pub fn new(now: DateTime) -> Self { + Self { + now, + has_been_used: None, + is_revoked: None, + is_expired: None, + is_valid: None, + } + } + + /// Filter by whether the token has been used at least once + #[must_use] + pub fn with_been_used(mut self, has_been_used: bool) -> Self { + self.has_been_used = Some(has_been_used); + self + } + + /// Filter by revoked status + #[must_use] + pub fn with_revoked(mut self, is_revoked: bool) -> Self { + self.is_revoked = Some(is_revoked); + self + } + + /// Filter by expired status + #[must_use] + pub fn with_expired(mut self, is_expired: bool) -> Self { + self.is_expired = Some(is_expired); + self + } + + /// Filter by valid status (meaning: not expired, not revoked, and still + /// with uses left) + #[must_use] + pub fn with_valid(mut self, is_valid: bool) -> Self { + self.is_valid = Some(is_valid); + self + } + + /// Get the used status filter + /// + /// Returns [`None`] if no used status filter was set + #[must_use] + pub fn has_been_used(&self) -> Option { + self.has_been_used + } + + /// Get the revoked status filter + /// + /// Returns [`None`] if no revoked status filter was set + #[must_use] + pub fn is_revoked(&self) -> Option { + self.is_revoked + } + + /// Get the expired status filter + /// + /// Returns [`None`] if no expired status filter was set + #[must_use] + pub fn is_expired(&self) -> Option { + self.is_expired + } + + /// Get the valid status filter + /// + /// Returns [`None`] if no valid status filter was set + #[must_use] + pub fn is_valid(&self) -> Option { + self.is_valid + } + + /// Get the current time for this filter evaluation + #[must_use] + pub fn now(&self) -> DateTime { + self.now + } +} + +/// A [`UserRegistrationTokenRepository`] helps interacting with +/// [`UserRegistrationToken`] saved in the storage backend +#[async_trait] +pub trait UserRegistrationTokenRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup a [`UserRegistrationToken`] by its ID + /// + /// Returns `None` if no [`UserRegistrationToken`] was found + /// + /// # Parameters + /// + /// * `id`: The ID of the [`UserRegistrationToken`] to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Lookup a [`UserRegistrationToken`] by its token string + /// + /// Returns `None` if no [`UserRegistrationToken`] was found + /// + /// # Parameters + /// + /// * `token`: The token string to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_by_token( + &mut self, + token: &str, + ) -> Result, Self::Error>; + + /// Create a new [`UserRegistrationToken`] + /// + /// Returns the newly created [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `token`: The token string + /// * `usage_limit`: Optional limit on how many times the token can be used + /// * `expires_at`: Optional expiration time for the token + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + token: String, + usage_limit: Option, + expires_at: Option>, + ) -> Result; + + /// Increment the usage count of a [`UserRegistrationToken`] + /// + /// Returns the updated [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `token`: The [`UserRegistrationToken`] to update + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn use_token( + &mut self, + clock: &dyn Clock, + token: UserRegistrationToken, + ) -> Result; + + /// Revoke a [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `token`: The [`UserRegistrationToken`] to delete + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + token: UserRegistrationToken, + ) -> Result; + + /// List [`UserRegistrationToken`]s based on the provided filter + /// + /// Returns a list of matching [`UserRegistrationToken`]s + /// + /// # Parameters + /// + /// * `filter`: The filter to apply + /// * `pagination`: The pagination parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn list( + &mut self, + filter: UserRegistrationTokenFilter, + pagination: crate::Pagination, + ) -> Result, Self::Error>; + + /// Count [`UserRegistrationToken`]s based on the provided filter + /// + /// Returns the number of matching [`UserRegistrationToken`]s + /// + /// # Parameters + /// + /// * `filter`: The filter to apply + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn count(&mut self, filter: UserRegistrationTokenFilter) -> Result; +} + +repository_impl!(UserRegistrationTokenRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + async fn find_by_token(&mut self, token: &str) -> Result, Self::Error>; + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + token: String, + usage_limit: Option, + expires_at: Option>, + ) -> Result; + async fn use_token( + &mut self, + clock: &dyn Clock, + token: UserRegistrationToken, + ) -> Result; + async fn revoke( + &mut self, + clock: &dyn Clock, + token: UserRegistrationToken, + ) -> Result; + async fn list( + &mut self, + filter: UserRegistrationTokenFilter, + pagination: crate::Pagination, + ) -> Result, Self::Error>; + async fn count(&mut self, filter: UserRegistrationTokenFilter) -> Result; +); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 54b2f193d..c21096ee6 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1068,6 +1068,61 @@ impl TemplateContext for RegisterStepsDisplayNameContext { } } +/// Fields of the registration token form +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RegisterStepsRegistrationTokenFormField { + /// The registration token + Token, +} + +impl FormField for RegisterStepsRegistrationTokenFormField { + fn keep(&self) -> bool { + match self { + Self::Token => true, + } + } +} + +/// The registration token page context +#[derive(Serialize, Default)] +pub struct RegisterStepsRegistrationTokenContext { + form: FormState, +} + +impl RegisterStepsRegistrationTokenContext { + /// Constructs a context for the registration token page + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the form state + #[must_use] + pub fn with_form_state( + mut self, + form_state: FormState, + ) -> Self { + self.form = form_state; + self + } +} + +impl TemplateContext for RegisterStepsRegistrationTokenContext { + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec + where + Self: Sized, + { + vec![Self { + form: FormState::default(), + }] + } +} + /// Fields of the account recovery start form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 431b1f52b..88a72225a 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -42,7 +42,8 @@ pub use self::{ RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, - RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, + RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext, + RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession, @@ -340,6 +341,9 @@ register_templates! { /// Render the display name page pub fn render_register_steps_display_name(WithLanguage>) { "pages/register/steps/display_name.html" } + /// Render the registration token page + pub fn render_register_steps_registration_token(WithLanguage>) { "pages/register/steps/registration_token.html" } + /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } @@ -444,6 +448,7 @@ impl Templates { check::render_register_steps_verify_email(self, now, rng)?; check::render_register_steps_email_in_use(self, now, rng)?; check::render_register_steps_display_name(self, now, rng)?; + check::render_register_steps_registration_token(self, now, rng)?; check::render_consent(self, now, rng)?; check::render_policy_violation(self, now, rng)?; check::render_sso_login(self, now, rng)?; diff --git a/docs/api/spec.json b/docs/api/spec.json index 6e90e231f..2fb0c3a85 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2132,6 +2132,381 @@ } } }, + "/api/admin/v1/user-registration-tokens": { + "get": { + "tags": [ + "user-registration-token" + ], + "summary": "List user registration tokens", + "operationId": "listUserRegistrationTokens", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[used]", + "description": "Retrieve tokens that have (or have not) been used at least once", + "schema": { + "description": "Retrieve tokens that have (or have not) been used at least once", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[revoked]", + "description": "Retrieve tokens that are (or are not) revoked", + "schema": { + "description": "Retrieve tokens that are (or are not) revoked", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expired]", + "description": "Retrieve tokens that are (or are not) expired", + "schema": { + "description": "Retrieve tokens that are (or are not) expired", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[valid]", + "description": "Retrieve tokens that are (or are not) valid\n\nValid means that the token has not expired, is not revoked, and has not reached its usage limit.", + "schema": { + "description": "Retrieve tokens that are (or are not) valid\n\nValid means that the token has not expired, is not revoked, and has not reached its usage limit.", + "type": "boolean", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of registration tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_UserRegistrationToken" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "valid": true, + "usage_limit": 10, + "times_used": 5, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": "1970-01-01T00:00:00Z", + "expires_at": "1970-01-31T00:00:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + }, + { + "type": "user-registration_token", + "id": "02081040G2081040G2081040G2", + "attributes": { + "token": "xyz789abc012", + "valid": false, + "usage_limit": null, + "times_used": 0, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "1970-01-01T00:00:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[first]=2", + "first": "/api/admin/v1/user-registration-tokens?page[first]=2", + "last": "/api/admin/v1/user-registration-tokens?page[last]=2", + "next": "/api/admin/v1/user-registration-tokens?page[after]=02081040G2081040G2081040G2&page[first]=2" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "user-registration-token" + ], + "summary": "Create a new user registration token", + "operationId": "addUserRegistrationToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUserRegistrationTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "A new user registration token was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "valid": true, + "usage_limit": 10, + "times_used": 5, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": "1970-01-01T00:00:00Z", + "expires_at": "1970-01-31T00:00:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + } + } + } + } + } + } + }, + "/api/admin/v1/user-registration-tokens/{id}": { + "get": { + "tags": [ + "user-registration-token" + ], + "summary": "Get a user registration token", + "operationId": "getUserRegistrationToken", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Registration token was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "valid": true, + "usage_limit": 10, + "times_used": 5, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": "1970-01-01T00:00:00Z", + "expires_at": "1970-01-31T00:00:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Registration token was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/user-registration-tokens/{id}/revoke": { + "post": { + "tags": [ + "user-registration-token" + ], + "summary": "Revoke a user registration token", + "description": "Calling this endpoint will revoke the user registration token, preventing it from being used for new registrations.", + "operationId": "revokeUserRegistrationToken", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Registration token was revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "02081040G2081040G2081040G2", + "attributes": { + "token": "xyz789abc012", + "valid": false, + "usage_limit": null, + "times_used": 0, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "1970-01-01T00:00:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2/revoke" + } + } + } + } + }, + "400": { + "description": "Token is already revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 is already revoked" + } + ] + } + } + } + }, + "404": { + "description": "Registration token was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/upstream-oauth-links": { "get": { "tags": [ @@ -3588,6 +3963,181 @@ } } }, + "RegistrationTokenFilter": { + "type": "object", + "properties": { + "filter[used]": { + "description": "Retrieve tokens that have (or have not) been used at least once", + "type": "boolean", + "nullable": true + }, + "filter[revoked]": { + "description": "Retrieve tokens that are (or are not) revoked", + "type": "boolean", + "nullable": true + }, + "filter[expired]": { + "description": "Retrieve tokens that are (or are not) expired", + "type": "boolean", + "nullable": true + }, + "filter[valid]": { + "description": "Retrieve tokens that are (or are not) valid\n\nValid means that the token has not expired, is not revoked, and has not reached its usage limit.", + "type": "boolean", + "nullable": true + } + } + }, + "PaginatedResponse_for_UserRegistrationToken": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_UserRegistrationToken" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_UserRegistrationToken": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/UserRegistrationToken" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "UserRegistrationToken": { + "description": "A registration token", + "type": "object", + "required": [ + "created_at", + "times_used", + "token", + "valid" + ], + "properties": { + "token": { + "description": "The token string", + "type": "string" + }, + "valid": { + "description": "Whether the token is valid", + "type": "boolean" + }, + "usage_limit": { + "description": "Maximum number of times this token can be used", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + }, + "times_used": { + "description": "Number of times this token has been used", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "created_at": { + "description": "When the token was created", + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "description": "When the token was last used. If null, the token has never been used.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "expires_at": { + "description": "When the token expires. If null, the token never expires.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "description": "When the token was revoked. If null, the token is not revoked.", + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "AddUserRegistrationTokenRequest": { + "title": "JSON payload for the `POST /api/admin/v1/user-registration-tokens`", + "type": "object", + "properties": { + "token": { + "description": "The token string. If not provided, a random token will be generated.", + "type": "string", + "nullable": true + }, + "usage_limit": { + "description": "Maximum number of times this token can be used. If not provided, the token can be used an unlimited number of times.", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + }, + "expires_at": { + "description": "When the token expires. If not provided, the token never expires.", + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "SingleResponse_for_UserRegistrationToken": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_UserRegistrationToken" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } + }, "UpstreamOAuthLinkFilter": { "type": "object", "properties": { @@ -3779,6 +4329,10 @@ "name": "user-session", "description": "Manage browser sessions of users" }, + { + "name": "user-registration-token", + "description": "Manage user registration tokens" + }, { "name": "upstream-oauth-link", "description": "Manage links between local users and identities from upstream OAuth 2.0 providers" diff --git a/docs/config.schema.json b/docs/config.schema.json index 3bc0f407d..0e9fa0eb9 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2533,6 +2533,10 @@ "login_with_email_allowed": { "description": "Whether users can log in with their email address. Defaults to `false`.\n\nThis has no effect if password login is disabled.", "type": "boolean" + }, + "registration_token_required": { + "description": "Whether registration tokens are required for password registrations. Defaults to `false`.\n\nWhen enabled, users must provide a valid registration token during password registration. This has no effect if password registration is disabled.", + "type": "boolean" } } }, diff --git a/docs/reference/cli/manage.md b/docs/reference/cli/manage.md index 4b798a0d8..0f14f1773 100644 --- a/docs/reference/cli/manage.md +++ b/docs/reference/cli/manage.md @@ -46,6 +46,19 @@ Options: $ mas-cli manage issue-compatibility-token --device-id --yes-i-want-to-grant-synapse-admin-privileges ``` +## `manage issue-user-registration-token` + +Create a new user registration token. + +Options: +- `--token `: Specific token string to use. If not provided, a random token will be generated. +- `--usage-limit `: Limit the number of times the token can be used. If not provided, the token can be used an unlimited number of times. +- `--expires-in `: Time in seconds after which the token expires. If not provided, the token never expires. + +``` +$ mas-cli manage issue-user-registration-token --token --usage-limit --expires-in +``` + ## `manage provision-all-users` Trigger a provisioning job for all users. @@ -101,4 +114,4 @@ Options: ``` $ mas-cli manage register-user -``` \ No newline at end of file +``` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2303e889e..c7eefa0a2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -320,6 +320,14 @@ account: # Defaults to `false`. # This has no effect if password login is disabled. login_with_email_allowed: false + + # Whether registration tokens are required for password registrations. + # + # Defaults to `false`. + # + # 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 ``` ## `captcha` @@ -712,7 +720,7 @@ upstream_oauth2: # Additional parameters to include in the authorization request #additional_authorization_parameters: # foo: "bar" - + # Whether the `login_hint` should be forwarded to the provider in the # authorization request. #forward_login_hint: false diff --git a/templates/pages/register/steps/registration_token.html b/templates/pages/register/steps/registration_token.html new file mode 100644 index 000000000..d58c82e7f --- /dev/null +++ b/templates/pages/register/steps/registration_token.html @@ -0,0 +1,44 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.key_solid() }} +
+
+

{{ _("mas.registration_token.headline") }}

+

{{ _("mas.registration_token.description") }}

+
+
+ +
+
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% call(f) field.field(label=_("mas.registration_token.field"), name="token", form_state=form, class="mb-4") %} + + {% endcall %} + + {{ button.button(text=_("action.continue")) }} +
+
+{% endblock content %} diff --git a/translations/en.json b/translations/en.json index 5b2a5ad04..d17e09338 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,7 +10,7 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { @@ -635,6 +635,20 @@ "context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" } }, + "registration_token": { + "description": "Enter a registration token provided by the homeserver administrator.", + "@description": { + "context": "pages/register/steps/registration_token.html:17:25-64" + }, + "field": "Registration token", + "@field": { + "context": "pages/register/steps/registration_token.html:33:35-68" + }, + "headline": "Registration token", + "@headline": { + "context": "pages/register/steps/registration_token.html:16:27-63" + } + }, "scope": { "edit_profile": "Edit your profile and contact details", "@edit_profile": {