password forgot hecho

This commit is contained in:
2025-10-04 16:51:22 +02:00
parent b66ceee85c
commit dbc2038f9f
14 changed files with 366 additions and 248 deletions

View File

@ -1,134 +1,92 @@
package com.imprimelibros.erp.auth;
import com.imprimelibros.erp.common.RateLimiterService;
import com.imprimelibros.erp.common.email.EmailService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.imprimelibros.erp.users.UserDao;
import java.util.Locale;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
@Controller
@RequestMapping("/password")
@RequestMapping("/auth/password")
@Validated
public class PasswordResetController {
private final PasswordResetService resetService;
private final EmailService emailService; // tu servicio de correo HTML
private final UserDao userRepo; // tu repo de usuarios
private final RateLimiterService rateLimiter;
private final PasswordResetService service;
private final MessageSource messageSource;
public PasswordResetController(PasswordResetService resetService,
EmailService emailService,
UserDao userRepo,
RateLimiterService rateLimiter,
MessageSource messageSource) {
this.resetService = resetService;
this.emailService = emailService;
this.userRepo = userRepo;
this.rateLimiter = rateLimiter;
public PasswordResetController(PasswordResetService service, MessageSource messageSource) {
this.service = service;
this.messageSource = messageSource;
}
/** Endpoint para solicitar el email de reseteo (respuesta neutra y rate limiting) */
@PostMapping("/request")
@ResponseBody
public ResponseEntity<?> requestReset(@RequestParam("email") String email, HttpServletRequest req, Locale locale) {
String clientIp = extractClientIp(req);
// RATE LIMIT: 5/15min por IP -> si se supera, 429 con mensaje neutro
if (!rateLimiter.tryConsume("reset:" + clientIp)) {
// No revelamos nada concreto
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("message",
messageSource.getMessage("auth.reset.request.success", null, locale)));
}
userRepo.findByUserNameIgnoreCase(email).ifPresent(user -> {
String token = resetService.createTokenForUser(
user.getId(),
1, // caduca en 1h
clientIp,
req.getHeader("User-Agent")
);
String resetLink = buildResetLink(req, token);
try {
emailService.sendPasswordResetMail(user.getUserName(), user.getFullName(), resetLink, locale);
} catch (Exception ignored) {
// Deliberadamente no variamos la respuesta para no filtrar info
}
});
// Respuesta neutra SIEMPRE en 200 (si no fue 429)
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("auth.reset.request.success", null, locale)));
// 4.1 Página "¿Olvidaste tu contraseña?"
@GetMapping("/forgot")
public String forgotForm(Model model, Locale locale) {
model.addAttribute("form", "_forgot-password");
return "imprimelibros/login/login";
}
/** Muestra formulario si el token es válido */
// 4.2 Envío del email (si existe)
@PostMapping("/forgot")
public String handleForgot(
@RequestParam @NotBlank @Email String username,
HttpServletRequest request,
Model model,
RedirectAttributes ra,
Locale locale) {
String baseUrl = request.getScheme() + "://" + request.getServerName()
+ (request.getServerPort() == 80 || request.getServerPort() == 443 ? ""
: ":" + request.getServerPort());
String ip = request.getRemoteAddr();
String ua = request.getHeader("User-Agent");
service.requestReset(username, baseUrl, ip, ua, 60, locale);
ra.addFlashAttribute("info", messageSource.getMessage("login.password-recovery.email-info", null, locale));
return "redirect:/login";
}
// 4.3 Formulario de nueva contraseña (a partir del enlace)
@GetMapping("/reset")
public String showResetForm(@RequestParam("token") String token, Model model) {
Long userId = resetService.validateTokenAndGetUserId(token);
if (userId == null) {
model.addAttribute("invalidToken", true);
return "auth/reset-invalid";
}
model.addAttribute("token", token);
return "auth/reset-form";
}
/** Procesa y guarda nueva contraseña */
@PostMapping("/reset")
public String handleReset(
public String resetForm(@RequestParam("uid") Long uid,
@RequestParam("token") String token,
@RequestParam("password") @NotBlank String password,
@RequestParam("confirmPassword") @NotBlank String confirmPassword,
Model model, Locale locale
) {
if (!password.equals(confirmPassword)) {
Model model, Locale locale) {
boolean ok = service.isValid(uid, token);
model.addAttribute("uid", uid);
model.addAttribute("token", token);
model.addAttribute("valid", ok);
model.addAttribute("form", "_reset-password");
return "imprimelibros/login/login";
}
// 4.4 Confirmación del reseteo
@PostMapping("/reset")
public String doReset(@RequestParam("uid") Long uid,
@RequestParam("token") String token,
@RequestParam("password") String password,
@RequestParam("password2") String password2,
Model model, Locale locale) {
if (!password.equals(password2) || password.length() < 8) {
model.addAttribute("uid", uid);
model.addAttribute("token", token);
model.addAttribute("error", messageSource.getMessage("auth.reset.form.passwordsMismatch", null, locale));
return "auth/reset-form";
model.addAttribute("danger", messageSource.getMessage("login.password-reset.error", null, locale));
model.addAttribute("form", "_reset-password");
return "imprimelibros/login/login";
}
try {
resetService.consumeTokenAndSetPassword(token, password);
return "redirect:/login?resetOk";
} catch (IllegalArgumentException ex) {
model.addAttribute("invalidToken", true);
return "auth/reset-invalid";
if (service.resetPassword(uid, token, password)) {
model.addAttribute("info", messageSource.getMessage("login.password-reset.success", null, locale));
} else {
model.addAttribute("danger", messageSource.getMessage("login.password-reset.error-link", null, locale));
}
}
private static String extractClientIp(HttpServletRequest req) {
// Soporte detrás de proxy
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
// coger el primer IP de la cadena
return xff.split(",")[0].trim();
}
String realIp = req.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) return realIp.trim();
return req.getRemoteAddr();
}
private static String buildResetLink(HttpServletRequest req, String token) {
String scheme = req.getHeader("X-Forwarded-Proto");
if (scheme == null || scheme.isBlank()) scheme = req.getScheme(); // http/https
String host = req.getHeader("Host"); // respeta el host público bajo proxy
if (host == null || host.isBlank()) host = req.getServerName() + (req.getServerPort() != 80 && req.getServerPort() != 443 ? ":" + req.getServerPort() : "");
String ctx = req.getContextPath() == null ? "" : req.getContextPath();
return scheme + "://" + host + ctx + "/password/reset?token=" + token;
model.addAttribute("form", "_login");
return "imprimelibros/login/login";
}
}