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";
}
}

View File

@ -1,86 +1,124 @@
package com.imprimelibros.erp.auth;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.imprimelibros.erp.users.UserDao;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import com.imprimelibros.erp.common.email.EmailService;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDao; // ajusta al path real de tu UserRepository
@Service
public class PasswordResetService {
private final PasswordResetTokenRepository tokenRepo;
private final UserDao userRepo; // tu repo real
private final UserDao userRepo;
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
private final MessageSource messages;
private final EmailService emailService;
private static final SecureRandom RNG = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
public PasswordResetService(PasswordResetTokenRepository tokenRepo, UserDao userRepo, PasswordEncoder enc) {
public PasswordResetService(
PasswordResetTokenRepository tokenRepo,
UserDao userRepo,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender,
SpringTemplateEngine templateEngine,
MessageSource messages,
EmailService emailService
) {
this.tokenRepo = tokenRepo;
this.userRepo = userRepo;
this.passwordEncoder = enc;
this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
this.templateEngine = templateEngine;
this.messages = messages;
this.emailService = emailService;
}
/** Elimina tokens previos sin usar, genera uno nuevo y lo guarda con auditoría básica. */
public String createTokenForUser(Long userId, int hoursToExpire, String requestIp, String userAgent) {
// Invalidar anteriores
tokenRepo.deleteAllByUserIdAndUsedAtIsNull(userId);
// 3.1 Solicitar reset (si el email existe, genera token y envía)
@Transactional
public void requestReset(String email, String baseUrl, String ip, String userAgent, int minutes, Locale locale) {
User user = userRepo.findByUserNameIgnoreCase(email).orElse(null);
// Siempre responder OK aunque no exista para evitar enumeración
if (user == null) return;
// Generar token
byte[] raw = new byte[32];
RNG.nextBytes(raw);
String token = HEX.formatHex(raw); // token plano (64 hex)
String tokenHash = sha256(token);
tokenRepo.invalidateActiveTokens(user.getId(), LocalDateTime.now());
PasswordResetToken prt = new PasswordResetToken();
prt.setUserId(userId);
prt.setTokenHash(tokenHash);
prt.setExpiresAt(Instant.now().plus(hoursToExpire, ChronoUnit.HOURS));
prt.setRequestIp(truncate(requestIp, 64));
prt.setUserAgent(truncate(userAgent, 255));
tokenRepo.save(prt);
String token = generateToken(); // token en claro SOLO para el enlace
String tokenHash = sha256(token); // guardamos hash en DB
return token; // Esto se envía por email
PasswordResetToken row = new PasswordResetToken();
row.setUserId(user.getId());
row.setCreatedAt(LocalDateTime.now());
row.setExpiresAt(LocalDateTime.now().plusMinutes(minutes));
row.setRequestIp(ip);
row.setUserAgent(userAgent);
row.setTokenHash(tokenHash);
tokenRepo.save(row);
String resetUrl = baseUrl + "/auth/password/reset?uid=" + user.getId() + "&token=" + token;
emailService.sendPasswordResetMail(user.getUserName(), user.getFullName(), resetUrl, locale);
}
public Long validateTokenAndGetUserId(String tokenPlain) {
return tokenRepo.findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(sha256(tokenPlain), Instant.now())
.map(PasswordResetToken::getUserId)
.orElse(null);
// 3.2 Validar token (para mostrar el formulario de nueva contraseña)
public boolean isValid(Long userId, String token) {
String hash = sha256(token);
return tokenRepo.findValidByUserAndHash(userId, hash, LocalDateTime.now()).isPresent();
}
public void consumeTokenAndSetPassword(String tokenPlain, String newPassword) {
var tokenOpt = tokenRepo.findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(sha256(tokenPlain), Instant.now());
var prt = tokenOpt.orElseThrow(() -> new IllegalArgumentException("Token inválido o caducado"));
// 3.3 Confirmar reseteo y marcar token como usado
@Transactional
public boolean resetPassword(Long userId, String token, String newPassword) {
String hash = sha256(token);
var opt = tokenRepo.findValidByUserAndHash(userId, hash, LocalDateTime.now());
if (opt.isEmpty()) return false;
var user = userRepo.findById(prt.getUserId())
.orElseThrow(() -> new IllegalStateException("Usuario no encontrado"));
var entry = opt.get();
var user = userRepo.findById(userId).orElse(null);
if (user == null) return false;
user.setPassword(passwordEncoder.encode(newPassword));
userRepo.save(user);
prt.setUsedAt(Instant.now());
tokenRepo.save(prt);
entry.setUsedAt(LocalDateTime.now());
tokenRepo.save(entry);
// (Opcional) invalidar otros tokens activos del usuario
tokenRepo.invalidateActiveTokens(userId, LocalDateTime.now());
return true;
}
private static String sha256(String s) {
// --- helpers ---
private String generateToken() {
byte[] buf = new byte[32];
new SecureRandom().nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
private String sha256(String s) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HEX.formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8)));
var md = MessageDigest.getInstance("SHA-256");
return Base64.getEncoder().encodeToString(md.digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static String truncate(String v, int max) {
if (v == null) return null;
return v.length() <= max ? v : v.substring(0, max);
}
}

View File

@ -1,38 +1,36 @@
package com.imprimelibros.erp.auth;
import java.time.LocalDateTime;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "password_reset_tokens", indexes = {
@Index(name = "idx_prt_token_hash", columnList = "tokenHash"),
@Index(name = "idx_prt_user_id", columnList = "userId")
})
@Table(name = "password_reset_tokens")
public class PasswordResetToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) private Long userId;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false, length = 128)
private String tokenHash; // SHA-256 hex
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private Instant createdAt = Instant.now();
private Instant usedAt;
// Auditoría ligera (GDPR: documéntalo y limita retención)
@Column(length = 64)
@Column(name = "request_ip", length = 64)
private String requestIp;
@Column(length = 255)
@Column(name = "token_hash", length = 128, nullable = false)
private String tokenHash;
@Column(name = "used_at")
private LocalDateTime usedAt;
@Column(name = "user_agent", length = 255)
private String userAgent;
@Column(name = "user_id", nullable = false)
private Long userId;
public Long getId() {
return id;
}
@ -41,44 +39,20 @@ public class PasswordResetToken {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public Instant getCreatedAt() {
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Instant getUsedAt() {
return usedAt;
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setUsedAt(Instant usedAt) {
this.usedAt = usedAt;
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public String getRequestIp() {
@ -89,6 +63,22 @@ public class PasswordResetToken {
this.requestIp = requestIp;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public LocalDateTime getUsedAt() {
return usedAt;
}
public void setUsedAt(LocalDateTime usedAt) {
this.usedAt = usedAt;
}
public String getUserAgent() {
return userAgent;
}
@ -97,6 +87,13 @@ public class PasswordResetToken {
this.userAgent = userAgent;
}
// getters/setters
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
}
}

View File

@ -1,13 +1,40 @@
package com.imprimelibros.erp.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.*;
import java.util.List;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, Instant now);
// Para validar un token en el reset
@Query("""
SELECT t FROM PasswordResetToken t
WHERE t.userId = :userId
AND t.tokenHash = :tokenHash
AND t.usedAt IS NULL
AND t.expiresAt > :now
""")
Optional<PasswordResetToken> findValidByUserAndHash(Long userId, String tokenHash, LocalDateTime now);
// Para invalidar anteriores
long deleteAllByUserIdAndUsedAtIsNull(Long userId);
// Invalida cualquier token activo del usuario
@Modifying
@Query("""
UPDATE PasswordResetToken t
SET t.usedAt = :now
WHERE t.userId = :userId
AND t.usedAt IS NULL
AND t.expiresAt > :now
""")
int invalidateActiveTokens(Long userId, LocalDateTime now);
// (Opcional) lista de activos, por si quieres inspeccionarlos
@Query("""
SELECT t FROM PasswordResetToken t
WHERE t.userId = :userId
AND t.usedAt IS NULL
AND t.expiresAt > :now
ORDER BY t.createdAt DESC
""")
List<PasswordResetToken> findAllActiveForUser(Long userId, LocalDateTime now);
}

View File

@ -7,7 +7,6 @@ import org.springframework.context.MessageSource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.springframework.core.io.ClassPathResource;
@ -29,11 +28,12 @@ public class EmailService {
}
public void sendPasswordResetMail(String to, String fullName, String resetUrl, Locale locale) {
String subject = messageSource.getMessage("email.resetPassword.title", null, locale);
String subject = messageSource.getMessage("email.reset-password.title", null, locale);
Map<String, Object> variables = Map.of(
"fullName", fullName,
"resetUrl", resetUrl);
sendEmail(to, subject, "imprimelibros/email/reset-password", variables, locale);
"resetUrl", resetUrl,
"minutes", 60);
sendEmail(to, subject, "imprimelibros/email/password-reset", variables, locale);
}
public void sendVerificationEmail(String to, String fullName, String verifyUrl, Locale locale) {

View File

@ -126,7 +126,7 @@ public class SecurityConfig {
"/login",
"/signup",
"/verify",
"/reset-password",
"/auth/password/**",
"/assets/**",
"/css/**",
"/js/**",