diff --git a/pom.xml b/pom.xml index 0469bd9..b14f49b 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,12 @@ 3.4.1 + + + org.springframework.boot + spring-boot-starter-mail + + org.springframework.boot diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index 10a67ef..c40cacb 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -48,6 +48,7 @@ public class SecurityConfig { "/vendor/**", "/tenant/**", "/onboarding", "/environments/**", "/license", "/admin/**").permitAll() .requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll() + .requestMatchers("/api/password-reset-notification").permitAll() .requestMatchers("/api/onboarding/**").authenticated() .requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin") .requestMatchers("/api/tenant/**").authenticated() diff --git a/src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java b/src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java new file mode 100644 index 0000000..14d2f79 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java @@ -0,0 +1,69 @@ +package net.siegeln.cameleer.saas.notification; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@RestController +@RequestMapping("/api/password-reset-notification") +public class PasswordResetNotificationController { + + private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationController.class); + + private static final long WINDOW_MS = 10 * 60 * 1000L; // 10 minutes + private static final int MAX_PER_WINDOW = 3; + + private final PasswordResetNotificationService notificationService; + + // email -> [windowStart, count] + private final ConcurrentHashMap rateLimitMap = new ConcurrentHashMap<>(); + + public PasswordResetNotificationController(PasswordResetNotificationService notificationService) { + this.notificationService = notificationService; + } + + public record NotificationRequest(String email) {} + + @PostMapping + public ResponseEntity> sendNotification(@RequestBody NotificationRequest request) { + String email = request.email(); + + if (email == null || !email.contains("@")) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid email address")); + } + + if (isRateLimited(email)) { + log.warn("Rate limit exceeded for password-reset notification to {}", email); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", "Too many requests — please wait before retrying")); + } + + // Fire-and-forget: send asynchronously to avoid blocking the sign-in flow + Thread.ofVirtual().start(() -> notificationService.sendNotification(email)); + + return ResponseEntity.ok(Map.of("sent", true)); + } + + private boolean isRateLimited(String email) { + long now = System.currentTimeMillis(); + var entry = rateLimitMap.compute(email, (key, existing) -> { + if (existing == null || now - existing[0] >= WINDOW_MS) { + // New window + return new long[]{now, 1}; + } + // Same window — increment + existing[1]++; + return existing; + }); + return entry[1] > MAX_PER_WINDOW; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java b/src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java new file mode 100644 index 0000000..d5bb499 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java @@ -0,0 +1,125 @@ +package net.siegeln.cameleer.saas.notification; + +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties; +import net.siegeln.cameleer.saas.vendor.EmailConnectorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +@Service +public class PasswordResetNotificationService { + + private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationService.class); + private static final DateTimeFormatter TIMESTAMP_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'"); + + private final EmailConnectorService emailConnectorService; + private final LogtoManagementClient logtoClient; + private final ProvisioningProperties provisioningProps; + + public PasswordResetNotificationService(EmailConnectorService emailConnectorService, + LogtoManagementClient logtoClient, + ProvisioningProperties provisioningProps) { + this.emailConnectorService = emailConnectorService; + this.logtoClient = logtoClient; + this.provisioningProps = provisioningProps; + } + + /** + * Sends a password-reset security notification to the given email address. + * Fire-and-forget: logs a warning on failure but does not throw. + */ + public void sendNotification(String toEmail) { + try { + doSend(toEmail); + } catch (Exception e) { + log.warn("Failed to send password-reset notification to {}: {}", toEmail, e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private void doSend(String toEmail) throws Exception { + // Read the full connector config from Logto (includes password) + var connectorStatus = emailConnectorService.getEmailConnector(); + if (connectorStatus == null) { + log.warn("No email connector configured — skipping password-reset notification for {}", toEmail); + return; + } + + // Re-read the raw connector config to get the password (getEmailConnector() omits it) + var connectors = logtoClient.listConnectors(); + var raw = connectors.stream() + .filter(c -> "Email".equals(c.get("type"))) + .findFirst() + .orElse(null); + if (raw == null) { + log.warn("Email connector not found in raw list — skipping notification for {}", toEmail); + return; + } + var config = (Map) raw.getOrDefault("config", Map.of()); + var auth = (Map) config.getOrDefault("auth", Map.of()); + + String host = connectorStatus.host(); + int port = connectorStatus.port(); + String username = connectorStatus.username(); + String fromEmail = connectorStatus.fromEmail(); + String password = String.valueOf(auth.getOrDefault("pass", "")); + + String htmlBody = buildHtmlBody(); + + // Build a programmatic JavaMailSender from the runtime SMTP config + var sender = new JavaMailSenderImpl(); + sender.setHost(host); + sender.setPort(port); + sender.setUsername(username); + sender.setPassword(password); + sender.setDefaultEncoding("UTF-8"); + + Properties props = sender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + if (port == 465) { + props.put("mail.smtp.ssl.enable", "true"); + } else { + props.put("mail.smtp.starttls.enable", "true"); + } + props.put("mail.smtp.timeout", "10000"); + props.put("mail.smtp.connectiontimeout", "10000"); + + var mimeMessage = sender.createMimeMessage(); + var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + helper.setTo(toEmail); + helper.setFrom(fromEmail); + helper.setSubject("Your Cameleer password was reset"); + helper.setText(htmlBody, true); + + sender.send(mimeMessage); + log.info("Password-reset notification sent to {}", toEmail); + } + + private String buildHtmlBody() throws IOException { + String content = new ClassPathResource("email-templates/password-reset-notification.html") + .getContentAsString(StandardCharsets.UTF_8); + + String watermarkUrl = provisioningProps.publicProtocol() + "://" + + provisioningProps.publicHost() + "/platform/assets/email-watermark.png"; + String timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(TIMESTAMP_FMT); + + return content + .replace("{{watermarkUrl}}", watermarkUrl) + .replace("{{timestamp}}", timestamp); + } +} diff --git a/src/main/resources/email-templates/password-reset-notification.html b/src/main/resources/email-templates/password-reset-notification.html new file mode 100644 index 0000000..280628e --- /dev/null +++ b/src/main/resources/email-templates/password-reset-notification.html @@ -0,0 +1,20 @@ +
+
+ Cameleer.io +
+
+ +
+

Your password was reset

+

Your Cameleer account password was successfully changed on {{timestamp}}.

+
+

Note: Multi-factor authentication (MFA) was not required for this password reset. We recommend enabling MFA to add an extra layer of security to your account.

+
+

If this wasn't you, contact your administrator immediately.

+
+
+
+

Questions? Contact your administrator

+

Cameleer — Apache Camel observability

+
+