Skip to content
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

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

9 changes: 9 additions & 0 deletions crates/defguard_core/src/db/models/enrollment.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::time::Duration;

use chrono::{NaiveDateTime, TimeDelta, Utc};
use defguard_common::{
VERSION,
Expand Down Expand Up @@ -111,6 +113,13 @@ impl Token {
}
}

/// Duration for which the token is valid, i.e. `expires_at - created_at`, clamped to zero.
#[must_use]
pub fn validity_duration(&self) -> Duration {
let seconds = (self.expires_at - self.created_at).num_seconds().max(0);
Duration::from_secs(u64::try_from(seconds).unwrap_or_default())
}

pub async fn save<'e, E>(&self, executor: E) -> Result<(), TokenError>
where
E: PgExecutor<'e>,
Expand Down
2 changes: 2 additions & 0 deletions crates/defguard_core/src/enrollment_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub async fn start_user_enrollment(
base_message_context,
enrollment_service_url,
&enrollment.id,
enrollment.validity_duration(),
)
.await;
match result {
Expand Down Expand Up @@ -200,6 +201,7 @@ pub async fn send_enrollment_invitation(
base_message_context,
enrollment_service_url,
token_id,
token.validity_duration(),
)
.await
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ pub(crate) async fn modify_openid_provider(
provider.directory_sync_group_match = group_match;
provider.jumpcloud_api_key = provider_data.jumpcloud_api_key;
provider.prefetch_users = provider_data.prefetch_users;
provider.disable_password_management = provider_data.disable_password_management;
provider.directory_sync_user_groups = user_groups;
provider.save(&mut *transaction).await?;
transaction.commit().await?;
Expand Down
7 changes: 7 additions & 0 deletions crates/defguard_core/src/mail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ impl Mail {
&self.subject
}

/// Getter for the plain text body. Used by tests to assert rendered content.
#[cfg(test)]
#[must_use]
pub(crate) fn text(&self) -> &str {
&self.text
}

/// Add to context.
pub fn add_to_context<K, V>(&mut self, key: K, value: &V)
where
Expand Down
122 changes: 118 additions & 4 deletions crates/defguard_core/src/mail/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use tera::{Context, Function, Tera};
use thiserror::Error;
use tracing::{debug, warn};

use super::{Attachment, MailError, MailMessage};
use super::{Attachment, Mail, MailError, MailMessage};

pub(crate) const DEFAULT_LANG: &str = "en_US";

Expand Down Expand Up @@ -139,14 +139,43 @@ pub async fn user_import_blocked_mail(
Ok(())
}

/// Placeholders substituted into the admin-configurable `token_info` mail text with the
/// configured enrollment timeouts.
const TOKEN_TIMEOUT_PLACEHOLDER: &str = "{{ token_timeout }}";
const SESSION_TIMEOUT_PLACEHOLDER: &str = "{{ session_timeout }}";

// Mail with link to enrollment service.
pub async fn new_account_mail(
to: &str,
conn: &mut PgConnection,
context: Context,
mut enrollment_service_url: Url,
enrollment_service_url: Url,
enrollment_token: &str,
token_timeout: Duration,
) -> Result<(), TemplateError> {
build_new_account_mail(
to,
conn,
context,
enrollment_service_url,
enrollment_token,
token_timeout,
)
.await?
.send_and_forget();
Ok(())
}

/// Build (but do not send) the enrollment start mail. Extracted so tests can assert the
/// rendered content without requiring an SMTP server.
pub(crate) async fn build_new_account_mail(
to: &str,
conn: &mut PgConnection,
context: Context,
mut enrollment_service_url: Url,
enrollment_token: &str,
token_timeout: Duration,
) -> Result<Mail, TemplateError> {
debug!("Render an enrollment start mail template for the user.");
let (mut tera, mut context) = get_base_tera_mjml(context, None, None, None)?;

Expand All @@ -166,9 +195,54 @@ pub async fn new_account_mail(

let message = MailMessage::NewAccount;
message.fill_context(conn, &mut context).await?;
message.mail(&mut tera, &context, to)?.send_and_forget();

Ok(())
// The token timeout is per-enrollment (passed in), while the session timeout is a global
// setting read here so the email reflects the configured value.
let session_timeout = Settings::get_current_settings().enrollment_session_timeout();

// Render the effective token/session timeouts into the admin-configurable `token_info` text.
if let Some(Value::String(token_info)) = context.get("token_info").cloned() {
if !token_info.contains(TOKEN_TIMEOUT_PLACEHOLDER)
|| !token_info.contains(SESSION_TIMEOUT_PLACEHOLDER)
{
warn!(
"mail_context 'new-account' token_info is missing the timeout placeholders; \
the configured enrollment timeouts will not be shown"
);
}
let rendered = token_info
.replace(TOKEN_TIMEOUT_PLACEHOLDER, &format_timeout(token_timeout))
.replace(
SESSION_TIMEOUT_PLACEHOLDER,
&format_timeout(session_timeout),
);
context.insert("token_info", &rendered);
}

message.mail(&mut tera, &context, to)
}

/// Format a timeout duration as a short human-readable string, e.g. "1 week", "1 day",
/// "24 hours", or "30 minutes".
fn format_timeout(duration: Duration) -> String {
let secs = duration.as_secs();
let minute = 60;
let hour = 60 * minute;
let day = 24 * hour;
let week = 7 * day;

let (value, unit) = if secs >= week && secs.is_multiple_of(week) {
(secs / week, "week")
} else if secs >= day && secs.is_multiple_of(day) {
(secs / day, "day")
} else if secs >= hour && secs.is_multiple_of(hour) {
(secs / hour, "hour")
} else if secs >= minute && secs.is_multiple_of(minute) {
(secs / minute, "minute")
} else {
(secs, "second")
};
format!("{value} {unit}{}", if value == 1 { "" } else { "s" })
}

// Mail with link to enrollment service.
Expand Down Expand Up @@ -687,3 +761,43 @@ pub async fn certificate_expired_mail(

Ok(())
}

#[cfg(test)]
mod tests {
use std::time::Duration;

use super::format_timeout;

#[test]
fn test_formats_weeks() {
assert_eq!(format_timeout(Duration::from_secs(7 * 24 * 3600)), "1 week");
assert_eq!(
format_timeout(Duration::from_secs(14 * 24 * 3600)),
"2 weeks"
);
}

#[test]
fn test_formats_days() {
assert_eq!(format_timeout(Duration::from_secs(24 * 3600)), "1 day");
assert_eq!(format_timeout(Duration::from_secs(2 * 24 * 3600)), "2 days");
}

#[test]
fn test_formats_hours() {
assert_eq!(format_timeout(Duration::from_secs(3600)), "1 hour");
assert_eq!(format_timeout(Duration::from_secs(23 * 3600)), "23 hours");
}

#[test]
fn test_formats_minutes() {
assert_eq!(format_timeout(Duration::from_secs(60)), "1 minute");
assert_eq!(format_timeout(Duration::from_secs(30 * 60)), "30 minutes");
}

#[test]
fn test_formats_seconds() {
assert_eq!(format_timeout(Duration::from_secs(1)), "1 second");
assert_eq!(format_timeout(Duration::from_secs(90)), "90 seconds");
}
}
61 changes: 61 additions & 0 deletions crates/defguard_core/src/mail/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,66 @@ fn dg25_8_server_side_template_injection() {
assert!(tera.render("text", &Context::new()).is_err());
}

/// Override the enrollment token/session timeouts and reload the global settings.
async fn set_enrollment_timeouts(pool: &PgPool, token_hours: i32, session_minutes: i32) {
sqlx::query!(
"UPDATE settings \
SET enrollment_token_timeout_hours = $1, \
enrollment_session_timeout_minutes = $2",
token_hours,
session_minutes,
)
.execute(pool)
.await
.unwrap();

initialize_current_settings(pool).await.unwrap();
}

/// Regression test for https://github.com/DefGuard/defguard/issues/3518
///
/// The enrollment email must reflect the configured enrollment token and session timeouts
/// instead of the hardcoded defaults ("24 hours" / "10 minutes").
#[sqlx::test]
async fn test_enrollment_email_reflects_configured_timeouts(
_: PgPoolOptions,
options: PgConnectOptions,
) {
let pool = setup_pool(options).await;
initialize_current_settings(&pool).await.unwrap();

// Configure non-default timeouts: token valid for 1 week, session for 30 minutes.
// `build_new_account_mail` reads the session timeout from the global settings set here,
// while the token timeout is per-enrollment and passed explicitly.
set_enrollment_timeouts(&pool, 168, 30).await;

let mut conn = pool.begin().await.unwrap();
let url = Url::parse("http://localhost:8001").unwrap();
let context = Context::new();
let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc";

let mail = templates::build_new_account_mail(
"user@example.com",
&mut conn,
context,
url,
token,
Duration::from_secs(168 * 3600),
)
.await
.unwrap();

let text = mail.text();
assert!(
text.contains("1 week"),
"enrollment email should show the configured token timeout, got: {text}"
);
assert!(
text.contains("30 minutes"),
"enrollment email should show the configured session timeout, got: {text}"
);
}

/// Delay, so send_and_forget() can process the message.
async fn delay() {
sleep(Duration::from_secs(2)).await;
Expand Down Expand Up @@ -163,6 +223,7 @@ fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) {
context,
url,
token,
Duration::from_secs(24 * 3600),
)
.await
.unwrap();
Expand Down
76 changes: 76 additions & 0 deletions crates/defguard_core/tests/integration/api/openid_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ struct UrlResponse {
url: String,
}

#[derive(Deserialize)]
struct CurrentProviderResponse {
provider: CurrentProvider,
}

#[derive(Deserialize)]
struct CurrentProvider {
disable_password_management: bool,
}

#[sqlx::test]
async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) {
let pool = setup_pool(options).await;
Expand Down Expand Up @@ -110,6 +120,72 @@ async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[sqlx::test]
async fn test_modify_openid_provider_persists_disable_password_management(
_: PgPoolOptions,
options: PgConnectOptions,
) {
let pool = setup_pool(options).await;
let client = make_client(pool).await;

let auth = Auth::new("admin", "pass123");
let response = client.post("/api/v1/auth").json(&auth).send().await;
assert_eq!(response.status(), StatusCode::OK);

exceed_enterprise_limits(&client).await;

let mut provider_data = AddProviderData {
name: "test".to_owned(),
base_url: "https://accounts.google.com".to_owned(),
kind: OpenIdProviderKind::Google,
client_id: "client_id".to_owned(),
client_secret: "client_secret".to_owned(),
display_name: Some("display_name".to_owned()),
admin_email: None,
google_service_account_email: None,
google_service_account_key: None,
directory_sync_enabled: false,
directory_sync_interval: 100,
directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(),
directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(),
directory_sync_target: DirectorySyncTarget::All.to_string(),
create_account: false,
okta_dirsync_client_id: None,
okta_private_jwk: None,
directory_sync_group_match: None,
username_handling: OpenIdUsernameHandling::PruneEmailDomain,
jumpcloud_api_key: None,
prefetch_users: false,
disable_password_management: false,
directory_sync_user_groups: None,
};

let response = client
.post("/api/v1/openid/provider")
.json(&provider_data)
.send()
.await;
assert_eq!(response.status(), StatusCode::CREATED);

// Toggle the flag and update the provider via PUT.
provider_data.disable_password_management = true;
let response = client
.put("/api/v1/openid/provider/test")
.json(&provider_data)
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);

// Read back the current provider and assert the flag was persisted.
let response = client.get("/api/v1/openid/provider/current").send().await;
assert_eq!(response.status(), StatusCode::OK);
let body: CurrentProviderResponse = response.json().await;
assert!(
body.provider.disable_password_management,
"disable_password_management should be persisted as true after update"
);
}

// FIXME: this test sometimes fails because of test_openid_providers.
// The license state is possibly preserved between those two. This requires further research.
#[sqlx::test]
Expand Down
Loading
Loading