[Devlog] Tydzień drugi. Łzy rozpaczy i wielka radocha. 403 Bad credentials, CORS, taka sytuacja.

Cześć. Post z opóźnieniem, bo powinien być w piątek, ale walczyłem do ostatniego dnia sprintu, żeby chociaż tą jedną rzecz zrobić. Zajęło mi to ponad tydzień i się udało. Budowa api w Symfony miała być prosta i przyjemna, ale pojawiły się problemy. O co chodzi? Czytaj dalej.

W ostatnim poście pisałem o procesie planowania i o zalążku, dosłownie, mojej aplikacji. Ten tydzień zszedł mi na jednym dużym problemie. Przez praktycznie cały tydzień zadawałem sobie pytanie „dlaczego do nie działa?” i wszelkie jego formy, również ze wstawkami z łaciny.

Najgorsze było to, że Stack Overflow milczał, a na dwa napisane posty na forum i na fejsie odpowiedzi były ubogie i tak naprawdę nikt nie wiedział o co chodzi i czemu nie działa.

Fakt jest taki, że roboczo spędziłem nad tym może z 8h, ale jednak jako, że nie pracuję nad tym projektem pełnoetatowo, wyszedł tydzień. Do rzeczy jednak.

Wszystko rozchodziło się o nagłówki CORS (na początku) a potem o JSON’a.

Co to jest CORS?

CORS to w uproszczeniu mówiąc dodatkowe nagłówki, które są wysyłane przez serwer, wyrażając zgodę na połączenie między dwiema różnymi domenami. I tak mój backend stoi na localhost:8000 a aplikacja Vue na localhost:8080, to tak naprawdę dwie różne domeny. I teraz kiedy z Vue wysyłam żądanie logowania występował błąd – 500 Bad Request z wiadomością „No Access-Control-Allow-Origin header is present on the requested resource. […]”. To właśnie świadczy o tym, że mój serwer backendowy odrzuca żądanie, bo nie akceptuje takich z innej domeny niż jego własna. Jest to rzecz jasna podyktowane względami bezpieczeństwa. Ten problem w Symfony można rozwiązać instalując np. NelmioCorsBundle, który pobieramy za pomocą Composera. Potem tworzymy plik nelmio_cors.yaml (w przypadku Symfony 4) w katalogu config/packages i wypełniamy w sposób podobny do mojego:

nelmio_cors:
    defaults:
        allow_credentials: false
        allow_origin: []
        allow_headers: []
        allow_methods: []
        expose_headers: []
        max_age: 0
        hosts: []
        origin_regex: false
        forced_allow_origin_value: '*'
    paths:
        "^/api":
            allow_origin: [ '*' ]
            allow_headers: [ '*' ]
            allow_methods: [ 'POST', 'PUT', 'PATCH', 'GET', 'DELETE' ]
            max_age: 3600

Dzięki temu serwer zwraca nagłówek Access-Control-Allow-Origin: * czyli akceptuje żądania ze wszystkich domen. Jeśli chcemy to ograniczyć, w linii allow_origin: [‚*’] zmieniamy zawartość tablicy na akceptowane przez nas domeny.

Okej. CORS załatwiony i przecież to takie proste. No fakt, tę sprawę rozwiązałem w powiedzmy 20 minut, bo też miałem delikatne problemy, ale to moje gapiostwo. Kolejny problem był z danymi do logowania.

403 Bad credentials

Taki błąd otrzymujemy w momencie, gdy przekazujemy błędne dane logowania. Niby spoko, pożądane działanie. Problem tylko, że podawałem poprawne dane! I to właśnie nad tym siedziałem tyle czasu, bo to było dziwne. Dopiero po pewnym czasie okazało się, że Vue przesyła te dane w formie JSON’a Symfony samo w sobie nie radzi sobie z nim. Serwer oczekiwał po prostu konkretnych dwóch parametrów: username i password.

Pogłówkałem, zasięgnąłem drobnej rady i problem w końcu rozwiązałem. Otóż utworzyłem listener, który nasłuchiwał na zdarzenie OnKernelRequest, sprawdzałem, czy zawartość żądania jest w formie JSON i jeśli tak, to podmieniałem request na dane oczekiwane przez moduł Security mojego webservice. Przykładowa zawartość takiego listenera:

use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

class CorsListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        // Don't do anything if it's not the master request.
        if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) {
            return;
        }

        $request = $event->getRequest();

        if ($request->getPathInfo() !== '/api/login') {
            return;
        }

        // perform preflight checks
        if ($request->getMethod() === 'POST') {
            if (!$this->isJson($request->getContent())) {
                return;
            }
            $credentials = json_decode($request->getContent(), true);
            $event->getRequest()->request->set('username', $credentials['username']);
            $event->getRequest()->request->set('password', $credentials['password']);
        }
    }

    function isJson($string) {
        json_decode($string);
        return (json_last_error() == JSON_ERROR_NONE);
    }
}

Jak widać sprawdzam jeszcze czy żądanie dotyczy ścieżki logowania, nie ma potrzeby aby przerabiać każdy request. No jeszcze trzeba do services.yaml dodać coś takiego:

    app.tokens.action_listener:
        class: App\EventListener\CorsListener
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 300 }

Od tej chwili po żądaniu do endpoint’a /api/login z moimi danymi, otrzymywałem token co było celem.

Autoryzacja za pomocą JWT – JSON Web Token

Wspomniałem o tokenie. Teraz w skrócie opiszę jak użyć JWT w Vue. Ogólnie jest to bardzo proste – kiedy odpowiedź serwera zawiera token (czyli logowanie przebiegło poprawnie), zapisujemy go do LocalStorage. Teraz za każdym razem, kiedy wysyłamy jakieś żądanie do serwera musimy dokleić nagłówek „Authorization” z naszym tokenem. Można tak robić ręcznie do każdego requesta, ale możemy też globalnie doklejać ów nagłówek. W tym celu w main.js dołączamy taki kod:

Vue.http.interceptors.push((request, next) => {
  if(localStorage.getItem("token")) {
    request.headers.set('Authorization', 'Bearer ' + localStorage.getItem("token"));
  }

  next(response => {
    if(response.status === 400 || response.status === 401 || response.status === 402) {
      router.push({path: '/login'});
    }
  });
});

Kod jest bardzo prosty – przechwytujemy request przed jego wysłaniem i jeżeli istnieje token, to go do nagłówka go „Authorization” go wklejamy i wysyłamy. Następnie sprawdzamy odpowiedź i jeżeli jej kod to 400, 401, 402 albo 403 to robimy przekierowanie do strony z logowaniem, bo te kody oznaczają brak zalogowania.

Ogólnie jest jeszcze kwestia stanu globalnego aplikacji – czy user jest zalogowany czy nie, tutaj użyłem Vuex czyli implementacji Redux, ale o tym może innym razem.

Repozytoria są zaaktualizowane:

https://github.com/webkonstruktor/saver-frontend

https://github.com/webkonstruktor/saver-backend

Do następnego!

BTW. Zapomniałem odnowić certyfikat i przez parę dni był problem z dostaniem się na bloga za co przepraszam 😉