mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-02-01 00:28:51 +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>
|
<version>1.17.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.jsoup</groupId>
|
|
||||||
<artifactId>jsoup</artifactId>
|
|
||||||
<version>1.17.2</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Escape seguro al renderizar -->
|
<!-- Escape seguro al renderizar -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.owasp.encoder</groupId>
|
<groupId>org.owasp.encoder</groupId>
|
||||||
@ -115,6 +109,17 @@
|
|||||||
<version>1.3.1</version>
|
<version>1.3.1</version>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<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;
|
package com.imprimelibros.erp.users;
|
||||||
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||||
|
|
||||||
|
|
||||||
User findByUserNameAndEnabledTrue(String userName);
|
User findByUserNameAndEnabledTrue(String userName);
|
||||||
|
Optional<User> findByUserNameIgnoreCase(String userName);
|
||||||
boolean existsByUserNameIgnoreCase(String userName);
|
boolean existsByUserNameIgnoreCase(String userName);
|
||||||
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,3 +59,13 @@ security.rememberme.key=N`BY^YRVO:/\H$hsKxNq
|
|||||||
# Enable HiddenHttpMethodFilter to support PUT and DELETE methods in forms
|
# 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