From ca77970ff3a01edd850fbbdbedca3335a648c7db Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 6 Jan 2026 11:13:11 -0500 Subject: [PATCH] feat(!783): Add admin commands for managing tokens --- conduwuit-example.toml | 12 +--- src/admin/admin.rs | 24 ++++++-- src/admin/mod.rs | 1 + src/admin/token/commands.rs | 74 +++++++++++++++++++++++++ src/admin/token/mod.rs | 47 ++++++++++++++++ src/api/client/account.rs | 2 +- src/database/maps.rs | 4 ++ src/service/registration_tokens/data.rs | 55 +++++++++++++++--- src/service/registration_tokens/mod.rs | 56 +++++++++++++------ src/service/uiaa/mod.rs | 2 +- 10 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 src/admin/token/commands.rs create mode 100644 src/admin/token/mod.rs diff --git a/conduwuit-example.toml b/conduwuit-example.toml index ad25d4a7..95e7ad04 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -421,7 +421,7 @@ # `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`. # #allow_registration = false @@ -458,16 +458,6 @@ # #registration_token = -# 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" -# -#registration_token_file = - # 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/admin/admin.rs b/src/admin/admin.rs index c9f89a6d..f4b0b687 100644 --- a/src/admin/admin.rs +++ b/src/admin/admin.rs @@ -2,10 +2,17 @@ use clap::Parser; use conduwuit::Result; use crate::{ - appservice, appservice::AppserviceCommand, check, check::CheckCommand, context::Context, - debug, debug::DebugCommand, federation, federation::FederationCommand, media, - media::MediaCommand, query, query::QueryCommand, room, room::RoomCommand, server, - server::ServerCommand, user, user::UserCommand, + appservice::{self, AppserviceCommand}, + check::{self, CheckCommand}, + context::Context, + debug::{self, DebugCommand}, + federation::{self, FederationCommand}, + media::{self, MediaCommand}, + query::{self, QueryCommand}, + room::{self, RoomCommand}, + server::{self, ServerCommand}, + token::{self, TokenCommand}, + user::{self, UserCommand}, }; #[derive(Debug, Parser)] @@ -19,6 +26,10 @@ pub enum AdminCommand { /// - Commands for managing local users Users(UserCommand), + #[command(subcommand)] + /// - Commands for managing registration tokens + Tokens(TokenCommand), + #[command(subcommand)] /// - Commands for managing rooms Rooms(RoomCommand), @@ -64,6 +75,11 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res context.bail_restricted()?; user::process(command, context).await }, + | Tokens(command) => { + // token commands are all restricted + context.bail_restricted()?; + token::process(command, context).await + }, | Rooms(command) => room::process(command, context).await, | Federation(command) => federation::process(command, context).await, | Server(command) => server::process(command, context).await, diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 1e46dc7f..93c5ff58 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod media; pub(crate) mod query; pub(crate) mod room; pub(crate) mod server; +pub(crate) mod token; pub(crate) mod user; extern crate conduwuit_api as api; diff --git a/src/admin/token/commands.rs b/src/admin/token/commands.rs new file mode 100644 index 00000000..fb727155 --- /dev/null +++ b/src/admin/token/commands.rs @@ -0,0 +1,74 @@ +use conduwuit::{Err, Result, utils}; +use conduwuit_macros::admin_command; +use futures::StreamExt; +use service::registration_tokens::TokenExpires; + +#[admin_command] +pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result { + let expires = { + if expires.immortal { + None + } else if let Some(max_uses) = expires.max_uses { + Some(TokenExpires::AfterUses(max_uses)) + } else if let Some(max_age) = expires + .max_age + .as_deref() + .map(|max_age| utils::time::timepoint_from_now(utils::time::parse_duration(max_age)?)) + .transpose()? + { + Some(TokenExpires::AfterTime(max_age)) + } else { + unreachable!(); + } + }; + + let (token, info) = self + .services + .registration_tokens + .issue_token(self.sender_or_service_user().into(), expires); + + self.write_str(&format!( + "New registration token issued: `{token}`. {}.", + if let Some(expires) = info.expires { + format!("{expires}") + } else { + "Never expires".to_owned() + } + )) + .await +} + +#[admin_command] +pub(super) async fn revoke_token(&self, token: String) -> Result { + let Some(token) = self + .services + .registration_tokens + .validate_token(token) + .await + else { + return Err!("This token does not exist or has already expired."); + }; + + self.services.registration_tokens.revoke_token(token)?; + + self.write_str("Token revoked successfully.").await +} + +#[admin_command] +pub(super) async fn list_tokens(&self) -> Result { + let tokens: Vec<_> = self + .services + .registration_tokens + .iterate_tokens() + .collect() + .await; + + self.write_str(&format!("Found {} registration tokens:\n", tokens.len())) + .await?; + + for token in tokens { + self.write_str(&format!("- {token}\n")).await?; + } + + Ok(()) +} diff --git a/src/admin/token/mod.rs b/src/admin/token/mod.rs new file mode 100644 index 00000000..0e0e952e --- /dev/null +++ b/src/admin/token/mod.rs @@ -0,0 +1,47 @@ +mod commands; + +use clap::{Args, Subcommand}; +use conduwuit::Result; + +use crate::admin_command_dispatch; + +#[admin_command_dispatch] +#[derive(Debug, Subcommand)] +pub enum TokenCommand { + /// - Issue a new registration token + #[clap(name = "issue")] + IssueToken { + /// When this token will expire. + #[command(flatten)] + expires: TokenExpires, + }, + + /// - Revoke a registration token + #[clap(name = "revoke")] + RevokeToken { + /// The token to revoke. + token: String, + }, + + /// - List all registration tokens + #[clap(name = "list")] + ListTokens, +} + +#[derive(Debug, Args)] +#[group(required = true, multiple = false)] +pub struct TokenExpires { + /// The maximum number of times this token is allowed to be used before it + /// expires. + #[arg(long)] + max_uses: Option, + + /// The maximum age of this token (e.g. 30s, 5m, 7d). It will expire after + /// this much time has passed. + #[arg(long)] + max_age: Option, + + /// This token will never expire. + #[arg(long)] + immortal: bool, +} diff --git a/src/api/client/account.rs b/src/api/client/account.rs index 77609666..2c377d03 100644 --- a/src/api/client/account.rs +++ b/src/api/client/account.rs @@ -864,7 +864,7 @@ pub(crate) async fn check_registration_token_validity( let valid = services .registration_tokens - .validate_token(&body.token) + .validate_token(body.token.clone()) .await .is_some(); diff --git a/src/database/maps.rs b/src/database/maps.rs index d63a6fc8..b5a7d05a 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -141,6 +141,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "referencedevents", ..descriptor::RANDOM }, + Descriptor { + name: "registrationtoken_info", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "roomid_invitedcount", ..descriptor::RANDOM_SMALL diff --git a/src/service/registration_tokens/data.rs b/src/service/registration_tokens/data.rs index f3723d2f..1d5c3a7e 100644 --- a/src/service/registration_tokens/data.rs +++ b/src/service/registration_tokens/data.rs @@ -1,6 +1,9 @@ use std::{sync::Arc, time::SystemTime}; -use conduwuit::utils::stream::{ReadyExt, TryIgnore}; +use conduwuit::utils::{ + self, + stream::{ReadyExt, TryIgnore}, +}; use database::{Database, Deserialized, Json, Map}; use futures::Stream; use ruma::OwnedUserId; @@ -32,7 +35,7 @@ impl DatabaseTokenInfo { #[must_use] pub fn is_valid(&self) -> bool { match self.expires { - | Some(TokenExpires::AfterUses(max_uses)) => self.uses <= max_uses, + | Some(TokenExpires::AfterUses(max_uses)) => self.uses < max_uses, | Some(TokenExpires::AfterTime(expiry_time)) => { let now = SystemTime::now(); @@ -43,12 +46,46 @@ impl DatabaseTokenInfo { } } +impl std::fmt::Display for DatabaseTokenInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Token created by {} and used {} time. ", &self.creator, self.uses)?; + if let Some(expires) = &self.expires { + write!(f, "{expires}.")?; + } else { + write!(f, "Never expires.")?; + } + + Ok(()) + } +} + #[derive(Debug, Serialize, Deserialize)] pub enum TokenExpires { AfterUses(u64), AfterTime(SystemTime), } +impl std::fmt::Display for TokenExpires { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + | Self::AfterUses(max_uses) => write!(f, "Expires after {max_uses} uses"), + | Self::AfterTime(max_age) => { + let now = SystemTime::now(); + let formatted_expiry = utils::time::format(*max_age, "%+"); + + match max_age.duration_since(now) { + | Ok(duration) => write!( + f, + "Expires in {} ({formatted_expiry})", + utils::time::pretty(duration) + ), + | Err(_) => write!(f, "Expired at {formatted_expiry}"), + } + }, + } + } +} + impl Data { pub(super) fn new(db: &Arc) -> Self { Self { @@ -58,16 +95,16 @@ impl Data { /// 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)); + self.registrationtoken_info.raw_put(token, Json(info)); } /// Delete a registration token. - pub(super) fn revoke_token(&self, token: &str) { self.registrationtoken_info.del(token); } + pub(super) fn revoke_token(&self, token: &str) { self.registrationtoken_info.remove(token); } /// Look up a registration token's metadata. pub(super) async fn lookup_token_info(&self, token: &str) -> Option { self.registrationtoken_info - .qry(token) + .get(token) .await .deserialized() .ok() @@ -80,12 +117,12 @@ impl Data { self.registrationtoken_info .stream() .ignore_err() - .ready_filter(|item: &(&str, DatabaseTokenInfo)| { + .ready_filter_map(|item: (&str, DatabaseTokenInfo)| { if item.1.is_valid() { - true + Some(item) } else { - self.registrationtoken_info.del(item.0); - false + self.registrationtoken_info.remove(item.0); + None } }) } diff --git a/src/service/registration_tokens/mod.rs b/src/service/registration_tokens/mod.rs index 437cfe46..2f75aa3c 100644 --- a/src/service/registration_tokens/mod.rs +++ b/src/service/registration_tokens/mod.rs @@ -10,7 +10,7 @@ use ruma::OwnedUserId; use crate::{Dep, config}; -const RANDOM_TOKEN_LENGTH: usize = 64; +const RANDOM_TOKEN_LENGTH: usize = 16; pub struct Service { db: Data, @@ -22,16 +22,24 @@ struct Services { } /// A validated registration token which may be used to create an account. -pub struct ValidToken<'token> { - pub token: &'token str, +#[derive(Debug)] +pub struct ValidToken { + pub token: String, pub source: ValidTokenSource, } -impl PartialEq for ValidToken<'_> { +impl std::fmt::Display for ValidToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "`{}` --- {}", self.token, &self.source) + } +} + +impl PartialEq for ValidToken { fn eq(&self, other: &str) -> bool { self.token == other } } /// The source of a valid database token. +#[derive(Debug)] pub enum ValidTokenSource { /// The static token set in the homeserver's config file, which is /// always valid. @@ -40,6 +48,15 @@ pub enum ValidTokenSource { Database(DatabaseTokenInfo), } +impl std::fmt::Display for ValidTokenSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + | Self::ConfigFile => write!(f, "Token defined in config."), + | Self::Database(info) => info.fmt(f), + } + } +} + impl crate::Service for Service { fn build(args: crate::Args<'_>) -> Result> { Ok(Arc::new(Self { @@ -68,11 +85,11 @@ impl Service { } /// Get the registration token set in the config file, if it exists. - pub fn get_config_file_token(&self) -> Option> { + pub fn get_config_file_token(&self) -> Option { self.services .config .registration_token - .as_deref() + .clone() .map(|token| ValidToken { token, source: ValidTokenSource::ConfigFile, @@ -80,7 +97,7 @@ impl Service { } /// Validate a registration token. - pub async fn validate_token<'token>(&self, token: &'token str) -> Option> { + pub async fn validate_token(&self, token: String) -> Option { // Check the registration token in the config first if self .get_config_file_token() @@ -93,7 +110,7 @@ impl Service { } // Now check the database - if let Some(token_info) = self.db.lookup_token_info(token).await + if let Some(token_info) = self.db.lookup_token_info(&token).await && token_info.is_valid() { return Some(ValidToken { @@ -107,7 +124,7 @@ impl Service { } /// Mark a valid token as having been used to create a new account. - pub fn mark_token_as_used(&self, ValidToken { token, source }: ValidToken<'_>) { + 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 @@ -115,7 +132,7 @@ impl Service { | ValidTokenSource::Database(mut info) => { info.uses = info.uses.saturating_add(1); - self.db.save_token(token, &info); + self.db.save_token(&token, &info); }, } } @@ -124,7 +141,7 @@ impl Service { /// /// 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 { + pub fn revoke_token(&self, ValidToken { token, source }: ValidToken) -> Result { match source { | ValidTokenSource::ConfigFile => { // the config file token cannot be revoked @@ -134,19 +151,22 @@ impl Service { ) }, | ValidTokenSource::Database(_) => { - self.db.revoke_token(token); + 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, + pub fn iterate_tokens(&self) -> impl Stream + Send + '_ { + let db_tokens = self + .db + .iterate_and_clean_tokens() + .map(|(token, info)| ValidToken { + token: token.to_owned(), source: ValidTokenSource::Database(info), - }, - )) + }); + + stream::iter(self.get_config_file_token()).chain(db_tokens) } } diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 674249c1..76874e69 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -209,7 +209,7 @@ pub async fn try_auth( } }, | AuthData::RegistrationToken(t) => { - let token = t.token.trim(); + let token = t.token.trim().to_owned(); if let Some(valid_token) = self .services