Wstęp
Ten wpis powstał na bazie komentarza użytkownika patricus w serwisie Stack Overflow. Dla dodatkowych informacji i przykładów polecam również zajrzeć tam.
Laravel Passport to implementacja serwera OAuth 2.0 pozwalająca w łatwy, przyjemny (głównie dla programistów) i bezpieczny (głównie dla użytkowników) sposób uwierzytelniać użytkowników w Laravelu. Stanowi dodatkowy pakiet, który można pobrać i w okamgnieniu doinstalować do swojej aplikacji zbudowanej na Laravelu.
Standardowy przypadek
Tworzenie tokenów autoryzacyjnych w Laravel Passport nie stanowi zapewne żadnej tajemnicy. Ot, bierzemy użytkownika, który wykonał żądanie do serwera i korzystamy z dostępnych metod, aby taki token utworzyć:
$accessToken = auth()
->user()
->createToken("Access Token")
->accessToken;
Taki fragment kodu można oczywiście najczęściej spotkać w metodzie login() (lub podobnej) kontrolera odpowiedzialnego za szeroko pojętą autoryzację. Komplementarnie, najpewniej w metodzie logout() tego samego kontrolera, znajdziemy kilka linijek, które odpowiadają za unieważnienie takiego tokena:
$revoked = auth()
->user()
->token()
->revoke();
Jeżeli zarówno sama aplikacja jak i Laravel Passport są prawidłowo skonfigurowane, to wszystko będzie działać... no chyba, że zaczniemy to testować.
Problem w testach
Na początek, nie wnikając w dokładną implementację kontrolera autoryzacyjnego, przyjrzyjmy się dwóm testom, które nie wskażą jeszcze żadnego problemu. Pierwszy jest test logowania przez Laravel Passport:
/**
* @test
*/
public function existingUserCanLogIn(): void
{
// We try to log the user in...
$loginResponse = $this->postJson("/api/login", [
"email" => "example@email",
"password" = "123",
]);
// and we assert that the response contains the access token...
$loginResponse
->assertSuccessful()
->assertJsonStructure(["access_token"]);
}
Drugi test natomiast loguje użytkownika, a potem sprawdza wylogowanie:
/**
* @test
*/
public function existingUserCanLogIn(): void
{
// We try to log the user in...
$loginResponse = $this->postJson("/api/login", [
"email" => "example@email",
"password" = "123",
]);
// then we get the access token...
$token = $loginResponse->getAccessToken();
// and we try to log the user out...
$logoutResponse = $this
->withBearerToken($token)
->postJson("/api/logout");
// and we assert that the response is successful...
$logoutResponse->assertOk();
}
Zakładając, że kontroler autoryzacyjny działa tak jak powinien, oba testy przejdą bez żadnego problemu. Problem pojawi się dopiero, gdy wewnątrz jednego testu po wylogowaniu spróbujemy wykonać akcję, która wymaga, aby użytkownik był zalogowany. Wówczas okaże się, że operacja (pomimo wylogowania) zostanie wykonana, a odpowiedź serwera będzie pomyślna. Przyjrzyjmy się takiemu testowi:
/**
* @test
*/
public function userThatLoggedOutCannotAccessAuthorizedRoutes(): void
{
// We create a user and authenticate them...
$authentication = $this->authenticateExampleUser();
// we get the access token...
$token = $authentication->getAccessToken();
// and then log the user out...
$logoutResponse = $this
->withBearerToken($token)
->postJson("/api/logout");
// and assert that we successfully logged the user out...
$logoutResponse->assertOk();
// and then try to get user details...
$detailsResponse = $this
->withBearerToken($token)
->getJson("/api/user");
// and assert that we get an error response...
$detailsResponse->assertUnauthorized();
}
Pomijając skrót w uwierzytelnieniu użytkownika (żeby nie rozpisywać się zanadto), po uruchomieniu testu okaże się, że wystąpił błąd asercji:
Response status code [200] is not an unauthorized status code.
Failed asserting that 200 is identical to 401.
Jak się przyjrzeć, to wydaje się, że serwer zwrócił odpowiedź o statusie 200, podczas gdy powinien zwrócić błąd 401. No i racja - wydaje się. Testowany fragment wcale nie powoduje uruchomienia serwera i faktycznego wysłania jakiegokolwiek żądania. W rzeczywistości wszystko dzieje się pod powłoką, żądanie jest "symulowane" przez Laravela, a serwer w ogóle nie jest uruchamiany. To powoduje, że zachowanie jest odrobinę inne niż w przypadku realnie działającego serwera.
Można to zresztą w bardzo prosty sposób zweryfikować korzystając, na przykład, z Postmana. Po wykonaniu takich samych żądań (w tej samej kolejności) serwer zwróci błąd 401. Jest to spowodowane tym, że serwer "pilnuje" strażników autoryzacyjnych (ang. auth guards - nie mam pojęcia jak to się tłumaczy) i usuwa unieważnione tokeny pomiędzy żądaniami. To jednak nie dzieje się w przypadku "symulacji" serwera jaka ma miejsce po uruchomieniu testów.
Rozwiązanie
Rozwiązanie problemu jest raczej intuicyjne. Skoro unieważnione tokeny nie są usuwane pomiędzy testami (wewnątrz jednej metody testującej po wylogowaniu), to usuńmy je ręcznie. Za to właśnie odpowiada metoda znaleziona we wspomnianym na początku komentarzu. Czyści ona wszystkie wartości strażników autoryzacyjnych. Ponieważ wymagane atrybuty nie są publiczne, należy się do nich dobrać poprzez ReflectionProperty.
/**
* Resets the authorization guards.
*
* @param array|null $guards
* @return void
*/
protected function resetAuth(?array $guards = null): void
{
$guards = $guards ?: array_keys(config("auth.guards"));
foreach ($guards as $guard) {
$guard = $this->app["auth"]->guard($guard);
if ($guard instanceof SessionGuard) {
$guard->logout();
}
}
try {
$protectedProperty = new ReflectionProperty($this->app["auth"], "guards");
$protectedProperty->setAccessible(true);
$protectedProperty->setValue($this->app["auth"], []);
} catch (ReflectionException $exception) {
echo $exception->getTraceAsString();
exit;
}
}
Teraz wystarczy umieścić wywołanie tej metody zaraz po wylogowaniu użytkownika w teście:
/**
* @test
*/
public function userThatLoggedOutCannotAccessAuthorizedRoutes(): void
{
// We create a user and authenticate them...
$authentication = $this->authenticateExampleUser();
// we get the access token...
$token = $authentication->getAccessToken();
// and then log the user out...
$logoutResponse = $this
->withBearerToken($token)
->postJson("/api/logout");
// and assert that we successfully logged the user out...
$logoutResponse->assertOk();
// and we reset the authorization guards...
$this->resetAuth();
// and then try to get user details...
$detailsResponse = $this
->withBearerToken($token)
->getJson("/api/user");
// and assert that we get an error response...
$detailsResponse->assertUnauthorized();
}
I to wszystko. Dzięki temu zasymulowane żądanie prawidłowo zwróci odpowiedź z błędem 401, a test wykona się pomyślnie.
Można się jeszcze pobawić w jakieś większe uabstrakcyjnienie tego wywołania, aby nie musieć tego robić ręcznie, ale to już zabawa.
