From 865b1573b926b49b040818d149918ba0b8c56379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Sun, 28 Sep 2025 18:36:44 +0200 Subject: [PATCH] recovery del pass hecho en el backend a falta de hacer los formularios --- pom.xml | 17 ++- .../erp/auth/PasswordResetController.java | 133 ++++++++++++++++++ .../erp/auth/PasswordResetService.java | 86 +++++++++++ .../erp/auth/PasswordResetToken.java | 102 ++++++++++++++ .../auth/PasswordResetTokenRepository.java | 13 ++ .../erp/common/EmailService.java | 54 +++++++ .../erp/common/RateLimiterService.java | 26 ++++ .../com/imprimelibros/erp/users/UserDao.java | 5 +- src/main/resources/application.properties | 12 +- src/main/resources/i18n/auth_en.properties | 0 src/main/resources/i18n/auth_es.properties | 2 + src/main/resources/i18n/email_en.properties | 1 + src/main/resources/i18n/email_es.properties | 6 + .../resources/static/assets/css/email.css | 35 +++++ .../imprimelibros/email/password-reset.html | 33 +++++ 15 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/imprimelibros/erp/auth/PasswordResetController.java create mode 100644 src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java create mode 100644 src/main/java/com/imprimelibros/erp/auth/PasswordResetToken.java create mode 100644 src/main/java/com/imprimelibros/erp/auth/PasswordResetTokenRepository.java create mode 100644 src/main/java/com/imprimelibros/erp/common/EmailService.java create mode 100644 src/main/java/com/imprimelibros/erp/common/RateLimiterService.java create mode 100644 src/main/resources/i18n/auth_en.properties create mode 100644 src/main/resources/i18n/auth_es.properties create mode 100644 src/main/resources/i18n/email_en.properties create mode 100644 src/main/resources/i18n/email_es.properties create mode 100644 src/main/resources/static/assets/css/email.css create mode 100644 src/main/resources/templates/imprimelibros/email/password-reset.html diff --git a/pom.xml b/pom.xml index 05266e8..9054bfb 100644 --- a/pom.xml +++ b/pom.xml @@ -102,12 +102,6 @@ 1.17.2 - - org.jsoup - jsoup - 1.17.2 - - org.owasp.encoder @@ -115,6 +109,17 @@ 1.3.1 + + org.springframework.boot + spring-boot-starter-mail + + + + com.bucket4j + bucket4j-core + 8.10.1 + + diff --git a/src/main/java/com/imprimelibros/erp/auth/PasswordResetController.java b/src/main/java/com/imprimelibros/erp/auth/PasswordResetController.java new file mode 100644 index 0000000..99cdac6 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/auth/PasswordResetController.java @@ -0,0 +1,133 @@ +package com.imprimelibros.erp.auth; + +import com.imprimelibros.erp.common.RateLimiterService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotBlank; + +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 com.imprimelibros.erp.common.EmailService; +import com.imprimelibros.erp.users.UserDao; + +import java.util.Locale; +import java.util.Map; + +@Controller +@RequestMapping("/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 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; + 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))); + } + + /** Muestra formulario si el token es válido */ + @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( + @RequestParam("token") String token, + @RequestParam("password") @NotBlank String password, + @RequestParam("confirmPassword") @NotBlank String confirmPassword, + Model model, Locale locale + ) { + if (!password.equals(confirmPassword)) { + model.addAttribute("token", token); + model.addAttribute("error", messageSource.getMessage("auth.reset.form.passwordsMismatch", null, locale)); + return "auth/reset-form"; + } + + try { + resetService.consumeTokenAndSetPassword(token, password); + return "redirect:/login?resetOk"; + } catch (IllegalArgumentException ex) { + model.addAttribute("invalidToken", true); + return "auth/reset-invalid"; + } + } + + 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; + } +} diff --git a/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java b/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java new file mode 100644 index 0000000..c5a3dbc --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java @@ -0,0 +1,86 @@ +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; + +@Service +public class PasswordResetService { + + private final PasswordResetTokenRepository tokenRepo; + private final UserDao userRepo; // tu repo real + private final PasswordEncoder passwordEncoder; + + private static final SecureRandom RNG = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + + public PasswordResetService(PasswordResetTokenRepository tokenRepo, UserDao userRepo, PasswordEncoder enc) { + this.tokenRepo = tokenRepo; + this.userRepo = userRepo; + this.passwordEncoder = enc; + } + + /** 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); + + // Generar token + byte[] raw = new byte[32]; + RNG.nextBytes(raw); + String token = HEX.formatHex(raw); // token plano (64 hex) + String tokenHash = sha256(token); + + 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); + + return token; // Esto se envía por email + } + + public Long validateTokenAndGetUserId(String tokenPlain) { + return tokenRepo.findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(sha256(tokenPlain), Instant.now()) + .map(PasswordResetToken::getUserId) + .orElse(null); + } + + 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")); + + var user = userRepo.findById(prt.getUserId()) + .orElseThrow(() -> new IllegalStateException("Usuario no encontrado")); + + user.setPassword(passwordEncoder.encode(newPassword)); + userRepo.save(user); + + prt.setUsedAt(Instant.now()); + tokenRepo.save(prt); + } + + private static String sha256(String s) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HEX.formatHex(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); + } +} diff --git a/src/main/java/com/imprimelibros/erp/auth/PasswordResetToken.java b/src/main/java/com/imprimelibros/erp/auth/PasswordResetToken.java new file mode 100644 index 0000000..40a78fe --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/auth/PasswordResetToken.java @@ -0,0 +1,102 @@ +package com.imprimelibros.erp.auth; + +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") +}) +public class PasswordResetToken { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) private Long userId; + + @Column(nullable = false, length = 128) + private String tokenHash; // SHA-256 hex + + @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) + private String requestIp; + + @Column(length = 255) + private String userAgent; + + public Long getId() { + return id; + } + + public void setId(Long id) { + 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() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUsedAt() { + return usedAt; + } + + public void setUsedAt(Instant usedAt) { + this.usedAt = usedAt; + } + + public String getRequestIp() { + return requestIp; + } + + public void setRequestIp(String requestIp) { + this.requestIp = requestIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + // getters/setters + +} diff --git a/src/main/java/com/imprimelibros/erp/auth/PasswordResetTokenRepository.java b/src/main/java/com/imprimelibros/erp/auth/PasswordResetTokenRepository.java new file mode 100644 index 0000000..5d398d4 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/auth/PasswordResetTokenRepository.java @@ -0,0 +1,13 @@ +package com.imprimelibros.erp.auth; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.time.Instant; +import java.util.Optional; + +public interface PasswordResetTokenRepository extends JpaRepository { + + Optional findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, Instant now); + + // Para invalidar anteriores + long deleteAllByUserIdAndUsedAtIsNull(Long userId); +} diff --git a/src/main/java/com/imprimelibros/erp/common/EmailService.java b/src/main/java/com/imprimelibros/erp/common/EmailService.java new file mode 100644 index 0000000..2569f22 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/common/EmailService.java @@ -0,0 +1,54 @@ +package com.imprimelibros.erp.common; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +import org.springframework.context.MessageSource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.util.Locale; +import java.util.Map; + +@Service +public class EmailService { + + private final JavaMailSender mailSender; + private final TemplateEngine templateEngine; + private final MessageSource messageSource; + + public EmailService(JavaMailSender mailSender, TemplateEngine templateEngine, MessageSource messageSource) { + this.mailSender = mailSender; + this.templateEngine = templateEngine; + this.messageSource = messageSource; + } + + public void sendPasswordResetMail(String to, String username, String resetLink, Locale locale) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom("no-reply@imprimelibros.com"); + helper.setTo(to); + helper.setSubject(messageSource.getMessage("email.resetPassword.title", null, locale)); + + // Variables para la plantilla + Context context = new Context(); + context.setVariables(Map.of( + "username", username, + "resetLink", resetLink, + "year", String.valueOf(java.time.Year.now().getValue()) + )); + + // Procesar plantilla HTML + String html = templateEngine.process("email/password-reset", context); + helper.setText(html, true); + + helper.addInline("companyLogo", new ClassPathResource("static/images/logo-light.png")); + + mailSender.send(message); + } +} diff --git a/src/main/java/com/imprimelibros/erp/common/RateLimiterService.java b/src/main/java/com/imprimelibros/erp/common/RateLimiterService.java new file mode 100644 index 0000000..c4cb936 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/common/RateLimiterService.java @@ -0,0 +1,26 @@ +package com.imprimelibros.erp.common; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class RateLimiterService { + + private final Map buckets = new ConcurrentHashMap<>(); + + // 5 solicitudes cada 15 minutos por clave (IP) + private Bucket newBucket() { + Bandwidth limit = Bandwidth.classic(5, Refill.greedy(5, Duration.ofMinutes(15))); + return Bucket.builder().addLimit(limit).build(); + } + + public boolean tryConsume(String key) { + return buckets.computeIfAbsent(key, k -> newBucket()).tryConsume(1); + } +} diff --git a/src/main/java/com/imprimelibros/erp/users/UserDao.java b/src/main/java/com/imprimelibros/erp/users/UserDao.java index 3be9126..e4eaf1b 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDao.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDao.java @@ -1,14 +1,17 @@ package com.imprimelibros.erp.users; import org.springframework.stereotype.Repository; + +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @Repository public interface UserDao extends JpaRepository, JpaSpecificationExecutor { - User findByUserNameAndEnabledTrue(String userName); + Optional findByUserNameIgnoreCase(String userName); boolean existsByUserNameIgnoreCase(String userName); boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e1733be..2efda73 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,4 +58,14 @@ security.rememberme.key=N`BY^YRVO:/\H$hsKxNq # # Enable HiddenHttpMethodFilter to support PUT and DELETE methods in forms # -spring.mvc.hiddenmethod.filter.enabled=true \ No newline at end of file +spring.mvc.hiddenmethod.filter.enabled=true + +# +# Email +# +spring.mail.host=smtp.ionos.es +spring.mail.port=587 +spring.mail.username=no-reply@imprimelibros.com +spring.mail.password=%j4Su*#ZcjRDYsa$ +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true diff --git a/src/main/resources/i18n/auth_en.properties b/src/main/resources/i18n/auth_en.properties new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/i18n/auth_es.properties b/src/main/resources/i18n/auth_es.properties new file mode 100644 index 0000000..2c0ce11 --- /dev/null +++ b/src/main/resources/i18n/auth_es.properties @@ -0,0 +1,2 @@ +auth.reset.request.success=Si existe una cuenta asociada, se han enviado las instrucciones. +auth.reset.form.passwordsMismatch=Las contraseñas no coinciden. \ No newline at end of file diff --git a/src/main/resources/i18n/email_en.properties b/src/main/resources/i18n/email_en.properties new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/resources/i18n/email_en.properties @@ -0,0 +1 @@ + diff --git a/src/main/resources/i18n/email_es.properties b/src/main/resources/i18n/email_es.properties new file mode 100644 index 0000000..ee041d7 --- /dev/null +++ b/src/main/resources/i18n/email_es.properties @@ -0,0 +1,6 @@ +email.resetPassword.title=Restablecer contraseña +email.greeting=Hola +email.resetPassword.body=Hemos recibido una solicitud para restablecer tu contraseña. Haz clic en el siguiente botón: +email.resetPassword.button=Restablecer contraseña +email.resetPassword.ignoreMessage=Si no solicitaste este cambio, puedes ignorar este mensaje. +email.footer=Imprimelibros - Todos los derechos reservados. diff --git a/src/main/resources/static/assets/css/email.css b/src/main/resources/static/assets/css/email.css new file mode 100644 index 0000000..20ca139 --- /dev/null +++ b/src/main/resources/static/assets/css/email.css @@ -0,0 +1,35 @@ +body { + font-family: Arial, sans-serif; + background: #f8f9fa; + padding: 20px; +} + +.container { + background: #fff; + border-radius: 8px; + padding: 20px; + max-width: 600px; + margin: auto; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.header { + text-align: center; + margin-bottom: 20px; +} + +.btn { + display: inline-block; + padding: 10px 20px; + background: #0d6efd; + color: #fff; + text-decoration: none; + border-radius: 5px; +} + +.footer { + margin-top: 20px; + font-size: 12px; + color: #6c757d; + text-align: center; +} \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/email/password-reset.html b/src/main/resources/templates/imprimelibros/email/password-reset.html new file mode 100644 index 0000000..549500a --- /dev/null +++ b/src/main/resources/templates/imprimelibros/email/password-reset.html @@ -0,0 +1,33 @@ + + + + + + Recuperación de contraseña + + + + +
+
+ + Logo Imprimelibros +
+ +

Hola Usuario,

+

Hemos recibido una solicitud para restablecer tu contraseña. Haz clic en el siguiente botón:

+ +

+ Restablecer contraseña +

+ +

Si no solicitaste este cambio, puedes ignorar este mensaje.

+ + + +
+ + + \ No newline at end of file