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:
6
pom.xml
6
pom.xml
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user