feat: ochrona przed podwójnym składaniem zamówienia (order submit token)
Token CSRF w sesji zapobiega duplikowaniu zamówień przy wielokrotnym kliknięciu przycisku. Przy duplikacie przekierowanie do istniejącego zamówienia. JS naprawiony — nasłuch na submit formularza zamiast click. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@ composer test
|
||||
|
||||
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
||||
|
||||
Current suite: **805 tests, 2253 assertions**.
|
||||
Current suite: **810 tests, 2264 assertions**.
|
||||
|
||||
### Creating Updates
|
||||
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs.
|
||||
|
||||
@@ -3,6 +3,9 @@ namespace front\Controllers;
|
||||
|
||||
class ShopBasketController
|
||||
{
|
||||
private const ORDER_SUBMIT_TOKEN_SESSION_KEY = 'order-submit-token';
|
||||
private const ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY = 'order-submit-last-order-id';
|
||||
|
||||
public static $title = [
|
||||
'mainView' => 'Koszyk'
|
||||
];
|
||||
@@ -274,6 +277,7 @@ class ShopBasketController
|
||||
}
|
||||
|
||||
$client = \Shared\Helpers\Helpers::get_session( 'client' );
|
||||
$orderSubmitToken = $this->createOrderSubmitToken();
|
||||
|
||||
return \Shared\Tpl\Tpl::view( 'shop-basket/summary-view', [
|
||||
'lang_id' => $lang_id,
|
||||
@@ -284,12 +288,35 @@ class ShopBasketController
|
||||
'addresses' => ( new \Domain\Client\ClientRepository( $GLOBALS['mdb'] ) )->clientAddresses( (int)$client['id'] ),
|
||||
'settings' => $settings,
|
||||
'coupon' => \Shared\Helpers\Helpers::get_session( 'coupon' ),
|
||||
'basket_message' => \Shared\Helpers\Helpers::get_session( 'basket_message' )
|
||||
'basket_message' => \Shared\Helpers\Helpers::get_session( 'basket_message' ),
|
||||
'order_submit_token' => $orderSubmitToken
|
||||
] );
|
||||
}
|
||||
|
||||
public function basketSave()
|
||||
{
|
||||
$orderSubmitToken = (string)\Shared\Helpers\Helpers::get( 'order_submit_token', true );
|
||||
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] ) ? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] : 0;
|
||||
|
||||
if ( !$this->isValidOrderSubmitToken( $orderSubmitToken ) )
|
||||
{
|
||||
if ( $existingOrderId > 0 )
|
||||
{
|
||||
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
|
||||
if ( $existingOrderHash )
|
||||
{
|
||||
header( 'Location: /zamowienie/' . $existingOrderHash );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
|
||||
header( 'Location: /koszyk' );
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->consumeOrderSubmitToken();
|
||||
|
||||
$client = \Shared\Helpers\Helpers::get_session( 'client' );
|
||||
|
||||
if ( \Domain\Basket\BasketCalculator::checkProductQuantityInStock( \Shared\Helpers\Helpers::get_session( 'basket' ) ) )
|
||||
@@ -322,6 +349,7 @@ class ShopBasketController
|
||||
\Shared\Helpers\Helpers::get_session( 'basket_message' )
|
||||
) )
|
||||
{
|
||||
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY, (int)$order_id );
|
||||
\Shared\Helpers\Helpers::alert( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat' ) );
|
||||
\Shared\Helpers\Helpers::delete_session( 'basket' );
|
||||
\Shared\Helpers\Helpers::delete_session( 'basket-transport-method-id' );
|
||||
@@ -414,4 +442,45 @@ class ShopBasketController
|
||||
] );
|
||||
exit;
|
||||
}
|
||||
|
||||
private function createOrderSubmitToken()
|
||||
{
|
||||
$token = $this->generateOrderSubmitToken();
|
||||
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, $token );
|
||||
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY );
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function generateOrderSubmitToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
return bin2hex( random_bytes( 16 ) );
|
||||
}
|
||||
catch ( \Exception $exception )
|
||||
{
|
||||
return md5( uniqid( (string)mt_rand(), true ) );
|
||||
}
|
||||
}
|
||||
|
||||
private function isValidOrderSubmitToken( $token )
|
||||
{
|
||||
if ( !$token )
|
||||
return false;
|
||||
|
||||
$sessionToken = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] ) ? (string)$_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] : '';
|
||||
if ( !$sessionToken )
|
||||
return false;
|
||||
|
||||
if ( function_exists( 'hash_equals' ) )
|
||||
return hash_equals( $sessionToken, $token );
|
||||
|
||||
return $sessionToken === $token;
|
||||
}
|
||||
|
||||
private function consumeOrderSubmitToken()
|
||||
{
|
||||
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
|
||||
|
||||
---
|
||||
|
||||
## ver. 0.333 (2026-03-10) - Ochrona przed podwójnym składaniem zamówienia (order submit token)
|
||||
|
||||
- **NEW**: `ShopBasketController` — mechanizm tokenu CSRF chroniący przed podwójnym składaniem zamówienia (generowanie, walidacja, konsumpcja tokenu w sesji)
|
||||
- **NEW**: `ShopBasketController::basketSave()` — przy duplikacie przekierowanie do istniejącego zamówienia zamiast tworzenia kolejnego
|
||||
- **FIX**: `templates/shop-basket/summary-view.php` — JS nasłuchuje na `submit` formularza zamiast `click` przycisku (poprawna obsługa walidacji HTML5)
|
||||
- **FIX**: `templates/shop-basket/address-form.php` — ukryte pole `order_submit_token` z escape XSS
|
||||
- **TESTS**: `ShopBasketControllerTest` — testy konstruktora i zależności (5 testów)
|
||||
|
||||
---
|
||||
|
||||
## ver. 0.332 (2026-03-01) - API produktów: nowe pola new_to_date i additional_message
|
||||
|
||||
- **NEW**: `ProductRepository::getProductForApi()` — eksportuje 4 nowe pola: `new_to_date`, `additional_message` (int 0/1), `additional_message_required` (int 0/1), `additional_message_text`
|
||||
|
||||
@@ -23,10 +23,10 @@ composer test # standard
|
||||
## Aktualny stan
|
||||
|
||||
```text
|
||||
OK (807 tests, 2258 assertions)
|
||||
OK (810 tests, 2264 assertions)
|
||||
```
|
||||
|
||||
Zweryfikowano: 2026-03-01 (ver. 0.332)
|
||||
Zweryfikowano: 2026-03-10 (ver. 0.333)
|
||||
|
||||
## Konfiguracja
|
||||
|
||||
@@ -89,6 +89,8 @@ tests/
|
||||
| |-- ShopStatusesControllerTest.php
|
||||
| |-- ShopTransportControllerTest.php
|
||||
| `-- UsersControllerTest.php
|
||||
| |-- front/Controllers/
|
||||
| | `-- ShopBasketControllerTest.php
|
||||
| `-- api/
|
||||
| |-- ApiRouterTest.php
|
||||
| `-- Controllers/
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<? endif;?>
|
||||
<? if ( $this -> client ):?><div class="right"><? endif;?>
|
||||
<form class="form-horizontal" action="/zloz-zamowienie" method="POST" id="form-order">
|
||||
<input type="hidden" name="order_submit_token" value="<?= htmlspecialchars( (string)($this -> order_submit_token ?? ''), ENT_QUOTES, 'UTF-8' );?>">
|
||||
<? if ( !$this -> client ):?>
|
||||
<div class="form-group row">
|
||||
<div class="col-12">
|
||||
@@ -198,4 +199,4 @@
|
||||
$( '#address-' + address_id ).addClass( 'active' );
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -140,14 +140,16 @@
|
||||
</div>
|
||||
<div class="right">
|
||||
<?= \Shared\Tpl\Tpl::view( 'shop-basket/address-form', [
|
||||
'transport_method' => $this -> transport
|
||||
'transport_method' => $this -> transport,
|
||||
'order_submit_token' => $this -> order_submit_token
|
||||
] );?>
|
||||
</div>
|
||||
<? else:?>
|
||||
<?= \Shared\Tpl\Tpl::view( 'shop-basket/address-form', [
|
||||
'client' => $this -> client,
|
||||
'addresses' => $this -> addresses,
|
||||
'transport_method' => $this -> transport
|
||||
'transport_method' => $this -> transport,
|
||||
'order_submit_token' => $this -> order_submit_token
|
||||
] );?>
|
||||
<? endif;?>
|
||||
</div>
|
||||
@@ -156,17 +158,20 @@
|
||||
<? endif;?>
|
||||
</div>
|
||||
<script class="footer" type="text/javascript">
|
||||
document.getElementById('order-send').addEventListener('click', function() {
|
||||
var form = document.getElementById('form-order'); // Zastąp 'form-id' rzeczywistym ID Twojego formularza
|
||||
if (form.checkValidity()) {
|
||||
this.classList.add('loading-button');
|
||||
this.disabled = true;
|
||||
form.submit();
|
||||
} else {
|
||||
// Opcjonalnie: wywołaj funkcję reportValidity(), aby wyświetlić komunikaty o błędach formularza
|
||||
form.reportValidity();
|
||||
}
|
||||
});
|
||||
var orderForm = document.getElementById('form-order');
|
||||
var orderSendButton = document.getElementById('order-send');
|
||||
|
||||
if (orderForm && orderSendButton) {
|
||||
orderForm.addEventListener('submit', function(event) {
|
||||
if (orderSendButton.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
orderSendButton.classList.add('loading-button');
|
||||
orderSendButton.disabled = true;
|
||||
});
|
||||
}
|
||||
<? if ( $this -> settings['google_tag_manager_id'] ):?>
|
||||
dataLayer.push({ ecommerce: null });
|
||||
dataLayer.push({
|
||||
@@ -180,4 +185,4 @@
|
||||
}
|
||||
});
|
||||
<? endif;?>
|
||||
</script>
|
||||
</script>
|
||||
|
||||
44
tests/Unit/front/Controllers/ShopBasketControllerTest.php
Normal file
44
tests/Unit/front/Controllers/ShopBasketControllerTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
namespace Tests\Unit\front\Controllers;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use front\Controllers\ShopBasketController;
|
||||
use Domain\Order\OrderRepository;
|
||||
use Domain\PaymentMethod\PaymentMethodRepository;
|
||||
|
||||
class ShopBasketControllerTest extends TestCase
|
||||
{
|
||||
private $orderRepository;
|
||||
private $paymentMethodRepository;
|
||||
private $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->orderRepository = $this->createMock(OrderRepository::class);
|
||||
$this->paymentMethodRepository = $this->createMock(PaymentMethodRepository::class);
|
||||
$this->controller = new ShopBasketController($this->orderRepository, $this->paymentMethodRepository);
|
||||
}
|
||||
|
||||
public function testConstructorAcceptsRepositories(): void
|
||||
{
|
||||
$controller = new ShopBasketController($this->orderRepository, $this->paymentMethodRepository);
|
||||
$this->assertInstanceOf(ShopBasketController::class, $controller);
|
||||
}
|
||||
|
||||
public function testHasCheckoutMethods(): void
|
||||
{
|
||||
$this->assertTrue(method_exists($this->controller, 'summaryView'));
|
||||
$this->assertTrue(method_exists($this->controller, 'basketSave'));
|
||||
}
|
||||
|
||||
public function testConstructorRequiresDependencies(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(ShopBasketController::class);
|
||||
$constructor = $reflection->getConstructor();
|
||||
$params = $constructor->getParameters();
|
||||
|
||||
$this->assertCount(2, $params);
|
||||
$this->assertEquals('Domain\Order\OrderRepository', $params[0]->getType()->getName());
|
||||
$this->assertEquals('Domain\PaymentMethod\PaymentMethodRepository', $params[1]->getType()->getName());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user