diff --git a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java index 096488d..8ffbb77 100644 --- a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java +++ b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java @@ -30,143 +30,151 @@ import jakarta.servlet.http.HttpServletRequest; @Configuration public class SecurityConfig { - private final DataSource dataSource; + private final DataSource dataSource; - public SecurityConfig(DataSource dataSource) { - this.dataSource = dataSource; - } + public SecurityConfig(DataSource dataSource) { + this.dataSource = dataSource; + } - // ========== Beans base ========== + // ========== Beans base ========== - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } - // Remember-me (tabla persistent_logins) - @Bean - public PersistentTokenRepository persistentTokenRepository() { - JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); - repo.setDataSource(dataSource); - // repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la - // tabla - return repo; - } + // Remember-me (tabla persistent_logins) + @Bean + public PersistentTokenRepository persistentTokenRepository() { + JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); + repo.setDataSource(dataSource); + // repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la + // tabla + return repo; + } - // Provider que soporta UsernamePasswordAuthenticationToken - private static RequestMatcher pathStartsWith(String... prefixes) { - return new RequestMatcher() { - @Override - public boolean matches(HttpServletRequest request) { - String uri = request.getRequestURI(); - if (uri == null) - return false; - for (String p : prefixes) { - if (uri.startsWith(p)) - return true; - } - return false; - } - }; - } + // Provider que soporta UsernamePasswordAuthenticationToken + private static RequestMatcher pathStartsWith(String... prefixes) { + return new RequestMatcher() { + @Override + public boolean matches(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null) + return false; + for (String p : prefixes) { + if (uri.startsWith(p)) + return true; + } + return false; + } + }; + } - @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http, - @Value("${security.rememberme.key}") String keyRememberMe, - UserDetailsService userDetailsService, - PersistentTokenRepository tokenRepo, - PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception { + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + @Value("${security.rememberme.key}") String keyRememberMe, + UserDetailsService userDetailsService, + PersistentTokenRepository tokenRepo, + PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl); - provider.setPasswordEncoder(passwordEncoder); - http.authenticationProvider(provider); - http - .authenticationProvider(provider) + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl); + provider.setPasswordEncoder(passwordEncoder); + http.authenticationProvider(provider); + http + .authenticationProvider(provider) - .sessionManagement(session -> session - //.invalidSessionUrl("/login?expired") - .maximumSessions(1)) + .sessionManagement(session -> session + // .invalidSessionUrl("/login?expired") + .maximumSessions(1)) - // Ignora CSRF para tu recurso público (sin Ant/Mvc matchers) - .csrf(csrf -> csrf - .ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"))) - // ====== RequestCache: sólo navegaciones HTML reales ====== - .requestCache(rc -> { - HttpSessionRequestCache cache = new HttpSessionRequestCache(); + // Ignora CSRF para tu recurso público (sin Ant/Mvc matchers) + .csrf(csrf -> csrf + .ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"), + pathStartsWith("/pagos/redsys/"))) + // ====== RequestCache: sólo navegaciones HTML reales ====== + .requestCache(rc -> { + HttpSessionRequestCache cache = new HttpSessionRequestCache(); - // Navegación HTML (por tipo o por cabecera Accept) - RequestMatcher htmlPage = new OrRequestMatcher( - new MediaTypeRequestMatcher(MediaType.TEXT_HTML), - new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML), - new RequestHeaderRequestMatcher("Accept", "text/html")); + // Navegación HTML (por tipo o por cabecera Accept) + RequestMatcher htmlPage = new OrRequestMatcher( + new MediaTypeRequestMatcher(MediaType.TEXT_HTML), + new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML), + new RequestHeaderRequestMatcher("Accept", "text/html")); - // No AJAX - RequestMatcher nonAjax = new NegatedRequestMatcher( - new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest")); + // No AJAX + RequestMatcher nonAjax = new NegatedRequestMatcher( + new RequestHeaderRequestMatcher("X-Requested-With", + "XMLHttpRequest")); - // Excluir sondas .well-known - RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/")); + // Excluir sondas .well-known + RequestMatcher notWellKnown = new NegatedRequestMatcher( + pathStartsWith("/.well-known/")); - // Excluir estáticos: comunes + tu /assets/** - RequestMatcher notStatic = new AndRequestMatcher( - new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()), - new NegatedRequestMatcher(pathStartsWith("/assets/"))); - - RequestMatcher cartCount = new AndRequestMatcher( - new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()), - new NegatedRequestMatcher(pathStartsWith("/cart/count"))); + // Excluir estáticos: comunes + tu /assets/** + RequestMatcher notStatic = new AndRequestMatcher( + new NegatedRequestMatcher(PathRequest.toStaticResources() + .atCommonLocations()), + new NegatedRequestMatcher(pathStartsWith("/assets/"))); - cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown, cartCount)); - rc.requestCache(cache); - }) - // ======================================================== + RequestMatcher cartCount = new AndRequestMatcher( + new NegatedRequestMatcher(PathRequest.toStaticResources() + .atCommonLocations()), + new NegatedRequestMatcher(pathStartsWith("/cart/count"))); - .authorizeHttpRequests(auth -> auth - // Aquí usa patrones String (no deprecados) - .requestMatchers( - "/", - "/login", - "/signup", - "/verify", - "/auth/password/**", - "/assets/**", - "/css/**", - "/js/**", - "/images/**", - "/public/**", - "/presupuesto/public/**", - "/error", - "/favicon.ico", - "/.well-known/**", // opcional - "/api/pdf/presupuesto/**" - ).permitAll() - .requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN") - .anyRequest().authenticated()) + cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, + notWellKnown, cartCount)); + rc.requestCache(cache); + }) + // ======================================================== - .formLogin(login -> login - .loginPage("/login").permitAll() - .loginProcessingUrl("/login") - .usernameParameter("username") - .passwordParameter("password") - .defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada) - .failureUrl("/login?error")) + .authorizeHttpRequests(auth -> auth + // Aquí usa patrones String (no deprecados) + .requestMatchers( + "/", + "/login", + "/signup", + "/verify", + "/auth/password/**", + "/assets/**", + "/css/**", + "/js/**", + "/images/**", + "/public/**", + "/presupuesto/public/**", + "/error", + "/favicon.ico", + "/.well-known/**", // opcional + "/api/pdf/presupuesto/**", + "/pagos/redsys/**" + ) + .permitAll() + .requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN") + .anyRequest().authenticated()) - .rememberMe(rm -> rm - .key(keyRememberMe) - .rememberMeParameter("remember-me") - .rememberMeCookieName("IMPRIMELIBROS_REMEMBER") - .tokenValiditySeconds(60 * 60 * 24 * 2) - .userDetailsService(userDetailsService) - .tokenRepository(tokenRepo)) + .formLogin(login -> login + .loginPage("/login").permitAll() + .loginProcessingUrl("/login") + .usernameParameter("username") + .passwordParameter("password") + .defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada) + .failureUrl("/login?error")) - .logout(logout -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/") - .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER") - .permitAll()); + .rememberMe(rm -> rm + .key(keyRememberMe) + .rememberMeParameter("remember-me") + .rememberMeCookieName("IMPRIMELIBROS_REMEMBER") + .tokenValiditySeconds(60 * 60 * 24 * 2) + .userDetailsService(userDetailsService) + .tokenRepository(tokenRepo)) - return http.build(); - } + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER") + .permitAll()); + + return http.build(); + } } diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java index fd60256..573f0e2 100644 --- a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -77,21 +77,34 @@ public class PaymentService { * decodeMerchantParameters */ @Transactional - public void handleRedsysNotification(String dsSignature, String dsMerchantParametersB64) throws Exception { - RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParametersB64); + public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { + RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters); + + // Log útil para depurar + System.out.println(">> Redsys notify: order=" + notif.order + + " amountCents=" + notif.amountCents + + " currency=" + notif.currency + + " response=" + notif.response); Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order) .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order)); - if (!Objects.equals(p.getCurrency(), notif.currency)) { - throw new IllegalStateException("Divisa inesperada"); - } + // 🔹 Opción sencilla: sólo comprobar el importe if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) { - throw new IllegalStateException("Importe inesperado"); + throw new IllegalStateException("Importe inesperado: esperado=" + + p.getAmountTotalCents() + " recibido=" + notif.amountCents); } - // Idempotencia sencilla: si ya está capturado o reembolsado, no creamos otra - // transacción + // Si quieres, puedes hacer un check mínimamente decente de divisa numérica: + // (si usas siempre EUR) + /* + * if (!"978".equals(notif.currency)) { + * throw new IllegalStateException("Divisa Redsys inesperada: " + + * notif.currency); + * } + */ + + // Idempotencia simple: si ya está capturado o reembolsado, no hacemos nada if (p.getStatus() == PaymentStatus.CAPTURED || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED || p.getStatus() == PaymentStatus.REFUNDED) { @@ -101,9 +114,10 @@ public class PaymentService { PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.CAPTURE); - tx.setCurrency(p.getCurrency()); + tx.setCurrency(p.getCurrency()); // "EUR" tx.setAmountCents(notif.amountCents); - tx.setStatus(notif.authorized() ? PaymentTransactionStatus.SUCCEEDED + tx.setStatus(notif.authorized() + ? PaymentTransactionStatus.SUCCEEDED : PaymentTransactionStatus.FAILED); Object authCode = notif.raw.get("Ds_AuthorisationCode"); diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java index bd28f98..a1d4d8c 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java @@ -116,25 +116,26 @@ public class RedsysController { @GetMapping("/ko") @ResponseBody public ResponseEntity koGet() { - return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); + return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); } @PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @ResponseBody public ResponseEntity koPost(@RequestParam Map form) { // Podrías loguear 'form' si quieres ver qué manda Redsys - return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); + return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); } @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @ResponseBody - @Transactional + @jakarta.transaction.Transactional public String notifyRedsys(@RequestParam("Ds_Signature") String signature, @RequestParam("Ds_MerchantParameters") String merchantParameters) { try { paymentService.handleRedsysNotification(signature, merchantParameters); return "OK"; } catch (Exception e) { + e.printStackTrace(); // 👈 para ver el motivo del 500 en logs return "ERROR"; } } diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java index 9ceba5a..d6a4067 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java @@ -38,10 +38,12 @@ public class RedsysService { // ---------- RECORDS ---------- // Pedido a Redsys - public record PaymentRequest(String order, long amountCents, String description) {} + public record PaymentRequest(String order, long amountCents, String description) { + } // Payload para el formulario - public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {} + public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) { + } // ---------- MÉTODO PRINCIPAL (TARJETA) ---------- public FormPayload buildRedirectForm(PaymentRequest req) throws Exception { @@ -50,7 +52,7 @@ public class RedsysService { // ---------- NUEVO: MÉTODO PARA BIZUM ---------- public FormPayload buildRedirectFormBizum(PaymentRequest req) throws Exception { - return buildRedirectFormInternal(req, true); // true = Bizum (PAYMETHODS = z) + return buildRedirectFormInternal(req, true); // true = Bizum (PAYMETHODS = z) } // ---------- LÓGICA COMÚN ---------- @@ -58,7 +60,7 @@ public class RedsysService { ApiMacSha256 api = new ApiMacSha256(); api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents())); - api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros + api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode); api.setParameter("DS_MERCHANT_CURRENCY", currency); api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType); @@ -103,6 +105,7 @@ public class RedsysService { // ---------- STEP 4: Validar notificación ---------- public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64) throws Exception { + // 1) Decodificamos a mapa solo para leer campos Map mp = decodeMerchantParametersToMap(dsMerchantParametersB64); RedsysNotification notif = new RedsysNotification(mp); @@ -110,15 +113,21 @@ public class RedsysService { throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters"); } + // 2) Calculamos la firma esperada usando el B64 tal cual ApiMacSha256 api = new ApiMacSha256(); + // Esta línea es opcional para createMerchantSignatureNotif, pero no molesta: api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64); String expected = api.createMerchantSignatureNotif( secretKeyBase64, - api.decodeMerchantParameters(dsMerchantParametersB64) + dsMerchantParametersB64 // 👈 AQUÍ va el B64, NO el JSON ); + // 3) Comparamos en constante time, normalizando Base64 URL-safe if (!safeEqualsB64(dsSignature, expected)) { + System.out.println("Firma Redsys no válida"); + System.out.println("Ds_Signature (Redsys) = " + dsSignature); + System.out.println("Expected (local) = " + expected); throw new SecurityException("Firma Redsys no válida"); } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index ee004cd..f6b1c86 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -22,4 +22,4 @@ safekat.api.password=Safekat2024 redsys.environment=test redsys.urls.ok=http://localhost:8080/pagos/redsys/ok redsys.urls.ko=http://localhost:8080/pagos/redsys/ko -redsys.urls.notify=https://hns2jx2x-8080.uks1.devtunnels.ms/pagos/redsys/notify \ No newline at end of file +redsys.urls.notify=https://orological-sacrilegiously-lucille.ngrok-free.dev/pagos/redsys/notify \ No newline at end of file