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; package com.imprimelibros.erp.auth;
import com.imprimelibros.erp.common.RateLimiterService; import jakarta.validation.constraints.Email;
import com.imprimelibros.erp.common.email.EmailService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.Locale;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.imprimelibros.erp.users.UserDao; import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import java.util.Map;
@Controller @Controller
@RequestMapping("/password") @RequestMapping("/auth/password")
@Validated @Validated
public class PasswordResetController { public class PasswordResetController {
private final PasswordResetService resetService; private final PasswordResetService service;
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; private final MessageSource messageSource;
public PasswordResetController(PasswordResetService resetService, public PasswordResetController(PasswordResetService service, MessageSource messageSource) {
EmailService emailService, this.service = service;
UserDao userRepo,
RateLimiterService rateLimiter,
MessageSource messageSource) {
this.resetService = resetService;
this.emailService = emailService;
this.userRepo = userRepo;
this.rateLimiter = rateLimiter;
this.messageSource = messageSource; this.messageSource = messageSource;
} }
/** Endpoint para solicitar el email de reseteo (respuesta neutra y rate limiting) */ // 4.1 Página "¿Olvidaste tu contraseña?"
@PostMapping("/request") @GetMapping("/forgot")
@ResponseBody public String forgotForm(Model model, Locale locale) {
public ResponseEntity<?> requestReset(@RequestParam("email") String email, HttpServletRequest req, Locale locale) { model.addAttribute("form", "_forgot-password");
String clientIp = extractClientIp(req); return "imprimelibros/login/login";
// 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 */ // 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") @GetMapping("/reset")
public String showResetForm(@RequestParam("token") String token, Model model) { public String resetForm(@RequestParam("uid") Long uid,
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("token") String token,
@RequestParam("password") @NotBlank String password, Model model, Locale locale) {
@RequestParam("confirmPassword") @NotBlank String confirmPassword, boolean ok = service.isValid(uid, token);
Model model, Locale locale model.addAttribute("uid", uid);
) { model.addAttribute("token", token);
if (!password.equals(confirmPassword)) { 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("token", token);
model.addAttribute("error", messageSource.getMessage("auth.reset.form.passwordsMismatch", null, locale)); model.addAttribute("danger", messageSource.getMessage("login.password-reset.error", null, locale));
return "auth/reset-form"; model.addAttribute("form", "_reset-password");
return "imprimelibros/login/login";
} }
if (service.resetPassword(uid, token, password)) {
try { model.addAttribute("info", messageSource.getMessage("login.password-reset.success", null, locale));
resetService.consumeTokenAndSetPassword(token, password); } else {
return "redirect:/login?resetOk"; model.addAttribute("danger", messageSource.getMessage("login.password-reset.error-link", null, locale));
} catch (IllegalArgumentException ex) {
model.addAttribute("invalidToken", true);
return "auth/reset-invalid";
} }
} model.addAttribute("form", "_login");
return "imprimelibros/login/login";
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

@ -1,86 +1,124 @@
package com.imprimelibros.erp.auth; 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.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.util.Base64;
import java.util.HexFormat; 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 @Service
public class PasswordResetService { public class PasswordResetService {
private final PasswordResetTokenRepository tokenRepo; private final PasswordResetTokenRepository tokenRepo;
private final UserDao userRepo; // tu repo real private final UserDao userRepo;
private final PasswordEncoder passwordEncoder; 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(); public PasswordResetService(
private static final HexFormat HEX = HexFormat.of(); PasswordResetTokenRepository tokenRepo,
UserDao userRepo,
public PasswordResetService(PasswordResetTokenRepository tokenRepo, UserDao userRepo, PasswordEncoder enc) { PasswordEncoder passwordEncoder,
JavaMailSender mailSender,
SpringTemplateEngine templateEngine,
MessageSource messages,
EmailService emailService
) {
this.tokenRepo = tokenRepo; this.tokenRepo = tokenRepo;
this.userRepo = userRepo; 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. */ // 3.1 Solicitar reset (si el email existe, genera token y envía)
public String createTokenForUser(Long userId, int hoursToExpire, String requestIp, String userAgent) { @Transactional
// Invalidar anteriores public void requestReset(String email, String baseUrl, String ip, String userAgent, int minutes, Locale locale) {
tokenRepo.deleteAllByUserIdAndUsedAtIsNull(userId);
User user = userRepo.findByUserNameIgnoreCase(email).orElse(null);
// Siempre responder OK aunque no exista para evitar enumeración
if (user == null) return;
// Generar token tokenRepo.invalidateActiveTokens(user.getId(), LocalDateTime.now());
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(); String token = generateToken(); // token en claro SOLO para el enlace
prt.setUserId(userId); String tokenHash = sha256(token); // guardamos hash en DB
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 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) { // 3.2 Validar token (para mostrar el formulario de nueva contraseña)
return tokenRepo.findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(sha256(tokenPlain), Instant.now()) public boolean isValid(Long userId, String token) {
.map(PasswordResetToken::getUserId) String hash = sha256(token);
.orElse(null); return tokenRepo.findValidByUserAndHash(userId, hash, LocalDateTime.now()).isPresent();
} }
public void consumeTokenAndSetPassword(String tokenPlain, String newPassword) { // 3.3 Confirmar reseteo y marcar token como usado
var tokenOpt = tokenRepo.findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(sha256(tokenPlain), Instant.now()); @Transactional
var prt = tokenOpt.orElseThrow(() -> new IllegalArgumentException("Token inválido o caducado")); 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()) var entry = opt.get();
.orElseThrow(() -> new IllegalStateException("Usuario no encontrado")); var user = userRepo.findById(userId).orElse(null);
if (user == null) return false;
user.setPassword(passwordEncoder.encode(newPassword)); user.setPassword(passwordEncoder.encode(newPassword));
userRepo.save(user); userRepo.save(user);
prt.setUsedAt(Instant.now()); entry.setUsedAt(LocalDateTime.now());
tokenRepo.save(prt); 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 { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); var md = MessageDigest.getInstance("SHA-256");
return HEX.formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8))); return Base64.getEncoder().encodeToString(md.digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(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; package com.imprimelibros.erp.auth;
import java.time.LocalDateTime;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant;
@Entity @Entity
@Table(name = "password_reset_tokens", indexes = { @Table(name = "password_reset_tokens")
@Index(name = "idx_prt_token_hash", columnList = "tokenHash"),
@Index(name = "idx_prt_user_id", columnList = "userId")
})
public class PasswordResetToken { public class PasswordResetToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@Column(nullable = false) private Long userId; @Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false, length = 128) @Column(name = "expires_at", nullable = false)
private String tokenHash; // SHA-256 hex private LocalDateTime expiresAt;
@Column(nullable = false) @Column(name = "request_ip", length = 64)
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; 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; private String userAgent;
@Column(name = "user_id", nullable = false)
private Long userId;
public Long getId() { public Long getId() {
return id; return id;
} }
@ -41,44 +39,20 @@ public class PasswordResetToken {
this.id = id; this.id = id;
} }
public Long getUserId() { public LocalDateTime getCreatedAt() {
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; return createdAt;
} }
public void setCreatedAt(Instant createdAt) { public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
public Instant getUsedAt() { public LocalDateTime getExpiresAt() {
return usedAt; return expiresAt;
} }
public void setUsedAt(Instant usedAt) { public void setExpiresAt(LocalDateTime expiresAt) {
this.usedAt = usedAt; this.expiresAt = expiresAt;
} }
public String getRequestIp() { public String getRequestIp() {
@ -89,6 +63,22 @@ public class PasswordResetToken {
this.requestIp = requestIp; 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() { public String getUserAgent() {
return userAgent; return userAgent;
} }
@ -97,6 +87,13 @@ public class PasswordResetToken {
this.userAgent = userAgent; 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; package com.imprimelibros.erp.auth;
import org.springframework.data.jpa.repository.JpaRepository; import java.time.LocalDateTime;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.*;
import java.util.List;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> { 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 // Invalida cualquier token activo del usuario
long deleteAllByUserIdAndUsedAtIsNull(Long userId); @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.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.thymeleaf.TemplateEngine; import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context; import org.thymeleaf.context.Context;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
@ -29,11 +28,12 @@ public class EmailService {
} }
public void sendPasswordResetMail(String to, String fullName, String resetUrl, Locale locale) { 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( Map<String, Object> variables = Map.of(
"fullName", fullName, "fullName", fullName,
"resetUrl", resetUrl); "resetUrl", resetUrl,
sendEmail(to, subject, "imprimelibros/email/reset-password", variables, locale); "minutes", 60);
sendEmail(to, subject, "imprimelibros/email/password-reset", variables, locale);
} }
public void sendVerificationEmail(String to, String fullName, String verifyUrl, Locale locale) { public void sendVerificationEmail(String to, String fullName, String verifyUrl, Locale locale) {

View File

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

View File

@ -5,4 +5,19 @@ email.verify.button=Verificar cuenta
email.verify.link-instruction=Si no funciona, copia y pega esta URL en tu navegador: email.verify.link-instruction=Si no funciona, copia y pega esta URL en tu navegador:
email.verify.expiration=Este enlace caduca en {0} minutos. email.verify.expiration=Este enlace caduca en {0} minutos.
email.verify.ignoreMessage=Si no solicitaste este cambio, puedes ignorar este mensaje. email.verify.ignoreMessage=Si no solicitaste este cambio, puedes ignorar este mensaje.
email.footer=Imprimelibros - Todos los derechos reservados.
email.reset-password.title=Restablecer contraseña
email.reset-password.body=Haz clic en el siguiente botón para restablecer tu contraseña:
email.reset-password.button=Restablecer contraseña
email.reset-password.link-instruction=Si no funciona, copia y pega esta URL en tu navegador:
email.reset-password.expiration=Este enlace caduca en {0} minutos.
email.reset-password.ignoreMessage=Si no solicitaste este cambio, puedes ignorar este mensaje.
email.reset.title=Restablecer tu contraseña
email.reset.hello=Hola,
email.reset.instructions=Has solicitado restablecer tu contraseña.
email.reset.button=Restablecer contraseña
email.reset.ignore=Si no solicitaste este cambio, puedes ignorar este correo.
email.verify.expiration=Este enlace caduca en {0} minutos.
email.footer=Imprimelibros - Todos los derechos reservados.

View File

@ -17,6 +17,14 @@ login.sign-up=Regístrate
login.sign-up-button=Crear cuenta login.sign-up-button=Crear cuenta
login.sign-up.title=Crear una cuenta login.sign-up.title=Crear una cuenta
login.sign-up.name=Nombre completo login.sign-up.name=Nombre completo
login.password-recovery.title=Recuperar contraseña
login.password-recovery.button=Recuperar contraseña
login.password-recovery.email-info=Se te ha enviado un correo con instrucciones para restablecer tu contraseña.
login.change-password.title=Cambiar contraseña
login.change-password.subtitle=Introduce tu nueva contraseña a continuación.
login.change-password.new-password=Nueva contraseña
login.change-password.confirm-password=Confirmar nueva contraseña
login.change-password.button=Cambiar contraseña
login.error=Credenciales inválidas login.error=Credenciales inválidas
login.signup.error.email.exists=El correo electrónico ya está en uso. login.signup.error.email.exists=El correo electrónico ya está en uso.
@ -25,5 +33,10 @@ login.signup.error.password.mismatch=Las contraseñas no coinciden.
login.signup.error.review=Por favor, revisa el formulario. login.signup.error.review=Por favor, revisa el formulario.
login.signup.error.token.invalid=Enlace inválido o caducado. Solicita uno nuevo. login.signup.error.token.invalid=Enlace inválido o caducado. Solicita uno nuevo.
login.password-reset.error=Las contraseñas no coinciden o son demasiado cortas.
login.password-reset.error-link=El enlace no es válido o ha expirado.
login.signup.success=Cuenta creada con éxito. Por favor, revisa tu correo para activar tu cuenta. login.signup.success=Cuenta creada con éxito. Por favor, revisa tu correo para activar tu cuenta.
login.signup.success.verified=¡Cuenta verificada! Ya puedes iniciar sesión. login.signup.success.verified=¡Cuenta verificada! Ya puedes iniciar sesión.
login.password-reset.success=Contraseña cambiada correctamente. Ya puedes iniciar sesión.

View File

@ -1,33 +1,45 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html lang="es" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="content">
<p style="margin:0 0 12px; color:#333333 !important;">
<span th:text="#{email.greeting}">Hola</span>
<span th:text="${fullName} ?: 'Usuario'">Usuario</span>,
</p>
<head> <p style="margin:0 0 12px; color:#333333 !important;">
<meta charset="UTF-8"> <span th:text="#{email.reset-password.body}">
<title th:text="#{email.resetPassword.title}">Recuperación de contraseña</title> Haz clic en el siguiente botón para restablecer tu contraseña:
<link rel="stylesheet" th:href="@{/css/email.css}"> </span>
</head> </p>
<body> <p style="margin:0 0 16px;">
<div class="container"> <a th:href="${resetUrl}"
<div class="header"> style="display:inline-block; padding:12px 20px; border-radius:6px; font-weight:bold;
<!-- Logo insertado como imagen inline --> background:#2563eb; color:#ffffff !important; text-decoration:none;">
<img th:src="@{cid:companyLogo}" alt="Logo Imprimelibros" width="150"> <span th:text="#{email.reset-password.button}">Restablecer contraseña</span>
</div> </a>
</p>
<p><span th:text="#{email.greeting}">Hola</span> <b th:text="${username}">Usuario</b>,</p> <p style="margin:0 0 8px; color:#333333 !important;">
<p th:text="#{email.resetPassword.body}">Hemos recibido una solicitud para restablecer tu contraseña. Haz clic en el siguiente botón:</p> <span th:text="#{email.reset-password.link-instruction}">
Si no funciona, copia y pega esta URL en tu navegador:
</span>
</p>
<p style="text-align:center;"> <p style="margin:0 0 12px; color:#333333 !important;">
<a th:href="${resetLink}" class="btn" th:text="#{email.resetPassword.button}">Restablecer contraseña</a> <span th:text="${resetUrl}">https://...</span>
</p> </p>
<p th:text="#{email.resetPassword.ignoreMessage}">Si no solicitaste este cambio, puedes ignorar este mensaje.</p> <p style="margin:0 0 12px; color:#333333 !important;">
<span th:text="#{email.reset-password.expiration(${minutes})}">
Este enlace caduca en 60 minutos.
</span>
</p>
<div class="footer"> <p style="margin:0; color:#333333 !important;">
© <span th:text="${year}">2025</span> <span th:text="#{email.footer}">Imprimelibros - Todos los derechos reservados.</span> <span th:text="#{email.reset-password.ignoreMessage}">
</div> Si no solicitaste este cambio, puedes ignorar este mensaje.
</span>
</div> </p>
</body> </th:block>
</html>
</html>

View File

@ -0,0 +1,21 @@
<div th:fragment="_forgot-password">
<div>
<h5 class="text-primary" th:text="#{login.password-recovery.title}">Recuperar contraseña</h5>
</div>
<form th:if="${form == '_forgot-password'}" th:action="@{/auth/password/forgot}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="mb-3">
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
<input type="email" class="form-control" id="username" th:placeholder="#{login.email-placeholder}"
name="username">
</div>
<div class="mt-4">
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.password-recovery.button}">Recuperar
contraseña</button>
</div>
</form>
</div>

View File

@ -22,7 +22,7 @@
<div class="mb-3"> <div class="mb-3">
<div class="float-end"> <div class="float-end">
<a href="/auth-pass-reset-cover" class="text-muted" th:text="#{login.forgotPassword}">¿Olvidó su <a href="/auth/password/forgot" class="text-muted" th:text="#{login.forgotPassword}">¿Olvidó su
contraseña?</a> contraseña?</a>
</div> </div>
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label> <label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>

View File

@ -0,0 +1,41 @@
<div th:fragment="_reset-password">
<div>
<h5 class="text-primary" th:text="#{login.change-password.title}">Cambiar contraseña</h5>
</div>
<form th:if="${form == '_reset-password'} and ${valid}" th:action="@{/auth/password/reset}" method="post"
autocomplete="off">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="uid" th:value="${uid}" />
<input type="hidden" name="token" th:value="${token}" />
<div class="mb-3">
<label class="form-label" for="password-input" th:text="#{login.change-password.new-password}">Nueva
contraseña</label>
<input type="password" class="form-control" id="password-input" name="password" required minlength="8"
autocomplete="new-password" th:placeholder="#{login.password-placeholder}">
</div>
<div class="mb-3">
<label class="form-label" for="password-confirm-input"
th:text="#{login.change-password.confirm-password}">Confirmar contraseña</label>
<input type="password" class="form-control" id="password-confirm-input" name="password2" required
minlength="8" autocomplete="new-password" th:placeholder="#{login.password-placeholder}">
</div>
<div class="mt-4">
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.change-password.button}">Cambiar
contraseña</button>
</div>
</form>
<!-- Si el token no es válido, sugerir pedir otro -->
<div th:if="${form == '_reset-password'} and ${valid != null and !valid}" class="mt-3">
<a class="btn btn-outline-primary w-100" th:href="@{/auth/password/forgot}"
th:text="#{login.password-reset.request-new}">
Solicitar nuevo enlace
</a>
</div>
</div>

View File

@ -1,7 +1,5 @@
<div th:fragment="_signup"> <div th:fragment="_signup">
<div> <div>
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5> <h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
<p class="text-muted" th:text="#{login.sign-up.title}">Crear cuenta</p> <p class="text-muted" th:text="#{login.sign-up.title}">Crear cuenta</p>
@ -10,8 +8,6 @@
<!-- En el caso del formulario de signup, asegúrate de bindear el DTO --> <!-- En el caso del formulario de signup, asegúrate de bindear el DTO -->
<form th:if="${form == '_signup'}" th:action="@{/signup}" method="post" th:object="${signupForm}"> <form th:if="${form == '_signup'}" th:action="@{/signup}" method="post" th:object="${signupForm}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" /> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="mb-3"> <div class="mb-3">