Skip to content

Commit cab8ba2

Browse files
authored
Merge a401332 into 46eb75f
2 parents 46eb75f + a401332 commit cab8ba2

Some content is hidden

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

48 files changed

+4383
-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: 68 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,29 @@ 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 only once, unless the
111+
/// `--unlimited` flag is set.
112+
#[arg(long, group = "token-usage-limit")]
113+
usage_limit: Option<u32>,
114+
115+
/// Allow the token to be used an unlimited number of times.
116+
#[arg(long, action = ArgAction::SetTrue, group = "token-usage-limit")]
117+
unlimited: bool,
118+
119+
/// Time in seconds after which the token expires.
120+
/// If not provided, the token never expires.
121+
#[arg(long)]
122+
expires_in: Option<u32>,
123+
},
124+
98125
/// Trigger a provisioning job for all users
99126
ProvisionAllUsers,
100127

@@ -330,6 +357,46 @@ impl Options {
330357
Ok(ExitCode::SUCCESS)
331358
}
332359

360+
SC::IssueUserRegistrationToken {
361+
token,
362+
usage_limit,
363+
unlimited,
364+
expires_in,
365+
} => {
366+
let _span = info_span!("cli.manage.add_user_registration_token").entered();
367+
368+
let usage_limit = match (usage_limit, unlimited) {
369+
(Some(usage_limit), false) => Some(usage_limit),
370+
(None, false) => Some(1),
371+
(None, true) => None,
372+
(Some(_), true) => unreachable!(), // This should be handled by the clap group
373+
};
374+
375+
let database_config = DatabaseConfig::extract_or_default(figment)?;
376+
let mut conn = database_connection_from_config(&database_config).await?;
377+
let txn = conn.begin().await?;
378+
let mut repo = PgRepository::from_conn(txn);
379+
380+
// Calculate expiration time if provided
381+
let expires_at =
382+
expires_in.map(|seconds| clock.now() + Duration::seconds(seconds.into()));
383+
384+
// Generate a token if not provided
385+
let token_str = token.unwrap_or_else(|| Alphanumeric.sample_string(&mut rng, 12));
386+
387+
// Create the token
388+
let registration_token = repo
389+
.user_registration_token()
390+
.add(&mut rng, &clock, token_str, usage_limit, expires_at)
391+
.await?;
392+
393+
repo.into_inner().commit().await?;
394+
395+
info!(%registration_token.id, "Created user registration token: {}", registration_token.token);
396+
397+
Ok(ExitCode::SUCCESS)
398+
}
399+
333400
SC::ProvisionAllUsers => {
334401
let _span = info_span!("cli.manage.provision_all_users").entered();
335402
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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,89 @@ 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+
/// Whether the token is valid
616+
valid: bool,
617+
618+
/// Maximum number of times this token can be used
619+
usage_limit: Option<u32>,
620+
621+
/// Number of times this token has been used
622+
times_used: u32,
623+
624+
/// When the token was created
625+
created_at: DateTime<Utc>,
626+
627+
/// When the token was last used. If null, the token has never been used.
628+
last_used_at: Option<DateTime<Utc>>,
629+
630+
/// When the token expires. If null, the token never expires.
631+
expires_at: Option<DateTime<Utc>>,
632+
633+
/// When the token was revoked. If null, the token is not revoked.
634+
revoked_at: Option<DateTime<Utc>>,
635+
}
636+
637+
impl UserRegistrationToken {
638+
pub fn new(token: mas_data_model::UserRegistrationToken, now: DateTime<Utc>) -> Self {
639+
Self {
640+
id: token.id,
641+
valid: token.is_valid(now),
642+
token: token.token,
643+
usage_limit: token.usage_limit,
644+
times_used: token.times_used,
645+
created_at: token.created_at,
646+
last_used_at: token.last_used_at,
647+
expires_at: token.expires_at,
648+
revoked_at: token.revoked_at,
649+
}
650+
}
651+
}
652+
653+
impl Resource for UserRegistrationToken {
654+
const KIND: &'static str = "user-registration_token";
655+
const PATH: &'static str = "/api/admin/v1/user-registration-tokens";
656+
657+
fn id(&self) -> Ulid {
658+
self.id
659+
}
660+
}
661+
662+
impl UserRegistrationToken {
663+
/// Samples of registration tokens
664+
pub fn samples() -> [Self; 2] {
665+
[
666+
Self {
667+
id: Ulid::from_bytes([0x01; 16]),
668+
token: "abc123def456".to_owned(),
669+
valid: true,
670+
usage_limit: Some(10),
671+
times_used: 5,
672+
created_at: DateTime::default(),
673+
last_used_at: Some(DateTime::default()),
674+
expires_at: Some(DateTime::default() + chrono::Duration::days(30)),
675+
revoked_at: None,
676+
},
677+
Self {
678+
id: Ulid::from_bytes([0x02; 16]),
679+
token: "xyz789abc012".to_owned(),
680+
valid: false,
681+
usage_limit: None,
682+
times_used: 0,
683+
created_at: DateTime::default(),
684+
last_used_at: None,
685+
expires_at: None,
686+
revoked_at: Some(DateTime::default()),
687+
},
688+
]
689+
}
690+
}

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)