From 42f4ec34cde5f7ef111cd1e54f610033d875fc52 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 5 Jan 2026 17:27:00 -0500 Subject: [PATCH] feat(!783): Initial implementation Adds support for extra limited-use registration tokens stored in the database, and a new service to manage them. --- src/api/client/account.rs | 30 +++-- src/core/config/check.rs | 18 --- src/core/config/mod.rs | 11 +- src/service/globals/mod.rs | 15 --- src/service/mod.rs | 1 + src/service/registration_tokens/data.rs | 92 ++++++++++++++ src/service/registration_tokens/mod.rs | 152 ++++++++++++++++++++++++ src/service/services.rs | 7 +- src/service/uiaa/mod.rs | 44 +++---- 9 files changed, 288 insertions(+), 82 deletions(-) create mode 100644 src/service/registration_tokens/data.rs create mode 100644 src/service/registration_tokens/mod.rs diff --git a/src/api/client/account.rs b/src/api/client/account.rs index ab2a5d00..77609666 100644 --- a/src/api/client/account.rs +++ b/src/api/client/account.rs @@ -185,7 +185,10 @@ pub(crate) async fn register_route( if is_guest && (!services.config.allow_guest_registration || (services.config.allow_registration - && services.globals.registration_token.is_some())) + && services + .registration_tokens + .get_config_file_token() + .is_some())) { info!( "Guest registration disabled / registration enabled with token configured, \ @@ -301,7 +304,13 @@ pub(crate) async fn register_route( let skip_auth = body.appservice_info.is_some() || is_guest; // Populate required UIAA flows - if services.globals.registration_token.is_some() { + if services + .registration_tokens + .iterate_tokens() + .next() + .await + .is_some() + { // Registration token required uiaainfo.flows.push(AuthFlow { stages: vec![AuthType::RegistrationToken], @@ -846,19 +855,20 @@ pub(crate) async fn request_3pid_management_token_via_msisdn_route( /// # `GET /_matrix/client/v1/register/m.login.registration_token/validity` /// -/// Checks if the provided registration token is valid at the time of checking -/// -/// Currently does not have any ratelimiting, and this isn't very practical as -/// there is only one registration token allowed. +/// Checks if the provided registration token is valid at the time of checking. pub(crate) async fn check_registration_token_validity( State(services): State, body: Ruma, ) -> Result { - let Some(reg_token) = services.globals.registration_token.clone() else { - return Err!(Request(Forbidden("Server does not allow token registration"))); - }; + // TODO: ratelimit this pretty heavily - Ok(check_registration_token_validity::v1::Response { valid: reg_token == body.token }) + let valid = services + .registration_tokens + .validate_token(&body.token) + .await + .is_some(); + + Ok(check_registration_token_validity::v1::Response { valid }) } /// Runs through all the deactivation steps: diff --git a/src/core/config/check.rs b/src/core/config/check.rs index 6710a91a..192e9818 100644 --- a/src/core/config/check.rs +++ b/src/core/config/check.rs @@ -146,22 +146,6 @@ pub fn check(config: &Config) -> Result { )); } - // check if we can read the token file path, and check if the file is empty - if config.registration_token_file.as_ref().is_some_and(|path| { - let Ok(token) = std::fs::read_to_string(path).inspect_err(|e| { - error!("Failed to read the registration token file: {e}"); - }) else { - return true; - }; - - token == String::new() - }) { - return Err!(Config( - "registration_token_file", - "Registration token file was specified but is empty or failed to be read" - )); - } - if config.max_request_size < 10_000_000 { return Err!(Config( "max_request_size", @@ -190,7 +174,6 @@ pub fn check(config: &Config) -> Result { if config.allow_registration && !config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse && config.registration_token.is_none() - && config.registration_token_file.is_none() && config.recaptcha_site_key.is_none() { return Err!(Config( @@ -209,7 +192,6 @@ pub fn check(config: &Config) -> Result { if config.allow_registration && config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse && config.registration_token.is_none() - && config.registration_token_file.is_none() { warn!( "Open registration is enabled via setting \ diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 60921b0e..7b9c043f 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -545,7 +545,7 @@ pub struct Config { /// `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse` /// /// If you would like registration only via token reg, please configure - /// `registration_token` or `registration_token_file`. + /// `registration_token`. #[serde(default)] pub allow_registration: bool, @@ -583,15 +583,6 @@ pub struct Config { /// display: sensitive pub registration_token: Option, - /// Path to a file on the system that gets read for additional registration - /// tokens. Multiple tokens can be added if you separate them with - /// whitespace - /// - /// continuwuity must be able to access the file, and it must not be empty - /// - /// example: "/etc/continuwuity/.reg_token" - pub registration_token_file: Option, - /// The public site key for reCaptcha. If this is provided, reCaptcha /// becomes required during registration. If both captcha *and* /// registration token are enabled, both will be required during diff --git a/src/service/globals/mod.rs b/src/service/globals/mod.rs index f5c99158..1fb6d2cf 100644 --- a/src/service/globals/mod.rs +++ b/src/service/globals/mod.rs @@ -18,7 +18,6 @@ pub struct Service { pub server_user: OwnedUserId, pub admin_alias: OwnedRoomAliasId, pub turn_secret: String, - pub registration_token: Option, } type RateLimitState = (Instant, u32); // Time if last failed try, number of failed tries @@ -41,19 +40,6 @@ impl crate::Service for Service { }, ); - let registration_token = config.registration_token_file.as_ref().map_or_else( - || config.registration_token.clone(), - |path| { - let Ok(token) = std::fs::read_to_string(path).inspect_err(|e| { - error!("Failed to read the registration token file: {e}"); - }) else { - return config.registration_token.clone(); - }; - - Some(token.trim().to_owned()) - }, - ); - Ok(Arc::new(Self { db, server: args.server.clone(), @@ -66,7 +52,6 @@ impl crate::Service for Service { ) .expect("@conduit:server_name is valid"), turn_secret, - registration_token, })) } diff --git a/src/service/mod.rs b/src/service/mod.rs index 1e2af7a4..1fa16d81 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -24,6 +24,7 @@ pub mod media; pub mod moderation; pub mod presence; pub mod pusher; +pub mod registration_tokens; pub mod resolver; pub mod rooms; pub mod sending; diff --git a/src/service/registration_tokens/data.rs b/src/service/registration_tokens/data.rs new file mode 100644 index 00000000..f3723d2f --- /dev/null +++ b/src/service/registration_tokens/data.rs @@ -0,0 +1,92 @@ +use std::{sync::Arc, time::SystemTime}; + +use conduwuit::utils::stream::{ReadyExt, TryIgnore}; +use database::{Database, Deserialized, Json, Map}; +use futures::Stream; +use ruma::OwnedUserId; +use serde::{Deserialize, Serialize}; + +pub(super) struct Data { + registrationtoken_info: Arc, +} + +/// Metadata of a registration token. +#[derive(Debug, Serialize, Deserialize)] +pub struct DatabaseTokenInfo { + /// The admin user who created this token. + pub creator: OwnedUserId, + /// The number of times this token has been used to create an account. + pub uses: u64, + /// When this token will expire, if it expires. + pub expires: Option, +} + +impl DatabaseTokenInfo { + pub(super) fn new(creator: OwnedUserId, expires: Option) -> Self { + Self { creator, uses: 0, expires } + } + + /// Determine whether this token info represents a valid token, i.e. one + /// that has not expired according to its [`Self::expires`] property. If + /// [`Self::expires`] is [`None`], this function will always return `true`. + #[must_use] + pub fn is_valid(&self) -> bool { + match self.expires { + | Some(TokenExpires::AfterUses(max_uses)) => self.uses <= max_uses, + | Some(TokenExpires::AfterTime(expiry_time)) => { + let now = SystemTime::now(); + + expiry_time >= now + }, + | None => true, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum TokenExpires { + AfterUses(u64), + AfterTime(SystemTime), +} + +impl Data { + pub(super) fn new(db: &Arc) -> Self { + Self { + registrationtoken_info: db["registrationtoken_info"].clone(), + } + } + + /// Associate a registration token with its metadata in the database. + pub(super) fn save_token(&self, token: &str, info: &DatabaseTokenInfo) { + self.registrationtoken_info.put(token, Json(info)); + } + + /// Delete a registration token. + pub(super) fn revoke_token(&self, token: &str) { self.registrationtoken_info.del(token); } + + /// Look up a registration token's metadata. + pub(super) async fn lookup_token_info(&self, token: &str) -> Option { + self.registrationtoken_info + .qry(token) + .await + .deserialized() + .ok() + } + + /// Iterate over all valid tokens and delete expired ones. + pub(super) fn iterate_and_clean_tokens( + &self, + ) -> impl Stream + Send + '_ { + self.registrationtoken_info + .stream() + .ignore_err() + .ready_filter(|item: &(&str, DatabaseTokenInfo)| { + if item.1.is_valid() { + true + } else { + self.registrationtoken_info.del(item.0); + false + } + }) + } +} diff --git a/src/service/registration_tokens/mod.rs b/src/service/registration_tokens/mod.rs new file mode 100644 index 00000000..437cfe46 --- /dev/null +++ b/src/service/registration_tokens/mod.rs @@ -0,0 +1,152 @@ +mod data; + +use std::sync::Arc; + +use conduwuit::{Err, Result, utils}; +use data::Data; +pub use data::{DatabaseTokenInfo, TokenExpires}; +use futures::{Stream, StreamExt, stream}; +use ruma::OwnedUserId; + +use crate::{Dep, config}; + +const RANDOM_TOKEN_LENGTH: usize = 64; + +pub struct Service { + db: Data, + services: Services, +} + +struct Services { + config: Dep, +} + +/// A validated registration token which may be used to create an account. +pub struct ValidToken<'token> { + pub token: &'token str, + pub source: ValidTokenSource, +} + +impl PartialEq for ValidToken<'_> { + fn eq(&self, other: &str) -> bool { self.token == other } +} + +/// The source of a valid database token. +pub enum ValidTokenSource { + /// The static token set in the homeserver's config file, which is + /// always valid. + ConfigFile, + /// A database token which has been checked to be valid. + Database(DatabaseTokenInfo), +} + +impl crate::Service for Service { + fn build(args: crate::Args<'_>) -> Result> { + Ok(Arc::new(Self { + db: Data::new(args.db), + services: Services { + config: args.depend::("config"), + }, + })) + } + + fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } +} + +impl Service { + /// Issue a new registration token and save it in the database. + pub fn issue_token( + &self, + creator: OwnedUserId, + expires: Option, + ) -> (String, DatabaseTokenInfo) { + let token = utils::random_string(RANDOM_TOKEN_LENGTH); + let info = DatabaseTokenInfo::new(creator, expires); + + self.db.save_token(&token, &info); + (token, info) + } + + /// Get the registration token set in the config file, if it exists. + pub fn get_config_file_token(&self) -> Option> { + self.services + .config + .registration_token + .as_deref() + .map(|token| ValidToken { + token, + source: ValidTokenSource::ConfigFile, + }) + } + + /// Validate a registration token. + pub async fn validate_token<'token>(&self, token: &'token str) -> Option> { + // Check the registration token in the config first + if self + .get_config_file_token() + .is_some_and(|valid_token| valid_token == *token) + { + return Some(ValidToken { + token, + source: ValidTokenSource::ConfigFile, + }); + } + + // Now check the database + if let Some(token_info) = self.db.lookup_token_info(token).await + && token_info.is_valid() + { + return Some(ValidToken { + token, + source: ValidTokenSource::Database(token_info), + }); + } + + // Otherwise it's not valid + None + } + + /// Mark a valid token as having been used to create a new account. + pub fn mark_token_as_used(&self, ValidToken { token, source }: ValidToken<'_>) { + match source { + | ValidTokenSource::ConfigFile => { + // we don't track uses of the config file token, do nothing + }, + | ValidTokenSource::Database(mut info) => { + info.uses = info.uses.saturating_add(1); + + self.db.save_token(token, &info); + }, + } + } + + /// Try to revoke a valid token. + /// + /// Note that some tokens (like the one set in the config file) cannot be + /// revoked. + pub fn revoke_token(&self, ValidToken { token, source }: ValidToken<'_>) -> Result { + match source { + | ValidTokenSource::ConfigFile => { + // the config file token cannot be revoked + Err!( + "The token set in the config file cannot be revoked. Edit the config file \ + to change it." + ) + }, + | ValidTokenSource::Database(_) => { + self.db.revoke_token(token); + Ok(()) + }, + } + } + + /// Iterate over all valid registration tokens. + pub fn iterate_tokens(&self) -> impl Stream> + Send + '_ { + stream::iter(self.get_config_file_token()).chain(self.db.iterate_and_clean_tokens().map( + |(token, info)| ValidToken { + token, + source: ValidTokenSource::Database(info), + }, + )) + } +} diff --git a/src/service/services.rs b/src/service/services.rs index 5b71c536..656be085 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -11,8 +11,9 @@ use crate::{ account_data, admin, announcements, antispam, appservice, client, config, emergency, federation, globals, key_backups, manager::Manager, - media, moderation, presence, pusher, resolver, rooms, sending, server_keys, service, - service::{Args, Map, Service}, + media, moderation, presence, pusher, registration_tokens, resolver, rooms, sending, + server_keys, + service::{self, Args, Map, Service}, sync, transaction_ids, uiaa, users, }; @@ -28,6 +29,7 @@ pub struct Services { pub media: Arc, pub presence: Arc, pub pusher: Arc, + pub registration_tokens: Arc, pub resolver: Arc, pub rooms: rooms::Service, pub federation: Arc, @@ -77,6 +79,7 @@ impl Services { media: build!(media::Service), presence: build!(presence::Service), pusher: build!(pusher::Service), + registration_tokens: build!(registration_tokens::Service), rooms: rooms::Service { alias: build!(rooms::alias::Service), auth_chain: build!(rooms::auth_chain::Service), diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 71e03d63..674249c1 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{BTreeMap, HashSet}, - sync::Arc, -}; +use std::{collections::BTreeMap, sync::Arc}; use conduwuit::{ Err, Error, Result, SyncRwLock, err, error, implement, utils, @@ -16,7 +13,7 @@ use ruma::{ }, }; -use crate::{Dep, config, globals, users}; +use crate::{Dep, config, globals, registration_tokens, users}; pub struct Service { userdevicesessionid_uiaarequest: SyncRwLock, @@ -28,6 +25,7 @@ struct Services { globals: Dep, users: Dep, config: Dep, + registration_tokens: Dep, } struct Data { @@ -50,6 +48,8 @@ impl crate::Service for Service { globals: args.depend::("globals"), users: args.depend::("users"), config: args.depend::("config"), + registration_tokens: args + .depend::("registration_tokens"), }, })) } @@ -57,26 +57,6 @@ impl crate::Service for Service { fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } } -#[implement(Service)] -pub async fn read_tokens(&self) -> Result> { - let mut tokens = HashSet::new(); - if let Some(file) = &self.services.config.registration_token_file.as_ref() { - match std::fs::read_to_string(file) { - | Ok(text) => { - text.split_ascii_whitespace().for_each(|token| { - tokens.insert(token.to_owned()); - }); - }, - | Err(e) => error!("Failed to read the registration token file: {e}"), - } - } - if let Some(token) = &self.services.config.registration_token { - tokens.insert(token.to_owned()); - } - - Ok(tokens) -} - /// Creates a new Uiaa session. Make sure the session token is unique. #[implement(Service)] pub fn create( @@ -229,8 +209,18 @@ pub async fn try_auth( } }, | AuthData::RegistrationToken(t) => { - let tokens = self.read_tokens().await?; - if tokens.contains(t.token.trim()) { + let token = t.token.trim(); + + if let Some(valid_token) = self + .services + .registration_tokens + .validate_token(token) + .await + { + self.services + .registration_tokens + .mark_token_as_used(valid_token); + uiaainfo.completed.push(AuthType::RegistrationToken); } else { uiaainfo.auth_error = Some(StandardErrorBody {