From 3a555e3b249ed17c884b862ebc9bf3c579e6ec08 Mon Sep 17 00:00:00 2001 From: Przemek Zatorski Date: Wed, 23 Sep 2026 13:22:20 +0200 Subject: [PATCH] AutoPay wg rozwiazania z Gieldy Komunalnej - ITN: XML w base64 (pole transactions), bezpieczny parser (bez XXE), podpis ze stalej listy pol, zgodnosc ServiceID, kwoty i waluty, idempotencja, podpisana odpowiedz XML CONFIRMED/NOTCONFIRMED. - Start transakcji podpisanym formularzem POST ze strony /platnosc/:orderId zamiast linku GET z podpisem w adresie; tytul przelewu w ASCII. - Losowy OrderID (UUID) zamiast Math.random. - Adres ITN do wpisania w panelu AutoPay widoczny w panelu admina. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../payment/AutoPayItnParser.java | 143 ++++++++++++ .../payment/AutoPayService.java | 111 ++++------ .../payment/AutoPayStartForm.java | 16 ++ .../promotion/AutoPayItnController.java | 50 ++--- .../promotion/AutoPayItnService.java | 177 +++++++++++++++ .../promotion/PromotionController.java | 6 + .../promotion/PromotionService.java | 63 +++--- .../promotion/AutoPayItnServiceTest.java | 205 ++++++++++++++++++ .../promotion/PaymentSecurityTest.java | 25 +-- frontend/src/App.tsx | 3 +- frontend/src/AuthPages.tsx | 72 +++++- frontend/src/admin/promotion.tsx | 2 +- frontend/src/routes.ts | 7 + frontend/tests/routing.spec.ts | 3 +- 14 files changed, 731 insertions(+), 152 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/payment/AutoPayItnParser.java create mode 100644 backend/src/main/java/pl/polskalokalnie/payment/AutoPayStartForm.java create mode 100644 backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java create mode 100644 backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java diff --git a/backend/src/main/java/pl/polskalokalnie/payment/AutoPayItnParser.java b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayItnParser.java new file mode 100644 index 0000000..8fb8a70 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayItnParser.java @@ -0,0 +1,143 @@ +package pl.polskalokalnie.payment; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * Parser notyfikacji ITN (XML przekazywany w polu {@code transactions} zakodowany base64). + * + * Dokument pochodzi z sieci, wiec parser jest twardo zabezpieczony przed XXE (wylaczone DTD + * i encje zewnetrzne) - inaczej spreparowana notyfikacja mogla by czytac pliki serwera albo + * wymuszac zadania wychodzace (SSRF). + */ +public final class AutoPayItnParser { + + private AutoPayItnParser() { + } + + /** Pojedyncza transakcja z notyfikacji wraz z polami wchodzacymi do podpisu. */ + public record ItnTransaction( + String orderId, + String remoteId, + String amount, + String currency, + String gatewayId, + String paymentDate, + String paymentStatus, + String paymentStatusDetails + ) { + public boolean success() { + return "SUCCESS".equalsIgnoreCase(paymentStatus); + } + + /** Pola w kolejnosci, w jakiej wchodza do podpisu notyfikacji. */ + List signedValues() { + List values = new ArrayList<>(); + values.add(orderId); + values.add(remoteId); + values.add(amount); + values.add(currency); + values.add(gatewayId); + values.add(paymentDate); + values.add(paymentStatus); + values.add(paymentStatusDetails); + return values; + } + } + + /** Zawartosc notyfikacji: identyfikator serwisu, lista transakcji i podpis calosci. */ + public record Itn(String serviceId, List transactions, String hash) { + + /** Wartosci podpisu calej notyfikacji: serviceID, a potem pola kolejnych transakcji. */ + public List signedValues() { + List values = new ArrayList<>(); + values.add(serviceId); + for (ItnTransaction tx : transactions) { + values.addAll(tx.signedValues()); + } + return values; + } + } + + public static Itn parse(String xml) { + Document doc = parseSecurely(xml); + Element root = doc.getDocumentElement(); + + String serviceId = childText(root, "serviceID"); + // Podpis jest bezposrednim dzieckiem korzenia (nie elementem transakcji). + String hash = childText(root, "hash"); + + List transactions = new ArrayList<>(); + for (Element tx : elementsByTag(root, "transaction")) { + transactions.add(new ItnTransaction( + childText(tx, "orderID"), + childText(tx, "remoteID"), + childText(tx, "amount"), + childText(tx, "currency"), + childText(tx, "gatewayID"), + childText(tx, "paymentDate"), + childText(tx, "paymentStatus"), + childText(tx, "paymentStatusDetails"))); + } + if (serviceId == null || transactions.isEmpty()) { + throw invalid(); + } + return new Itn(serviceId, transactions, hash); + } + + private static Document parseSecurely(String xml) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } catch (ParserConfigurationException e) { + throw new IllegalStateException("Nie mozna skonfigurowac bezpiecznego parsera XML", e); + } catch (Exception e) { + throw invalid(); + } + } + + private static ResponseStatusException invalid() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN"); + } + + /** Tekst bezposredniego dziecka o danej nazwie (bez schodzenia w glab drzewa). */ + private static String childText(Element parent, String tag) { + NodeList children = parent.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE && tag.equals(node.getNodeName())) { + String text = node.getTextContent(); + return text == null || text.isBlank() ? null : text.trim(); + } + } + return null; + } + + private static List elementsByTag(Element root, String tag) { + List result = new ArrayList<>(); + NodeList nodes = root.getElementsByTagName(tag); + for (int i = 0; i < nodes.getLength(); i++) { + result.add((Element) nodes.item(i)); + } + return result; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java index 6312f8f..3b5e05c 100644 --- a/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java +++ b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java @@ -2,23 +2,19 @@ package pl.polskalokalnie.payment; import java.math.BigDecimal; import java.math.RoundingMode; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import org.springframework.stereotype.Service; /** - * Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media). + * Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media) - wg rozwiazania dzialajacego + * produkcyjnie w Gieldzie Komunalnej. * - * Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych - * separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN. - * - * UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja - * AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac. + * Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy + * parametrow, rozdzielone separatorem, a na koncu doklejany jest klucz konfiguracyjny. Puste pola + * opcjonalne sa POMIJANE RAZEM Z SEPARATOREM. Przyklad z dokumentacji: ServiceID=2, OrderID=100, + * Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2"). */ @Service public class AutoPayService { @@ -26,66 +22,38 @@ public class AutoPayService { private static final String PROD_URL = "https://pay.autopay.eu/payment"; private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment"; - /** Adres paywalla z podpisanymi parametrami transakcji. */ - public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount, - String description, String customerEmail, String returnUrl) { - String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); - - // Kolejnosc parametrow ma znaczenie dla podpisu. - Map params = new LinkedHashMap<>(); - params.put("ServiceID", cfg.getServiceId()); - params.put("OrderID", orderId); - params.put("Amount", amountStr); - params.put("Currency", "PLN"); - params.put("Description", description == null ? "" : description); - params.put("CustomerEmail", customerEmail == null ? "" : customerEmail); - if (returnUrl != null && !returnUrl.isBlank()) { - params.put("ReturnURL", returnUrl); - } - - String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey()); - params.put("Hash", hash); - - String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL; - StringBuilder query = new StringBuilder(); - for (Map.Entry e : params.entrySet()) { - if (query.length() > 0) { - query.append('&'); - } - query.append(enc(e.getKey())).append('=').append(enc(e.getValue())); - } - return base + "?" + query; + /** + * Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje + * (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu. + * Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii. + */ + public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount, + String description, String customerEmail) { + String amountStr = formatAmount(amount); + String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description, + customerEmail == null ? "" : customerEmail)); + String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL; + return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description, + customerEmail, hash); } - /** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */ - public boolean verifyItn(Map params, PaymentConfig cfg) { - if (params == null || cfg == null || cfg.getSecretKey() == null) { - return false; - } - String provided = params.get("Hash"); - if (provided == null || provided.isBlank()) { - return false; - } - List values = new ArrayList<>(); - for (Map.Entry e : params.entrySet()) { - if (!"Hash".equalsIgnoreCase(e.getKey())) { - values.add(e.getValue()); - } - } - String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey()); - return MessageDigest.isEqual( - expected.getBytes(StandardCharsets.UTF_8), - provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); + /** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */ + public static String formatAmount(BigDecimal amount) { + return amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); } - /** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */ - public String computeHash(List values, String separator, String key) { - List parts = new ArrayList<>(values); - parts.add(key); - String data = String.join(separator, parts); + /** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */ + public String hash(PaymentConfig cfg, List values) { + StringBuilder sb = new StringBuilder(); + for (String value : values) { + if (value == null || value.isEmpty()) { + continue; // puste pole opcjonalne: pomijamy bez separatora + } + sb.append(value).append(cfg.getHashSeparator()); + } + sb.append(cfg.getSecretKey()); try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(bytes.length * 2); for (byte b : bytes) { hex.append(Character.forDigit((b >> 4) & 0xF, 16)); @@ -97,7 +65,16 @@ public class AutoPayService { } } - private static String enc(String value) { - return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + /** + * Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy + * sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow. + */ + public static boolean hashMatches(String expected, String actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual( + expected.toLowerCase().getBytes(StandardCharsets.UTF_8), + actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); } } diff --git a/backend/src/main/java/pl/polskalokalnie/payment/AutoPayStartForm.java b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayStartForm.java new file mode 100644 index 0000000..0813fcb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayStartForm.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.payment; + +/** + * Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl} + * (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}). + */ +public record AutoPayStartForm( + String gatewayUrl, + String serviceId, + String orderId, + String amount, + String description, + String customerEmail, + String hash +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java index eb23588..2d4585c 100644 --- a/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java +++ b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java @@ -1,57 +1,35 @@ package pl.polskalokalnie.promotion; -import java.util.Map; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import pl.polskalokalnie.payment.AutoPayService; -import pl.polskalokalnie.payment.PaymentConfig; -import pl.polskalokalnie.payment.PaymentConfigService; /** * Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification). - * Weryfikuje podpis i oznacza zamowienie jako oplacone. * - * UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje - * parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie - * i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay. + * Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym + * kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu + * AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN" + * (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn */ @RestController @RequestMapping("/api/payments/autopay") public class AutoPayItnController { - private final AutoPayService autoPayService; - private final PaymentConfigService paymentConfigService; - private final PromotionService promotionService; + private final AutoPayItnService itnService; - public AutoPayItnController(AutoPayService autoPayService, - PaymentConfigService paymentConfigService, - PromotionService promotionService) { - this.autoPayService = autoPayService; - this.paymentConfigService = paymentConfigService; - this.promotionService = promotionService; + public AutoPayItnController(AutoPayItnService itnService) { + this.itnService = itnService; } - @PostMapping("/itn") - public ResponseEntity itn(@RequestParam Map params) { - PaymentConfig cfg = paymentConfigService.get(); - if (!autoPayService.verifyItn(params, cfg)) { - return ResponseEntity.badRequest().body("ERROR: invalid hash"); - } - String orderId = params.get("OrderID"); - if (orderId != null && isSuccess(params)) { - promotionService.markPaidByExternalId(orderId, params.get("Amount")); - } - return ResponseEntity.ok("OK"); - } - - // Oplacone jest tylko zamowienie z jawnym, koncowym statusem SUCCESS. PENDING to platnosc - // rozpoczeta, ktora moze jeszcze sie nie udac, a brak statusu nie moze oznaczac sukcesu. - static boolean isSuccess(Map params) { - String status = params.getOrDefault("PaymentStatus", - params.getOrDefault("Status", params.get("paymentStatus"))); - return status != null && status.trim().equalsIgnoreCase("SUCCESS"); + /** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */ + @PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE) + public ResponseEntity itn(@RequestParam("transactions") String transactions) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_XML) + .body(itnService.handle(transactions)); } } diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java new file mode 100644 index 0000000..eb8eb1e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java @@ -0,0 +1,177 @@ +package pl.polskalokalnie.promotion; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.payment.AutoPayItnParser; +import pl.polskalokalnie.payment.AutoPayService; +import pl.polskalokalnie.payment.PaymentConfig; +import pl.polskalokalnie.payment.PaymentConfigService; + +/** + * Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone + * (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej). + * + * Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED): + * 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce), + * 2. zgodnosc identyfikatora serwisu, + * 3. istnienie zamowienia o danym OrderID, + * 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota), + * 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz. + * + * AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy. + */ +@Service +public class AutoPayItnService { + + private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class); + private static final String CONFIRMED = "CONFIRMED"; + private static final String NOT_CONFIRMED = "NOTCONFIRMED"; + private static final String NL = "\n"; + + private final PaymentConfigService paymentConfigService; + private final AutoPayService autoPayService; + private final PromotionOrderRepository orderRepository; + private final PromotionService promotionService; + + public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService, + PromotionOrderRepository orderRepository, PromotionService promotionService) { + this.paymentConfigService = paymentConfigService; + this.autoPayService = autoPayService; + this.orderRepository = orderRepository; + this.promotionService = promotionService; + } + + /** + * Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay. + * + * @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64) + */ + @Transactional + public String handle(String transactionsBase64) { + PaymentConfig cfg = paymentConfigService.get(); + AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64)); + + boolean authentic = verifySignature(cfg, itn); + if (!authentic) { + // Bez szczegolow - to potencjalna proba podszycia sie pod bramke. + log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu"); + } + + List confirmations = new ArrayList<>(); + for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) { + String result = authentic ? process(tx) : NOT_CONFIRMED; + confirmations.add(new Confirmation(tx.orderId(), result)); + } + return response(cfg, confirmations); + } + + private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) { + if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) { + return false; + } + String expected = autoPayService.hash(cfg, itn.signedValues()); + return AutoPayService.hashMatches(expected, itn.hash()); + } + + private String process(AutoPayItnParser.ItnTransaction tx) { + Optional found = orderRepository.findByExternalId(tx.orderId()); + if (found.isEmpty()) { + log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId()); + return NOT_CONFIRMED; + } + PromotionOrder order = found.get(); + + if (!tx.success()) { + // PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca), + // ale niczego nie naliczamy. + if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) { + order.setStatus(OrderStatus.FAILED); + orderRepository.save(order); + } + return CONFIRMED; + } + + // Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana, + // ale nie nalicza drugi raz kredytow ani wyroznienia. + if (order.getStatus() == OrderStatus.PAID) { + return CONFIRMED; + } + + if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) { + log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}", + tx.orderId(), order.getAmount(), tx.amount(), tx.currency()); + return NOT_CONFIRMED; + } + + promotionService.applyPaid(order); + log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId()); + return CONFIRMED; + } + + /** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */ + static boolean amountMatches(BigDecimal expected, String actual) { + if (expected == null || actual == null) { + return false; + } + try { + return expected.compareTo(new BigDecimal(actual)) == 0; + } catch (NumberFormatException e) { + return false; + } + } + + private record Confirmation(String orderId, String result) { + } + + /** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */ + private String response(PaymentConfig cfg, List confirmations) { + String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim(); + // Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation. + List values = new ArrayList<>(); + values.add(serviceId); + for (Confirmation c : confirmations) { + values.add(c.orderId()); + values.add(c.result()); + } + String hash = autoPayService.hash(cfg, values); + + StringBuilder entries = new StringBuilder(); + for (Confirmation c : confirmations) { + entries.append(" ").append(NL) + .append(" ").append(escape(c.orderId())).append("").append(NL) + .append(" ").append(c.result()).append("").append(NL) + .append(" ").append(NL); + } + + return "" + NL + + "" + NL + + " " + escape(serviceId) + "" + NL + + " " + NL + + entries + + " " + NL + + " " + hash + "" + NL + + ""; + } + + private static String decode(String base64) { + try { + return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN"); + } + } + + private static String escape(String value) { + return value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java index 57f0419..b63d069 100644 --- a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.payment.AutoPayStartForm; /** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */ @RestController @@ -51,6 +52,11 @@ public class PromotionController { return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id)); } + @GetMapping("/order/{id}/autopay-form") + public AutoPayStartForm autoPayForm(@PathVariable Long id, Authentication authentication) { + return promotionService.autoPayForm(authentication.getName(), id); + } + public record CreditsResponse(int credits) { } diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java index a5a3192..3e89278 100644 --- a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java @@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.listing.ListingRepository; import pl.polskalokalnie.listing.PropertyListing; import pl.polskalokalnie.payment.AutoPayService; +import pl.polskalokalnie.payment.AutoPayStartForm; import pl.polskalokalnie.payment.PaymentConfig; import pl.polskalokalnie.payment.PaymentConfigService; import pl.polskalokalnie.user.AppUser; @@ -27,7 +28,6 @@ public class PromotionService { private final ListingRepository listingRepository; private final PaymentConfigService paymentConfigService; private final AutoPayService autoPayService; - private final String baseUrl; private final boolean autoConfirmPayments; public PromotionService(PromotionPlanRepository planRepository, @@ -37,7 +37,6 @@ public class PromotionService { ListingRepository listingRepository, PaymentConfigService paymentConfigService, AutoPayService autoPayService, - @Value("${app.public-base-url:http://localhost}") String baseUrl, @Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) { this.planRepository = planRepository; this.packageRepository = packageRepository; @@ -46,7 +45,6 @@ public class PromotionService { this.listingRepository = listingRepository; this.paymentConfigService = paymentConfigService; this.autoPayService = autoPayService; - this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); this.autoConfirmPayments = autoConfirmPayments; } @@ -102,10 +100,38 @@ public class PromotionService { applyPaid(order); return CheckoutResponse.paid(order.getId()); } - String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId(); - String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(), - order.getItemName(), email, returnUrl); - return CheckoutResponse.redirect(order.getId(), url); + // Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST + // do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer. + return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId()); + } + + /** Podpisany formularz startu transakcji AutoPay dla oczekujacego zamowienia wlasciciela. */ + public AutoPayStartForm autoPayForm(String email, Long orderId) { + PromotionOrder order = orderRepository.findById(orderId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje")); + if (!order.getUserEmail().equalsIgnoreCase(email)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia"); + } + if (order.getStatus() != OrderStatus.PENDING) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "To zamówienie nie oczekuje już na płatność"); + } + PaymentConfig cfg = paymentConfigService.get(); + if (!cfg.isLive()) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Płatności są chwilowo niedostępne. Spróbuj ponownie później."); + } + return autoPayService.startForm(cfg, order.getExternalId(), order.getAmount(), + paymentDescription(order), order.getUserEmail()); + } + + /** + * Tytul przelewu. Dokumentacja AutoPay ogranicza pole do 79 znakow i dopuszcza znaki + * alfanumeryczne, {@code .:-,} i spacje - budujemy go sami, bez polskich znakow z nazwy planu. + */ + static String paymentDescription(PromotionOrder order) { + return order.getKind() == PromotionKind.PLAN + ? "Promowanie ogloszenia nr " + order.getListingId() + : "Pakiet promowan, zamowienie nr " + order.getId(); } @Transactional @@ -133,26 +159,6 @@ public class PromotionService { orderRepository.save(order); } - // Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu. Kwota z powiadomienia musi - // zgadzac sie z zamowieniem - inaczej wplata nie pokrywa tego, co mamy wydac. - @Transactional - public void markPaidByExternalId(String externalId, String paidAmount) { - orderRepository.findByExternalId(externalId) - .filter(order -> amountMatches(order.getAmount(), paidAmount)) - .ifPresent(this::applyPaid); - } - - static boolean amountMatches(BigDecimal expected, String paidAmount) { - if (expected == null || paidAmount == null || paidAmount.isBlank()) { - return false; - } - try { - return expected.compareTo(new BigDecimal(paidAmount.trim())) == 0; - } catch (NumberFormatException ex) { - return false; - } - } - @Transactional public int promoteWithCredit(String email, CreditPromoteRequest request) { AppUser user = userRepository.findByEmailIgnoreCase(email) @@ -308,7 +314,8 @@ public class PromotionService { return id; } + /** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */ private static String generateExternalId() { - return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000)); + return java.util.UUID.randomUUID().toString().replace("-", ""); } } diff --git a/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java b/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java new file mode 100644 index 0000000..df1970a --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java @@ -0,0 +1,205 @@ +package pl.polskalokalnie.promotion; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.payment.AutoPayService; +import pl.polskalokalnie.payment.PaymentConfig; +import pl.polskalokalnie.payment.PaymentConfigService; + +/** + * Zabezpieczenia obslugi notyfikacji ITN (scenariusze przeniesione z Gieldy Komunalnej): + * podszycie sie pod bramke, zanizenie kwoty, podmiana waluty, statusy nie-koncowe i powtorki. + */ +class AutoPayItnServiceTest { + + private static final String SERVICE_ID = "218886"; + private static final String KEY = "sekretny-klucz-hash"; + private static final String ORDER_ID = "abc123"; + + private final AutoPayService autoPayService = new AutoPayService(); + private PromotionOrderRepository orderRepository; + private PromotionService promotionService; + private AutoPayItnService service; + private PaymentConfig cfg; + private PromotionOrder order; + + @BeforeEach + void setUp() { + orderRepository = mock(PromotionOrderRepository.class); + promotionService = mock(PromotionService.class); + PaymentConfigService configService = mock(PaymentConfigService.class); + cfg = new PaymentConfig(); + cfg.setServiceId(SERVICE_ID); + cfg.setSecretKey(KEY); + cfg.setEnabled(true); + when(configService.get()).thenReturn(cfg); + service = new AutoPayItnService(configService, autoPayService, orderRepository, promotionService); + + order = new PromotionOrder(); + order.setExternalId(ORDER_ID); + order.setAmount(new BigDecimal("49.99")); + when(orderRepository.findByExternalId(ORDER_ID)).thenReturn(Optional.of(order)); + } + + private String itn(String amount, String currency, String status) { + String hash = autoPayService.hash(cfg, List.of( + SERVICE_ID, ORDER_ID, "remote-1", amount, currency, "21", "20260827120000", status, "OK")); + return encode(xml(amount, currency, status, hash)); + } + + private String xml(String amount, String currency, String status, String hash) { + return "" + + "" + + "" + SERVICE_ID + "" + + "" + + "" + ORDER_ID + "" + + "remote-1" + + "" + amount + "" + + "" + currency + "" + + "21" + + "20260827120000" + + "" + status + "" + + "OK" + + "" + + "" + hash + "" + + ""; + } + + private static String encode(String xml) { + return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(String input) throws Exception { + byte[] d = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : d) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + @Test + void hashFollowsDocumentationVector() throws Exception { + PaymentConfig doc = new PaymentConfig(); + doc.setSecretKey("2test2"); + assertThat(autoPayService.hash(doc, List.of("2", "100", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2")); + // Puste pole opcjonalne pomijane razem z separatorem. + assertThat(autoPayService.hash(doc, List.of("2", "100", "", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2")); + } + + @Test + void confirmsValidSuccessNotification() { + String response = service.handle(itn("49.99", "PLN", "SUCCESS")); + + assertThat(response).contains("CONFIRMED"); + verify(promotionService).applyPaid(order); + } + + @Test + void rejectsForgedSignature() { + String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", "0000000000000000"))); + + assertThat(response).contains("NOTCONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void rejectsSignatureFromDifferentKey() { + PaymentConfig attacker = new PaymentConfig(); + attacker.setSecretKey("klucz-atakujacego"); + String hash = autoPayService.hash(attacker, List.of( + SERVICE_ID, ORDER_ID, "remote-1", "49.99", "PLN", "21", "20260827120000", "SUCCESS", "OK")); + + String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", hash))); + + assertThat(response).contains("NOTCONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void pendingPaymentIsAcknowledgedButNotPaid() { + // Regresja: PENDING byl traktowany jak oplacona platnosc. + String response = service.handle(itn("49.99", "PLN", "PENDING")); + + assertThat(response).contains("CONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void failedPaymentMarksOrderFailed() { + service.handle(itn("49.99", "PLN", "FAILURE")); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.FAILED); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void rejectsUnderpayment() { + String response = service.handle(itn("0.01", "PLN", "SUCCESS")); + + assertThat(response).contains("NOTCONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void rejectsWrongCurrency() { + String response = service.handle(itn("49.99", "EUR", "SUCCESS")); + + assertThat(response).contains("NOTCONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void isIdempotentOnRetry() { + service.handle(itn("49.99", "PLN", "SUCCESS")); + order.setStatus(OrderStatus.PAID); // applyPaid jest zamockowane - odwzorowujemy skutek + + String second = service.handle(itn("49.99", "PLN", "SUCCESS")); + + assertThat(second).contains("CONFIRMED"); + verify(promotionService, times(1)).applyPaid(order); + } + + @Test + void rejectsUnknownOrderId() { + when(orderRepository.findByExternalId(ORDER_ID)).thenReturn(Optional.empty()); + + String response = service.handle(itn("49.99", "PLN", "SUCCESS")); + + assertThat(response).contains("NOTCONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void rejectsNotificationsWhenGatewayIsNotConfigured() { + String valid = itn("49.99", "PLN", "SUCCESS"); + cfg.setEnabled(false); + + assertThat(service.handle(valid)).contains("NOTCONFIRMED"); + verify(promotionService, never()).applyPaid(any()); + } + + @Test + void refusesXmlWithExternalEntities() { + String xxe = "]>" + + "&e;"; + + assertThatThrownBy(() -> service.handle(encode(xxe))).isInstanceOf(ResponseStatusException.class); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java b/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java index 5d15cfa..566064a 100644 --- a/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java +++ b/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java @@ -9,7 +9,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.math.BigDecimal; -import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -31,7 +30,7 @@ class PaymentSecurityTest { private PromotionService service(boolean autoConfirm) { return new PromotionService(mock(PromotionPlanRepository.class), packageRepository, orderRepository, userRepository, mock(ListingRepository.class), paymentConfigService, mock(AutoPayService.class), - "http://localhost", autoConfirm); + autoConfirm); } @Test @@ -65,20 +64,12 @@ class PaymentSecurityTest { } @Test - void itnCountsOnlyExplicitSuccessAsPaid() { - // Regresja: PENDING oraz brak statusu byly traktowane jak oplacona platnosc. - assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "SUCCESS"))).isTrue(); - assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "PENDING"))).isFalse(); - assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "FAILURE"))).isFalse(); - assertThat(AutoPayItnController.isSuccess(Map.of("OrderID", "PL-1"))).isFalse(); - } - - @Test - void itnAmountMustMatchOrder() { - assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "49.99")).isTrue(); - assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "49.990")).isTrue(); - assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "0.01")).isFalse(); - assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), null)).isFalse(); - assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "abc")).isFalse(); + void paymentDescriptionUsesOnlyCharactersAllowedByGateway() { + PromotionOrder order = new PromotionOrder(); + order.setKind(PromotionKind.PLAN); + order.setListingId(42L); + assertThat(PromotionService.paymentDescription(order)) + .isEqualTo("Promowanie ogloszenia nr 42") + .matches("[A-Za-z0-9 .:,-]{1,79}"); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 740588a..fdfc050 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import { AdminPromotionView } from './admin/promotion'; import { AdminSettingsView } from './admin/settings'; import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal'; import { SiteFooter } from './Footer'; -import { ActivationPage, PasswordResetPage, PhoneOtpModal } from './AuthPages'; +import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; @@ -3668,6 +3668,7 @@ function App() { {/* Trasy wymagajace zalogowania */} }> + } /> } /> } /> (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.'); @@ -65,6 +65,76 @@ export function ActivationPage() { ); } +type AutoPayStartForm = { + gatewayUrl: string; + serviceId: string; + orderId: string; + amount: string; + description: string; + customerEmail: string | null; + hash: string; +}; + +// /platnosc/:orderId - przekierowanie do bramki AutoPay. Transakcje startuje sie formularzem POST, +// wiec podpisane pola pobieramy z API i wysylamy automatycznie. Podpis nie trafia do paska adresu, +// historii przegladarki ani naglowka Referer. +export function PaymentRedirectPage() { + const { orderId } = useParams<{ orderId: string }>(); + const [form, setForm] = useState(null); + const [error, setError] = useState(null); + const formRef = useRef(null); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) { + return; + } + ran.current = true; + apiFetch(`/promotion/order/${encodeURIComponent(orderId ?? '')}/autopay-form`) + .then(setForm) + .catch((e) => setError(errMsg(e))); + }, [orderId]); + + // Automatyczna wysylka formularza po pobraniu podpisanych pol. + useEffect(() => { + if (form) { + formRef.current?.submit(); + } + }, [form]); + + if (error) { + return ( + +
!
+

Nie udało się rozpocząć płatności

+

{error}

+ Wróć do moich ofert +
+ ); + } + + return ( + +

Przekierowanie do płatności

+

+ {form ? 'Za chwilę nastąpi przekierowanie do AutoPay...' : 'Przygotowujemy bezpieczną płatność...'} +

+ {form && ( +
+ + + + + {form.customerEmail && } + + {/* Zapasowy przycisk, gdyby automatyczna wysylka nie zadzialala. */} + +
+ )} +
+ ); +} + // /reset-hasla/:token - ustawienie nowego hasla. export function PasswordResetPage() { const { token } = useParams<{ token: string }>(); diff --git a/frontend/src/admin/promotion.tsx b/frontend/src/admin/promotion.tsx index 90bd816..8125e1e 100644 --- a/frontend/src/admin/promotion.tsx +++ b/frontend/src/admin/promotion.tsx @@ -217,7 +217,7 @@ export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {

Bramka szybkich płatności - AutoPay

-

Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny.

+

Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny. Adres ITN w panelu AutoPay (Ustawienia serwisu → Konfiguracja techniczna → Edytuj): {window.location.origin}/api/payments/autopay/itn