PasswordResetController.java
/*
* Copyright (C) 2025 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.controller;
import static org.tailormap.api.util.TMPasswordDeserializer.encoder;
import static org.tailormap.api.util.TMPasswordDeserializer.validatePasswordStrength;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.annotation.Counted;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import java.io.Serializable;
import java.lang.invoke.MethodHandles;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.LocaleResolver;
import org.tailormap.api.configuration.TailormapPasswordStrengthConfig;
import org.tailormap.api.persistence.TemporaryToken;
import org.tailormap.api.persistence.User;
import org.tailormap.api.repository.TemporaryTokenRepository;
import org.tailormap.api.repository.UserRepository;
import org.tailormap.api.service.PasswordResetEmailService;
import org.tailormap.api.viewer.model.ErrorResponse;
@RestController
@Validated
@ConditionalOnProperty(name = "tailormap-api.password-reset.enabled", havingValue = "true")
public class PasswordResetController {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final UserRepository userRepository;
private final TemporaryTokenRepository temporaryTokenRepository;
private final LocaleResolver localeResolver;
private final PasswordResetEmailService passwordResetEmailService;
@Value("${tailormap-api.password-reset.enabled:false}")
private boolean passwordResetEnabled;
@Value("${tailormap-api.password-reset.disabled-for}")
private Set<String> passwordResetDisabledFor;
@Value("${tailormap-api.password-reset.token-expiration-minutes:5}")
private int passwordResetTokenExpirationMinutes;
public PasswordResetController(
UserRepository userRepository,
TemporaryTokenRepository temporaryTokenRepository,
LocaleResolver localeResolver,
PasswordResetEmailService passwordResetEmailService) {
this.userRepository = userRepository;
this.temporaryTokenRepository = temporaryTokenRepository;
this.localeResolver = localeResolver;
this.passwordResetEmailService = passwordResetEmailService;
}
@ExceptionHandler({ConstraintViolationException.class})
public ResponseEntity<?> handleException(ConstraintViolationException e) {
// wrap the exception in a proper json response
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse().message(e.getMessage()).code(HttpStatus.BAD_REQUEST.value()));
}
/**
* Request a password reset email. The email will be sent asynchronously.
*
* @param email the email address to request a password reset for
* @return 202 ACCEPTED with a message that the request is being processed
* @throws ConstraintViolationException when the email is not valid
*/
@PostMapping(
path = "${tailormap-api.base-path}/password-reset",
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@Counted(value = "tailormap_api_password_reset_request", description = "number of password reset requests by email")
public ResponseEntity<Serializable> requestPasswordReset(
@RequestParam @Email String email, HttpServletRequest request) throws ConstraintViolationException {
if (passwordResetEnabled && !passwordResetDisabledFor.contains(email)) {
this.sendPasswordResetEmail(email, request);
}
return ResponseEntity.accepted()
.body(new ObjectMapper()
.createObjectNode()
.put("message", "Your password reset request is being processed"));
}
@PostMapping(
path = "${tailormap-api.base-path}/user/reset-password",
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@Counted(
value = "tailormap_api_password_reset_confirmation",
description = "number of submitted password reset confirmations")
@Transactional
public ResponseEntity<Serializable> confirmPasswordReset(
@NotNull UUID token, @NotNull String username, @NotNull String newPassword) throws ResponseStatusException {
final TemporaryToken temporaryToken = temporaryTokenRepository
.findById(token)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Token invalid"));
// check if password is valid
boolean validation = TailormapPasswordStrengthConfig.getValidation();
int minLength = TailormapPasswordStrengthConfig.getMinLength();
int minStrength = TailormapPasswordStrengthConfig.getMinStrength();
if (validation && !validatePasswordStrength(newPassword, minLength, minStrength)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password too short or too easily guessable");
}
// check if token is valid (not expired, correct type and username matches)
if (temporaryToken.getExpirationTime().isAfter(Instant.now().atZone(ZoneId.of("UTC")))
&& temporaryToken.getTokenType() == TemporaryToken.TokenType.PASSWORD_RESET
&& temporaryToken.getUsername().equals(username)) {
// only reset password and return an OK response if user exists, is enabled and account has not expired
// even here we don't want to reveal if the user exists or not
final User user = userRepository.findById(username).orElse(null);
if (user != null && user.isEnabledAndValidUntil()) {
userRepository.updatePassword(username, encoder().encode(newPassword));
logger.info("Password reset successful for user: {}", user.getUsername());
temporaryTokenRepository.delete(temporaryToken);
return ResponseEntity.status(HttpStatus.OK)
.body(new ObjectMapper()
.createObjectNode()
.put("message", "Your password reset was reset successful"));
}
}
// if we reach this, something was wrong with the token or the user
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Token expired or invalid request");
}
private void sendPasswordResetEmail(String email, HttpServletRequest request) {
String absoluteLinkPrefix =
request.getRequestURL().toString().replace(request.getRequestURI(), request.getContextPath());
Locale locale = localeResolver.resolveLocale(request);
// Delegate to async service — returns immediately
passwordResetEmailService.sendPasswordResetEmailAsync(
email, absoluteLinkPrefix, locale, passwordResetTokenExpirationMinutes);
}
}