Skip to content

Registration tokens #4619

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
69 changes: 68 additions & 1 deletion crates/cli/src/commands/manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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<String>,

/// 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<u32>,

/// 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<u32>,
},

/// Trigger a provisioning job for all users
ProvisionAllUsers,

Expand Down Expand Up @@ -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)?;
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions crates/config/src/sections/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
}
}
}
Expand All @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/data-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@ pub use self::{
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken,
},
};
3 changes: 3 additions & 0 deletions crates/data-model/src/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
47 changes: 47 additions & 0 deletions crates/data-model/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,60 @@ 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<u32>,
pub times_used: u32,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}

impl UserRegistrationToken {
/// Returns `true` if the token is still valid and can be used
#[must_use]
pub fn is_valid(&self, now: DateTime<Utc>) -> 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<Utc>) -> bool {
self.is_valid(now)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserRegistration {
pub id: Ulid,
pub username: String,
pub display_name: Option<String>,
pub terms_url: Option<Url>,
pub email_authentication_id: Option<Ulid>,
pub user_registration_token_id: Option<Ulid>,
pub password: Option<UserRegistrationPassword>,
pub post_auth_action: Option<serde_json::Value>,
pub ip_address: Option<IpAddr>,
Expand Down
5 changes: 5 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
86 changes: 86 additions & 0 deletions crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,

/// Number of times this token has been used
times_used: u32,

/// When the token was created
created_at: DateTime<Utc>,

/// When the token was last used. If null, the token has never been used.
last_used_at: Option<DateTime<Utc>>,

/// When the token expires. If null, the token never expires.
expires_at: Option<DateTime<Utc>>,

/// When the token was revoked. If null, the token is not revoked.
revoked_at: Option<DateTime<Utc>>,
}

impl UserRegistrationToken {
pub fn new(token: mas_data_model::UserRegistrationToken, now: DateTime<Utc>) -> 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()),
},
]
}
}
26 changes: 26 additions & 0 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading