Skip to content

Commit 0f00bf0

Browse files
authored
Merge 9726f35 into 46eb75f
2 parents 46eb75f + 9726f35 commit 0f00bf0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+3780
-26
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ anyhow.workspace = true
1818
axum.workspace = true
1919
bytes.workspace = true
2020
camino.workspace = true
21+
chrono.workspace = true
2122
clap.workspace = true
2223
console = "0.15.11"
2324
dialoguer = { version = "0.11.0", default-features = false, features = [

crates/cli/src/commands/manage.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use std::{collections::BTreeMap, process::ExitCode};
88

99
use anyhow::Context;
10+
use chrono::Duration;
1011
use clap::{ArgAction, CommandFactory, Parser};
1112
use console::{Alignment, Style, Term, pad_str, style};
1213
use dialoguer::{Confirm, FuzzySelect, Input, Password, theme::ColorfulTheme};
@@ -28,7 +29,10 @@ use mas_storage::{
2829
user::{BrowserSessionFilter, UserEmailRepository, UserPasswordRepository, UserRepository},
2930
};
3031
use mas_storage_pg::{DatabaseError, PgRepository};
31-
use rand::{RngCore, SeedableRng};
32+
use rand::{
33+
RngCore, SeedableRng,
34+
distributions::{Alphanumeric, DistString as _},
35+
};
3236
use sqlx::{Acquire, types::Uuid};
3337
use tracing::{error, info, info_span, warn};
3438
use zeroize::Zeroizing;
@@ -95,6 +99,24 @@ enum Subcommand {
9599
admin: bool,
96100
},
97101

102+
/// Create a new user registration token
103+
IssueUserRegistrationToken {
104+
/// Specific token string to use. If not provided, a random token will
105+
/// be generated.
106+
#[arg(long)]
107+
token: Option<String>,
108+
109+
/// Maximum number of times this token can be used.
110+
/// If not provided, the token can be used an unlimited number of times.
111+
#[arg(long)]
112+
usage_limit: Option<u32>,
113+
114+
/// Time in seconds after which the token expires.
115+
/// If not provided, the token never expires.
116+
#[arg(long)]
117+
expires_in: Option<u32>,
118+
},
119+
98120
/// Trigger a provisioning job for all users
99121
ProvisionAllUsers,
100122

@@ -330,6 +352,38 @@ impl Options {
330352
Ok(ExitCode::SUCCESS)
331353
}
332354

355+
SC::IssueUserRegistrationToken {
356+
token,
357+
usage_limit,
358+
expires_in,
359+
} => {
360+
let _span = info_span!("cli.manage.add_user_registration_token").entered();
361+
362+
let database_config = DatabaseConfig::extract_or_default(figment)?;
363+
let mut conn = database_connection_from_config(&database_config).await?;
364+
let txn = conn.begin().await?;
365+
let mut repo = PgRepository::from_conn(txn);
366+
367+
// Calculate expiration time if provided
368+
let expires_at =
369+
expires_in.map(|seconds| clock.now() + Duration::seconds(seconds.into()));
370+
371+
// Generate a token if not provided
372+
let token_str = token.unwrap_or_else(|| Alphanumeric.sample_string(&mut rng, 12));
373+
374+
// Create the token
375+
let registration_token = repo
376+
.user_registration_token()
377+
.add(&mut rng, &clock, token_str, usage_limit, expires_at)
378+
.await?;
379+
380+
repo.into_inner().commit().await?;
381+
382+
info!(%registration_token.id, "Created user registration token: {}", registration_token.token);
383+
384+
Ok(ExitCode::SUCCESS)
385+
}
386+
333387
SC::ProvisionAllUsers => {
334388
let _span = info_span!("cli.manage.provision_all_users").entered();
335389
let database_config = DatabaseConfig::extract_or_default(figment)?;

crates/cli/src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ pub fn site_config_from_config(
211211
password_login_enabled: password_config.enabled(),
212212
password_registration_enabled: password_config.enabled()
213213
&& account_config.password_registration_enabled,
214+
registration_token_required: account_config.registration_token_required,
214215
email_change_allowed: account_config.email_change_allowed,
215216
displayname_change_allowed: account_config.displayname_change_allowed,
216217
password_change_allowed: password_config.enabled()

crates/config/src/sections/account.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ pub struct AccountConfig {
7272
/// This has no effect if password login is disabled.
7373
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
7474
pub login_with_email_allowed: bool,
75+
76+
/// Whether registration tokens are required for password registrations.
77+
/// Defaults to `false`.
78+
///
79+
/// When enabled, users must provide a valid registration token during
80+
/// password registration. This has no effect if password registration
81+
/// is disabled.
82+
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
83+
pub registration_token_required: bool,
7584
}
7685

7786
impl Default for AccountConfig {
@@ -84,6 +93,7 @@ impl Default for AccountConfig {
8493
password_recovery_enabled: default_false(),
8594
account_deactivation_allowed: default_true(),
8695
login_with_email_allowed: default_false(),
96+
registration_token_required: default_false(),
8797
}
8898
}
8999
}
@@ -98,6 +108,7 @@ impl AccountConfig {
98108
&& is_default_false(&self.password_recovery_enabled)
99109
&& is_default_true(&self.account_deactivation_allowed)
100110
&& is_default_false(&self.login_with_email_allowed)
111+
&& is_default_false(&self.registration_token_required)
101112
}
102113
}
103114

crates/data-model/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,6 @@ pub use self::{
5050
users::{
5151
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
5252
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
53-
UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
53+
UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken,
5454
},
5555
};

crates/data-model/src/site_config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ pub struct SiteConfig {
6464
/// Whether password registration is enabled.
6565
pub password_registration_enabled: bool,
6666

67+
/// Whether registration tokens are required for password registrations.
68+
pub registration_token_required: bool,
69+
6770
/// Whether users can change their email.
6871
pub email_change_allowed: bool,
6972

crates/data-model/src/users.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,60 @@ pub struct UserRegistrationPassword {
201201
pub version: u16,
202202
}
203203

204+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
205+
pub struct UserRegistrationToken {
206+
pub id: Ulid,
207+
pub token: String,
208+
pub usage_limit: Option<u32>,
209+
pub times_used: u32,
210+
pub created_at: DateTime<Utc>,
211+
pub last_used_at: Option<DateTime<Utc>>,
212+
pub expires_at: Option<DateTime<Utc>>,
213+
pub revoked_at: Option<DateTime<Utc>>,
214+
}
215+
216+
impl UserRegistrationToken {
217+
/// Returns `true` if the token is still valid and can be used
218+
#[must_use]
219+
pub fn is_valid(&self, now: DateTime<Utc>) -> bool {
220+
// Check if revoked
221+
if self.revoked_at.is_some() {
222+
return false;
223+
}
224+
225+
// Check if expired
226+
if let Some(expires_at) = self.expires_at {
227+
if now >= expires_at {
228+
return false;
229+
}
230+
}
231+
232+
// Check if usage limit exceeded
233+
if let Some(usage_limit) = self.usage_limit {
234+
if self.times_used >= usage_limit {
235+
return false;
236+
}
237+
}
238+
239+
true
240+
}
241+
242+
/// Returns `true` if the token can still be used (not expired and under
243+
/// usage limit)
244+
#[must_use]
245+
pub fn can_be_used(&self, now: DateTime<Utc>) -> bool {
246+
self.is_valid(now)
247+
}
248+
}
249+
204250
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
205251
pub struct UserRegistration {
206252
pub id: Ulid,
207253
pub username: String,
208254
pub display_name: Option<String>,
209255
pub terms_url: Option<Url>,
210256
pub email_authentication_id: Option<Ulid>,
257+
pub user_registration_token_id: Option<Ulid>,
211258
pub password: Option<UserRegistrationPassword>,
212259
pub post_auth_action: Option<serde_json::Value>,
213260
pub ip_address: Option<IpAddr>,

crates/handlers/src/admin/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi {
7373
description: Some("Manage browser sessions of users".to_owned()),
7474
..Tag::default()
7575
})
76+
.tag(Tag {
77+
name: "user-registration-token".to_owned(),
78+
description: Some("Manage user registration tokens".to_owned()),
79+
..Tag::default()
80+
})
7681
.tag(Tag {
7782
name: "upstream-oauth-link".to_owned(),
7883
description: Some(

crates/handlers/src/admin/model.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,83 @@ impl PolicyData {
602602
}]
603603
}
604604
}
605+
606+
/// A registration token
607+
#[derive(Serialize, JsonSchema)]
608+
pub struct UserRegistrationToken {
609+
#[serde(skip)]
610+
id: Ulid,
611+
612+
/// The token string
613+
token: String,
614+
615+
/// Maximum number of times this token can be used
616+
usage_limit: Option<u32>,
617+
618+
/// Number of times this token has been used
619+
times_used: u32,
620+
621+
/// When the token was created
622+
created_at: DateTime<Utc>,
623+
624+
/// When the token was last used. If null, the token has never been used.
625+
last_used_at: Option<DateTime<Utc>>,
626+
627+
/// When the token expires. If null, the token never expires.
628+
expires_at: Option<DateTime<Utc>>,
629+
630+
/// When the token was revoked. If null, the token is not revoked.
631+
revoked_at: Option<DateTime<Utc>>,
632+
}
633+
634+
impl From<mas_data_model::UserRegistrationToken> for UserRegistrationToken {
635+
fn from(token: mas_data_model::UserRegistrationToken) -> Self {
636+
Self {
637+
id: token.id,
638+
token: token.token,
639+
usage_limit: token.usage_limit,
640+
times_used: token.times_used,
641+
created_at: token.created_at,
642+
last_used_at: token.last_used_at,
643+
expires_at: token.expires_at,
644+
revoked_at: token.revoked_at,
645+
}
646+
}
647+
}
648+
649+
impl Resource for UserRegistrationToken {
650+
const KIND: &'static str = "user-registration_token";
651+
const PATH: &'static str = "/api/admin/v1/user-registration-tokens";
652+
653+
fn id(&self) -> Ulid {
654+
self.id
655+
}
656+
}
657+
658+
impl UserRegistrationToken {
659+
/// Samples of registration tokens
660+
pub fn samples() -> [Self; 2] {
661+
[
662+
Self {
663+
id: Ulid::from_bytes([0x01; 16]),
664+
token: "abc123def456".to_owned(),
665+
usage_limit: Some(10),
666+
times_used: 5,
667+
created_at: DateTime::default(),
668+
last_used_at: Some(DateTime::default()),
669+
expires_at: Some(DateTime::default() + chrono::Duration::days(30)),
670+
revoked_at: None,
671+
},
672+
Self {
673+
id: Ulid::from_bytes([0x02; 16]),
674+
token: "xyz789abc012".to_owned(),
675+
usage_limit: None,
676+
times_used: 0,
677+
created_at: DateTime::default(),
678+
last_used_at: None,
679+
expires_at: None,
680+
revoked_at: Some(DateTime::default()),
681+
},
682+
]
683+
}
684+
}

crates/handlers/src/admin/v1/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod oauth2_sessions;
2323
mod policy_data;
2424
mod upstream_oauth_links;
2525
mod user_emails;
26+
mod user_registration_tokens;
2627
mod user_sessions;
2728
mod users;
2829

@@ -119,6 +120,31 @@ where
119120
"/user-sessions/{id}",
120121
get_with(self::user_sessions::get, self::user_sessions::get_doc),
121122
)
123+
.api_route(
124+
"/user-registration-tokens",
125+
get_with(
126+
self::user_registration_tokens::list,
127+
self::user_registration_tokens::list_doc,
128+
)
129+
.post_with(
130+
self::user_registration_tokens::add,
131+
self::user_registration_tokens::add_doc,
132+
),
133+
)
134+
.api_route(
135+
"/user-registration-tokens/{id}",
136+
get_with(
137+
self::user_registration_tokens::get,
138+
self::user_registration_tokens::get_doc,
139+
),
140+
)
141+
.api_route(
142+
"/user-registration-tokens/{id}/revoke",
143+
post_with(
144+
self::user_registration_tokens::revoke,
145+
self::user_registration_tokens::revoke_doc,
146+
),
147+
)
122148
.api_route(
123149
"/upstream-oauth-links",
124150
get_with(

0 commit comments

Comments
 (0)