---
phase: 04-csrf-protection
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - autoload/Shared/Security/CsrfToken.php
  - autoload/admin/Support/Forms/FormRequestHandler.php
  - admin/templates/components/form-edit.php
  - admin/templates/site/unlogged-layout.php
  - admin/templates/users/user-2fa.php
  - autoload/admin/App.php
  - tests/Unit/Shared/Security/CsrfTokenTest.php
autonomous: true
---

<objective>
## Goal
Dodać ochronę CSRF do wszystkich state-changing POST endpointów panelu administracyjnego.

## Purpose
Brak tokenów CSRF umożliwia atakującemu wymuszenie na zalogowanym adminie wykonania akcji (zapis/usuń/aktualizuj) poprzez spreparowany link lub stronę. Jest to podatność MEDIUM wg concerns.md.

## Output
- Nowa klasa `\Shared\Security\CsrfToken` z generowaniem i walidacją tokenu
- Integracja w `FormRequestHandler` (walidacja) + `form-edit.php` (token w formularzu)
- Integracja w formularzach logowania i 2FA
- Test jednostkowy dla CsrfToken
</objective>

<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md

## Source Files
@autoload/admin/Support/Forms/FormRequestHandler.php
@admin/templates/components/form-edit.php
@admin/templates/site/unlogged-layout.php
@admin/templates/users/user-2fa.php
@autoload/admin/App.php
</context>

<skills>
## Required Skills (from SPECIAL-FLOWS.md)

| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| /feature-dev | required | Przed APPLY — nowe klasy, zmiany wielu plików | ○ |

**BLOCKING:** /feature-dev musi być załadowany przed /paul:apply.

## Skill Invocation Checklist
- [ ] /feature-dev loaded (uruchom przed apply)
</skills>

<acceptance_criteria>

## AC-1: Formularz edycji chroni przed CSRF
```gherkin
Given admin jest zalogowany i otwiera dowolny formularz edycji
When formularz jest renderowany
Then zawiera ukryte pole _csrf_token z aktualnym tokenem z sesji
```

## AC-2: Zapis przez formularz bez tokenu jest odrzucany
```gherkin
Given admin endpoint odbiera POST z FormRequestHandler
When żądanie nie zawiera _csrf_token lub token jest nieprawidłowy
Then handleSubmit() zwraca ['success' => false, 'errors' => ['csrf' => '...']]
  And żadna operacja na danych nie jest wykonywana
```

## AC-3: Formularz logowania zawiera CSRF token
```gherkin
Given niezalogowany użytkownik otwiera stronę logowania /admin/
When strona jest renderowana
Then formularz logowania zawiera ukryte pole _csrf_token
```

## AC-4: special_actions waliduje CSRF dla user-logon i user-2fa-verify
```gherkin
Given żądanie POST trafia do special_actions()
When s-action to 'user-logon' lub 'user-2fa-verify'
Then token jest walidowany przed przetworzeniem danych
  And brak tokenu kończy się przekierowaniem z komunikatem błędu
```

## AC-5: Token jest unikalny per sesja
```gherkin
Given sesja PHP jest aktywna
When CsrfToken::getToken() jest wywołany wielokrotnie
Then zwraca ten sam token w ramach jednej sesji
  And token ma co najmniej 64 znaki hex (32 bajty)
```

</acceptance_criteria>

<tasks>

<task type="auto">
  <name>Task 1: Utwórz klasę CsrfToken + test jednostkowy</name>
  <files>autoload/Shared/Security/CsrfToken.php, tests/Unit/Shared/Security/CsrfTokenTest.php</files>
  <action>
    Utwórz `autoload/Shared/Security/CsrfToken.php` z namespace `\Shared\Security`:

    ```php
    class CsrfToken {
        const SESSION_KEY = 'csrf_token';

        public static function getToken(): string
        // Jeśli nie ma tokenu w sesji — generuje bin2hex(random_bytes(32)) i zapisuje
        // Zwraca istniejący lub nowy token

        public static function validate(string $token): bool
        // Pobiera token z sesji, używa hash_equals() dla bezpiecznego porównania
        // Zwraca false jeśli sesja nie ma tokenu lub tokeny się różnią

        public static function regenerate(): void
        // Generuje nowy token i nadpisuje w sesji
        // Używać po udanym logowaniu (session fixation prevention)
    }
    ```

    Utwórz `tests/Unit/Shared/Security/CsrfTokenTest.php`:
    - test getToken() zwraca string długości 64
    - test getToken() zwraca ten sam token przy kolejnym wywołaniu (idempotency)
    - test validate() zwraca true dla poprawnego tokenu
    - test validate() zwraca false dla pustego stringa
    - test validate() zwraca false dla błędnego tokenu
    - test regenerate() zmienia token

    Uwaga PHP < 8.0: brak `match`, brak named arguments, brak union types.
    Użyj `isset($_SESSION[...])` zamiast `??` na zmiennych sesji w metodach static (sesja musi być started przed wywołaniem).
  </action>
  <verify>./test.ps1 tests/Unit/Shared/Security/CsrfTokenTest.php</verify>
  <done>AC-5 satisfied: token unikalny, 64 znaki, idempotentny</done>
</task>

