feat(!783): Initial implementation

Adds support for extra limited-use registration tokens
stored in the database, and a new service to manage them.
This commit is contained in:
Ginger 2026-01-05 17:27:00 -05:00
parent ecf74bb31f
commit 42f4ec34cd
9 changed files with 288 additions and 82 deletions

View file

@ -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<crate::State>,
body: Ruma<check_registration_token_validity::v1::Request>,
) -> Result<check_registration_token_validity::v1::Response> {
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:

View file

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

View file

@ -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<String>,
/// 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<PathBuf>,
/// 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

@ -18,7 +18,6 @@ pub struct Service {
pub server_user: OwnedUserId,
pub admin_alias: OwnedRoomAliasId,
pub turn_secret: String,
pub registration_token: Option<String>,
}
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,
}))
}

View file

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

View file

@ -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<Map>,
}
/// 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<TokenExpires>,
}
impl DatabaseTokenInfo {
pub(super) fn new(creator: OwnedUserId, expires: Option<TokenExpires>) -> 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<Database>) -> 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<DatabaseTokenInfo> {
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<Item = (&str, DatabaseTokenInfo)> + 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
}
})
}
}

View file

@ -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<config::Service>,
}
/// 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<str> 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<Arc<Self>> {
Ok(Arc::new(Self {
db: Data::new(args.db),
services: Services {
config: args.depend::<config::Service>("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<TokenExpires>,
) -> (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<ValidToken<'_>> {
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<ValidToken<'token>> {
// 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<Item = ValidToken<'_>> + Send + '_ {
stream::iter(self.get_config_file_token()).chain(self.db.iterate_and_clean_tokens().map(
|(token, info)| ValidToken {
token,
source: ValidTokenSource::Database(info),
},
))
}
}

View file

@ -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<media::Service>,
pub presence: Arc<presence::Service>,
pub pusher: Arc<pusher::Service>,
pub registration_tokens: Arc<registration_tokens::Service>,
pub resolver: Arc<resolver::Service>,
pub rooms: rooms::Service,
pub federation: Arc<federation::Service>,
@ -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),

View file

@ -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<RequestMap>,
@ -28,6 +25,7 @@ struct Services {
globals: Dep<globals::Service>,
users: Dep<users::Service>,
config: Dep<config::Service>,
registration_tokens: Dep<registration_tokens::Service>,
}
struct Data {
@ -50,6 +48,8 @@ impl crate::Service for Service {
globals: args.depend::<globals::Service>("globals"),
users: args.depend::<users::Service>("users"),
config: args.depend::<config::Service>("config"),
registration_tokens: args
.depend::<registration_tokens::Service>("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<HashSet<String>> {
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 {