Compare commits

...

2 Commits

21 changed files with 564 additions and 21 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

@ -103,7 +103,7 @@ public class SecurityConfig {
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/", true)
.defaultSuccessUrl("/", false)
.failureUrl("/login?error") // útil para diagnosticar
)

View File

@ -35,7 +35,7 @@ public class User {
@Column(name = "enabled")
private boolean enabled;
@ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new java.util.HashSet<>();

View File

@ -1,6 +1,7 @@
package com.imprimelibros.erp.users;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.users.validation.UserForm;
import jakarta.servlet.http.HttpServletRequest;
@ -22,7 +23,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.imprimelibros.erp.datatables.DataTablesRequest;
@ -32,7 +32,6 @@ import com.imprimelibros.erp.datatables.DataTable;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Locale;
@ -51,19 +50,35 @@ public class UserController {
private MessageSource messageSource;
private Sanitizer sanitizer;
private PasswordEncoder passwordEncoder;
private TranslationService translationService;
public UserController(UserDao repo, UserService userService, MessageSource messageSource, Sanitizer sanitizer,
PasswordEncoder passwordEncoder, RoleDao roleRepo) {
PasswordEncoder passwordEncoder, RoleDao roleRepo, TranslationService translationService) {
this.repo = repo;
this.messageSource = messageSource;
this.sanitizer = sanitizer;
this.roleRepo = roleRepo;
this.passwordEncoder = passwordEncoder;
this.translationService = translationService;
}
@GetMapping
public String list(Model model, Authentication authentication, Locale locale) {
List<String> keys = List.of(
"usuarios.delete.title",
"usuarios.delete.text",
"usuarios.eliminar",
"usuarios.delete.button",
"app.yes",
"app.cancelar",
"usuarios.delete.ok.title",
"usuarios.delete.ok.text"
);
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/users/users-list";
}
@ -79,7 +94,7 @@ public class UserController {
// Si 'role' es relación, sácalo de aquí:
List<String> searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de
// verdad
List<String> orderable = List.of("fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas
List<String> orderable = List.of("id", "fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas
// columnas
Specification<User> base = (root, query, cb) -> cb.conjunction();

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

@ -46,4 +46,9 @@ usuarios.error.delete-self=No se puede eliminar a sí mismo.
usuarios.exito.creado=Usuario creado con éxito.
usuarios.exito.actualizado=Usuario actualizado con éxito.
usuarios.exito.eliminado=Usuario eliminado con éxito.
usuarios.exito.eliminado=Usuario eliminado con éxito.
usuarios.delete.title=Eliminar usuario
usuarios.delete.button=Si, ELIMINAR
usuarios.delete.text=¿Está seguro de que desea eliminar al usuario?<br>Esta acción no se puede deshacer.
usuarios.delete.ok.title=Usuario eliminado
usuarios.delete.ok.text=El usuario ha sido eliminado con éxito.

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

@ -84,13 +84,17 @@ $(() => {
const id = $(this).data('id');
Swal.fire({
title: '¿Eliminar usuario?',
text: 'Esta acción no se puede deshacer.',
title: window.languageBundle.get(['usuarios.delete.title']) || 'Eliminar usuario',
html: window.languageBundle.get(['usuarios.delete.text']) || 'Esta acción no se puede deshacer.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar',
reverseButtons: true
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-danger w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: window.languageBundle.get(['usuarios.delete.button']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
@ -98,7 +102,14 @@ $(() => {
url: '/users/' + id,
type: 'DELETE',
success: function () {
Swal.fire({ icon: 'success', title: 'Eliminado', timer: 1200, showConfirmButton: false });
Swal.fire({
icon: 'success', title: window.languageBundle.get(['usuarios.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['usuarios.delete.ok.text']) || 'El usuario ha sido eliminado con éxito.',
showConfirmButton: true,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
$('#users-datatable').DataTable().ajax.reload(null, false);
},
error: function (xhr) {

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>

View File

@ -85,6 +85,9 @@
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<script th:src="@{/assets/js/pages/imprimelibros/users/list.js}"></script>