feat(!783): Add admin commands for managing tokens

This commit is contained in:
Ginger 2026-01-06 11:13:11 -05:00
parent 42f4ec34cd
commit ca77970ff3
10 changed files with 233 additions and 44 deletions

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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(())
}

47
src/admin/token/mod.rs Normal file
View file

@ -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<u64>,
/// 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<String>,
/// This token will never expire.
#[arg(long)]
immortal: bool,
}

View file

@ -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();

View file

@ -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

View file

@ -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<Database>) -> 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<DatabaseTokenInfo> {
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
}
})
}

View file

@ -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<str> 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<str> 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<Arc<Self>> {
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<ValidToken<'_>> {
pub fn get_config_file_token(&self) -> Option<ValidToken> {
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<ValidToken<'token>> {
pub async fn validate_token(&self, token: String) -> Option<ValidToken> {
// 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<Item = ValidToken<'_>> + 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<Item = ValidToken> + 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)
}
}

View file

@ -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