Skip to content
Draft
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
129 changes: 125 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,44 @@ pub async fn user_import_blocked_mail(
Ok(())
}

/// Placeholders substituted into the admin-configurable `token_info` mail text with the
/// configured enrollment timeouts. The migration seeding these placeholders into `mail_context`
/// uses the same literals, since SQL cannot share Rust constants.
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 +196,60 @@ 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
// via plain substitution, so the email reflects the configured values instead of the
// hardcoded defaults. See https://github.com/DefGuard/defguard/issues/3518.
//
// We deliberately do NOT render the DB text as a Tera template: that would evaluate
// admin-controlled content (see `dg25_8_server_side_template_injection`) and would fail the
// whole mail on stray `{{`/`{%` in customized 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 +768,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
Loading
Loading