<task type="auto">
  <name>Task 2: Integracja CSRF w formularzach edycji (form-edit.php + FormRequestHandler)</name>
  <files>admin/templates/components/form-edit.php, autoload/admin/Support/Forms/FormRequestHandler.php</files>
  <action>
    **1. form-edit.php** — dodaj token CSRF jako hidden field zaraz po `_form_id`:
    ```php
    <input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
    ```
    Dodaj po linii z `_form_id` (linia ~80).

    **2. FormRequestHandler::handleSubmit()** — dodaj walidację CSRF jako PIERWSZĄ operację, przed walidacją pól:
    ```php
    $csrfToken = isset($postData['_csrf_token']) ? (string)$postData['_csrf_token'] : '';
    if (!\Shared\Security\CsrfToken::validate($csrfToken)) {
        return [
            'success' => false,
            'errors' => ['csrf' => 'Nieprawidłowy token bezpieczeństwa. Odśwież stronę i spróbuj ponownie.'],
            'data' => []
        ];
    }
    ```

    Unikaj: modyfikowania logiki walidacji pól — CSRF check to osobny guard przed walidacją.
  </action>
  <verify>
    Ręcznie: sprawdź źródło strony formularza edycji — musi zawierać input[name="_csrf_token"].
    Testy: ./test.ps1 (suite nie powinna się zepsuć).
  </verify>
  <done>AC-1 i AC-2 satisfied</done>
</task>

<task type="auto">
  <name>Task 3: CSRF w formularzach logowania i special_actions</name>
  <files>admin/templates/site/unlogged-layout.php, admin/templates/users/user-2fa.php, autoload/admin/App.php</files>
  <action>
    **1. unlogged-layout.php** — dodaj hidden field CSRF do formularza logowania (zaraz po `s-action`):
    ```php
    <input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
    ```

    **2. user-2fa.php** — sprawdź czy jest formularz POST i dodaj analogicznie token CSRF.

    **3. App::special_actions()** — dodaj walidację CSRF na początku, dla akcji które mają konsekwencje:
    - `user-logon` — waliduj token, przy błędzie: alert + redirect `/admin/`
    - `user-2fa-verify` i `user-2fa-resend` — waliduj token
    - Po udanym logowaniu (`user-logon` case 1) — wywołaj `\Shared\Security\CsrfToken::regenerate()` PRZED `self::finalize_admin_login()` (zapobiega session fixation)

    Wzorzec walidacji w special_actions (na początku switch lub przed każdym case):
    ```php
    $csrfToken = isset($_POST['_csrf_token']) ? (string)$_POST['_csrf_token'] : '';
    if (!\Shared\Security\CsrfToken::validate($csrfToken)) {
        \Shared\Helpers\Helpers::alert('Nieprawidłowy token bezpieczeństwa. Spróbuj ponownie.');
        header('Location: /admin/');
        exit;
    }
    ```
    Umieść ten blok PRZED switch ($sa), aby był wspólny dla wszystkich case.

    Unikaj: dodawania CSRF do user-logout (to GET link, nie POST — zmiana na POST wykracza poza zakres).
  </action>
  <verify>
    Ręcznie: sprawdź źródło strony logowania — musi zawierać input[name="_csrf_token"].
    ./test.ps1 (suite nie powinna się zepsuć).
  </verify>
  <done>AC-3 i AC-4 satisfied</done>
</task>

</tasks>

<boundaries>

## DO NOT CHANGE
- Logika walidacji pól w `FormValidator` — tylko dodajemy CSRF guard przed walidacją
- Mechanizm sesji w `admin/index.php` — sesja jest już startowana przed wywołaniem kodu
- Routing w `admin\App::route()` — nie zmieniamy routingu
- Jakiekolwiek pliki frontendowe (front/) — CSRF dotyczy tylko admina w tej fazie
- Pliki testów innych niż nowy CsrfTokenTest.php

## SCOPE LIMITS
- Nie zmieniać logout z GET na POST — to osobna zmiana wykraczająca poza zakres
- Nie dodawać CSRF do admin/ajax.php (shop-category, users ajax) — to osobna iteracja
- Nie refaktoryzować FormRequestHandler — tylko dodać CSRF check
- Nie zmieniać struktury sesji poza `csrf_token` key

</boundaries>

<verification>
Przed uznaniem planu za zakończony:
- [ ] ./test.ps1 — wszystkie testy przechodzą (w tym nowe CsrfTokenTest)
- [ ] Strona formularza edycji zawiera hidden input[name="_csrf_token"]
- [ ] Strona logowania /admin/ zawiera hidden input[name="_csrf_token"]
- [ ] POST bez tokenu do FormRequestHandler zwraca error 'csrf'
- [ ] Brak regresji w istniejących testach (810 testów nadal przechodzi)
</verification>

<success_criteria>
- Wszystkie 3 taski wykonane
- CsrfTokenTest przechodzi (min. 6 assertions)
- Pełna suite testów przechodzi bez regresji
- Wszystkie acceptance criteria AC-1 do AC-5 spełnione
- Token regenerowany po udanym logowaniu
</success_criteria>

<output>
Po zakończeniu utwórz `.paul/phases/04-csrf-protection/04-01-SUMMARY.md`
</output>
