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