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
31 changes: 29 additions & 2 deletions crates/defguard_core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::{
events::ApiEvent,
handlers::{openid_flow::OidcFlowError, user::ValidationError},
location_management::LocationManagementError,
mail::templates::TemplateError,
mail::{MailError, templates::TemplateError},
user_management::UserManagementError,
};

Expand All @@ -36,6 +36,10 @@ pub enum WebError {
WebauthnRegistration(String),
#[error("Email error: {0}")]
Email(String),
#[error("SMTP is not configured")]
SmtpNotConfigured,
#[error("Failed to send verification email")]
MailSendFailed,
#[error("Object not found: {0}")]
ObjectNotFound(String),
#[error("Object already exists: {0}")]
Expand Down Expand Up @@ -68,7 +72,7 @@ pub enum WebError {
BadRequest(String),
#[error(transparent)]
#[schema(value_type=Object)]
TemplateError(#[from] TemplateError),
TemplateError(TemplateError),
#[error("License error: {0}")]
#[schema(value_type=Object)]
LicenseError(#[from] LicenseError),
Expand Down Expand Up @@ -140,6 +144,29 @@ impl From<ModelError> for WebError {
}
}

impl From<TemplateError> for WebError {
fn from(err: TemplateError) -> Self {
match err {
TemplateError::Mail(mail_err) => mail_err.into(),
other => Self::TemplateError(other),
}
}
}

impl From<MailError> for WebError {
fn from(err: MailError) -> Self {
match err {
MailError::SmtpNotConfigured => Self::SmtpNotConfigured,
MailError::Sqlx(err) => Self::DbError(err.to_string()),
MailError::Lettre(_)
| MailError::Address(_)
| MailError::Smtp(_)
| MailError::InvalidPort(_)
| MailError::OAuth2(_) => Self::MailSendFailed,
}
}
}

impl From<DeviceError> for WebError {
fn from(error: DeviceError) -> Self {
match error {
Expand Down
2 changes: 1 addition & 1 deletion crates/defguard_core/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ pub async fn email_mfa_init(session: SessionInfo, State(appstate): State<AppStat
let settings = Settings::get_current_settings();
if !settings.smtp_configured() {
error!("Unable to start email MFA configuration. SMTP is not configured.");
return Err(WebError::Email("SMTP not configured".into()));
return Err(WebError::SmtpNotConfigured);
}

// generate TOTP secret
Expand Down
10 changes: 10 additions & 0 deletions crates/defguard_core/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ pub enum WebErrorCode {
CertExpired,
CertNotYetValid,
CertParseError,
SmtpNotConfigured,
MailSendFailed,
}

pub static SESSION_COOKIE_NAME: &str = "defguard_session";
Expand Down Expand Up @@ -334,6 +336,14 @@ impl From<WebError> for ApiResponse {
StatusCode::BAD_REQUEST,
)
}
WebError::SmtpNotConfigured => Self::new(
json!({"msg": web_error.to_string(), "code": WebErrorCode::SmtpNotConfigured}),
StatusCode::SERVICE_UNAVAILABLE,
),
WebError::MailSendFailed => Self::new(
json!({"msg": web_error.to_string(), "code": WebErrorCode::MailSendFailed}),
StatusCode::SERVICE_UNAVAILABLE,
),
WebError::TemplateError(err) => {
error!("Template error: {err}");
Self::new(
Expand Down
11 changes: 8 additions & 3 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, MailMessage};
use super::{Attachment, MailError, MailMessage};

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

Expand All @@ -39,6 +39,8 @@ pub enum TemplateError {
MrmlParserError(#[from] mrml::prelude::parser::Error),
#[error(transparent)]
MrmlRenderError(#[from] mrml::prelude::render::Error),
#[error(transparent)]
Mail(#[from] MailError),
}

struct NoOp(&'static str);
Expand Down Expand Up @@ -388,6 +390,9 @@ pub async fn new_device_added_mail(
Ok(())
}

/// Intentionally fire-and-forget: MFA is already durably enabled by the time this
/// runs, so a failed confirmation email is a low-stakes notification, unlike
/// `mfa_activation_mail`/`mfa_code_mail` which the user is actively blocked on.
pub async fn mfa_configured_mail(
to: &str,
conn: &mut PgConnection,
Expand Down Expand Up @@ -535,7 +540,7 @@ pub async fn mfa_activation_mail(

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

Ok(())
}
Expand All @@ -562,7 +567,7 @@ pub async fn mfa_code_mail(

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

Ok(())
}
Expand Down
12 changes: 10 additions & 2 deletions crates/defguard_gateway_manager/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ impl GatewayHandler {
}

debug!("Sending Gateway disconnect email notification");
let name = self.gateway.name.clone();
let name = match Gateway::find_by_id(&self.pool, self.gateway.id).await {
Ok(Some(gateway)) => gateway.name,
_ => self.gateway.name.clone(),
};
let pool = self.pool.clone();
let url = format!("{}:{}", self.gateway.address, self.gateway.port);

Expand Down Expand Up @@ -316,11 +319,16 @@ impl GatewayHandler {
}

debug!("Sending Gateway reconnect email notification");
let gateway_name = self.gateway.name.clone();
let gateway_id = self.gateway.id;
let fallback_name = self.gateway.name.clone();
let pool = self.pool.clone();
let url = format!("{}:{}", self.gateway.address, self.gateway.port);

tokio::spawn(async move {
let gateway_name = match Gateway::find_by_id(&pool, gateway_id).await {
Ok(Some(gateway)) => gateway.name,
_ => fallback_name,
};
if let Err(err) =
send_gateway_reconnected_email(gateway_name, network_name, &url, &pool).await
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ use defguard_proto::{
};
use ipnetwork::IpNetwork;
use sqlx::PgPool;
use tokio::{sync::mpsc::UnboundedReceiver, time::timeout};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::TcpListener,
sync::mpsc::UnboundedReceiver,
time::timeout,
};
use tonic::Code;

use crate::tests::common::{HandlerTestContext, MockOidcProvider, RECEIVE_TIMEOUT};
Expand Down Expand Up @@ -483,6 +488,7 @@ pub(crate) async fn create_external_mfa_network(pool: &PgPool) -> WireguardNetwo
/// The code is valid immediately and can be passed directly to
/// `ClientMfaFinishRequest::code`.
pub(crate) async fn setup_user_email_mfa(pool: &PgPool, user: &mut User<Id>) -> String {
configure_working_smtp(pool).await;
user.new_email_secret(pool).await.expect("new_email_secret");
user.enable_email_mfa(pool).await.expect("enable_email_mfa");
// generate_email_mfa_code uses the in-memory secret; note that
Expand Down Expand Up @@ -939,6 +945,83 @@ pub(crate) fn configure_smtp(settings: &mut Settings) {
settings.smtp.sender = Some("noreply@example.com".into());
}

/// Spawn a minimal in-process SMTP server that accepts any message and replies
/// with success codes, then point `pool`'s current settings at it.
///
/// MFA emails (`mfa_code_mail`/`mfa_activation_mail`) are actually sent
/// (awaited) rather than fired-and-forgotten, so tests exercising the email
/// MFA method need a real, reachable SMTP endpoint or the send fails with
/// `SmtpNotConfigured`/`MailSendFailed`.
pub(crate) async fn configure_working_smtp(pool: &PgPool) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind fake SMTP listener");
let addr = listener.local_addr().expect("failed to get local addr");

tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let _ = writer.write_all(b"220 localhost ESMTP\r\n").await;
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => return,
Ok(_) => {}
Err(_) => return,
}
let upper = line.trim_end().to_ascii_uppercase();
if upper.starts_with("EHLO") || upper.starts_with("HELO") {
let _ = writer.write_all(b"250 localhost\r\n").await;
} else if upper.starts_with("MAIL FROM")
|| upper.starts_with("RCPT TO")
|| upper.starts_with("RSET")
{
let _ = writer.write_all(b"250 OK\r\n").await;
} else if upper.starts_with("DATA") {
let _ = writer
.write_all(b"354 End data with <CR><LF>.<CR><LF>\r\n")
.await;
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => return,
Ok(_) => {}
Err(_) => return,
}
if line == ".\r\n" || line == ".\n" {
break;
}
}
let _ = writer.write_all(b"250 OK message queued\r\n").await;
} else if upper.starts_with("QUIT") {
let _ = writer.write_all(b"221 Bye\r\n").await;
return;
} else {
let _ = writer.write_all(b"250 OK\r\n").await;
}
}
});
}
});

let mut settings = Settings::get_current_settings();
settings.smtp.server = Some(addr.ip().to_string());
settings.smtp.port = Some(i32::from(addr.port()));
settings.smtp.sender = Some("noreply@example.com".into());
settings.smtp.encryption = defguard_common::db::models::settings::smtp::SmtpEncryption::None;
settings.smtp.authentication =
defguard_common::db::models::settings::smtp::SmtpAuthentication::None;
update_current_settings(pool, settings)
.await
.expect("failed to persist fake SMTP settings");
}

/// Set minimal LDAP fields on a [`Settings`] so that `ldap_configured()` returns `true`.
pub(crate) fn configure_ldap(settings: &mut Settings) {
settings.ldap_url = Some("ldap://localhost".into());
Expand Down
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ fix-clippy:
-W clippy::str_to_string \
-W clippy::explicit_iter_loop

# run all tests with cargo nextest (needs a running Postgres for DATABASE_URL)
test *ARGS:
cargo nextest run --locked --all-features {{ARGS}}

# run LDAP integration tests against a throwaway OpenLDAP container (needs a running Postgres for DATABASE_URL, like other rust tests)
test-ldap *ARGS:
#!/usr/bin/env bash
Expand Down
2 changes: 1 addition & 1 deletion web/biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.2/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
Expand Down
4 changes: 3 additions & 1 deletion web/messages/en/api-error.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
"api_error_cert_invalid_validity_period": "The certificate's validity period is invalid",
"api_error_cert_expired": "The uploaded certificate has expired",
"api_error_cert_not_yet_valid": "The uploaded certificate is not valid yet",
"api_error_cert_parse_error": "The certificate could not be parsed"
"api_error_cert_parse_error": "The certificate could not be parsed",
"api_error_smtp_not_configured": "Email server is not configured. Please contact your administrator.",
"api_error_mail_send_failed": "We couldn't send the verification email. Please try again or contact your administrator."
}
2 changes: 2 additions & 0 deletions web/messages/en/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@
"modal_mfa_enable_email_verification": "Email verification",
"modal_mfa_enable_email_content": "To setup your MFA enter the code that was sent to your account email: {email}",
"modal_mfa_enable_email_resend": "Resend email",
"modal_mfa_enable_email_resend_countdown": "Resend email ({seconds}s)",
"modal_mfa_enable_totp_title": "Authentication App Setup",
"modal_mfa_enable_totp_step_1_title": "Scan QR code",
"modal_mfa_enable_totp_step_1_content": "To set up Multi-Factor Authentication (MFA), simply scan this QR code using an authenticator app (like Google Authenticator, Microsoft Authenticator, or any other you prefer).",
"modal_mfa_enable_totp_qr_problem": "Can't scan QR code? Enter code manually in the app.",
"modal_mfa_enable_totp_step_2_title": "Enter 6-digit code from authentication app",
"modal_mfa_enable_totp_success": "Authentication app MFA enabled",
"modal_mfa_add_passkey_title": "Add passkey",
"modal_mfa_add_passkey_content": "Passkeys can be used as your second authentication factor instead of a verification code. Learn how to set them up in the documentation.",
"modal_mfa_add_passkey_label": "Passkey name",
Expand Down
Loading
Loading