feat: add password reset security notification email endpoint

Adds POST /api/password-reset-notification (public, rate-limited 3/10min)
that sends a branded HTML security notification email via the runtime-
configured Logto SMTP connector. Uses spring-boot-starter-mail with a
programmatic JavaMailSender built from the connector's live credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 13:59:23 +02:00
parent ffb65edcec
commit a5b30cd1ea
5 changed files with 221 additions and 0 deletions

View File

@@ -100,6 +100,12 @@
<version>3.4.1</version>
</dependency>
<!-- Mail (for password-reset security notification) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -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()

View File

@@ -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<String, long[]> rateLimitMap = new ConcurrentHashMap<>();
public PasswordResetNotificationController(PasswordResetNotificationService notificationService) {
this.notificationService = notificationService;
}
public record NotificationRequest(String email) {}
@PostMapping
public ResponseEntity<Map<String, Object>> 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;
}
}

View File

@@ -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<String, Object>) raw.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) 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);
}
}

View File

@@ -0,0 +1,20 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Your password was reset</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your Cameleer account password was successfully changed on {{timestamp}}.</p>
<div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
<p style="color:#444;font-size:13px;line-height:1.5;margin:0;"><strong>Note:</strong> 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.</p>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">If this wasn't you, contact your administrator immediately.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>