recovery del pass hecho en el backend a falta de hacer los formularios

This commit is contained in:
2025-09-28 18:36:44 +02:00
parent 22198b4f25
commit 865b1573b9
15 changed files with 517 additions and 8 deletions

17
pom.xml
View File

@ -102,12 +102,6 @@
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<!-- Escape seguro al renderizar -->
<dependency>
<groupId>org.owasp.encoder</groupId>
@ -115,6 +109,17 @@
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Rate limiting -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
</dependencies>
<build>

View File

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

View File

@ -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);
}
}

View File

@ -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
}

View File

@ -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<PasswordResetToken, Long> {
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, Instant now);
// Para invalidar anteriores
long deleteAllByUserIdAndUsedAtIsNull(Long userId);
}

View File

@ -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);
}
}

View File

@ -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<String, Bucket> 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);
}
}

View File

@ -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<User, Long>, JpaSpecificationExecutor<User> {
User findByUserNameAndEnabledTrue(String userName);
Optional<User> findByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
}

View File

@ -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
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

View File

@ -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.

View File

@ -0,0 +1 @@

View File

@ -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.

View File

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

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="#{email.resetPassword.title}">Recuperación de contraseña</title>
<link rel="stylesheet" th:href="@{/css/email.css}">
</head>
<body>
<div class="container">
<div class="header">
<!-- Logo insertado como imagen inline -->
<img th:src="@{cid:companyLogo}" alt="Logo Imprimelibros" width="150">
</div>
<p><span th:text="#{email.greeting}">Hola</span> <b th:text="${username}">Usuario</b>,</p>
<p th:text="#{email.resetPassword.body}">Hemos recibido una solicitud para restablecer tu contraseña. Haz clic en el siguiente botón:</p>
<p style="text-align:center;">
<a th:href="${resetLink}" class="btn" th:text="#{email.resetPassword.button}">Restablecer contraseña</a>
</p>
<p th:text="#{email.resetPassword.ignoreMessage}">Si no solicitaste este cambio, puedes ignorar este mensaje.</p>
<div class="footer">
© <span th:text="${year}">2025</span> <span th:text="#{email.footer}">Imprimelibros - Todos los derechos reservados.</span>
</div>
</div>
</body>
</html>