mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-12 16:38:48 +00:00
recovery del pass hecho en el backend a falta de hacer los formularios
This commit is contained in:
17
pom.xml
17
pom.xml
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
102
src/main/java/com/imprimelibros/erp/auth/PasswordResetToken.java
Normal file
102
src/main/java/com/imprimelibros/erp/auth/PasswordResetToken.java
Normal 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
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
54
src/main/java/com/imprimelibros/erp/common/EmailService.java
Normal file
54
src/main/java/com/imprimelibros/erp/common/EmailService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
0
src/main/resources/i18n/auth_en.properties
Normal file
0
src/main/resources/i18n/auth_en.properties
Normal file
2
src/main/resources/i18n/auth_es.properties
Normal file
2
src/main/resources/i18n/auth_es.properties
Normal 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.
|
||||
1
src/main/resources/i18n/email_en.properties
Normal file
1
src/main/resources/i18n/email_en.properties
Normal file
@ -0,0 +1 @@
|
||||
|
||||
6
src/main/resources/i18n/email_es.properties
Normal file
6
src/main/resources/i18n/email_es.properties
Normal 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.
|
||||
35
src/main/resources/static/assets/css/email.css
Normal file
35
src/main/resources/static/assets/css/email.css
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
Reference in New Issue
Block a user