Codice: weryfikacja właściciela zasobu (uprawnień użytkownika)

Z kilku przyczyn delikatnie rozjechał mi się harmonogram cotygodniowy, ale czas nadrobić straty. Poprzednio opisywałem doświadczenia zebrane podczas projektowania procesu instalacji dla Codice. Zajmiemy się teraz kolejnym zagadnieniem, które mimo iż oparte na przykładzie tego konkretnego projektu, jest jednym z podstawowych zagadnień dla każdej aplikacji CRUD. Zachęcam do tego aby ten, jak i kolejne wpisy traktować jako zwyczajne poradniki dla piszących w Laravelu, z nutką historii w tle.

Za każdym razem gdy piszemy aplikację, której instancja ma być używana równolegle przez więcej niż jedną osobę, pojawia się kwestia weryfikacji właściciela zasobu. Prościej mówiąc - sprawdzenia czy obecnie zalogowany użytkownik ma uprawnienia do wyświetlenia/edycji/usunięcia danego rekordu. Na pierwszy rzut oka sprawa nie należy do skomplikowanych:

$note = Note::where('id', $request->get('id'))->where('user_id', Auth::id())->get();

Na przykładzie notatek, pobieramy rekord o ID przekazanym w adresie i zaznaczamy że ID przypisanego użytkownika musi być równe identyfikatorowi obecnie zalogowanego (metoda pomocnicza id() z fasady Auth). Przyjmijmy też że naszym celem jest usunięcie wybranej notatki. W razie błędu przekierujemy zaś na inną stronę, na której wyświetlimy czytelny dla użytkownika komunikat. Bardziej kompletny kod oparty o metodzie przedstawionej powyżej mógłby wyglądać tak:

$note = Note::where('id', $request->get('id'))->where('user_id', Auth::id())->get();

if ($note === null) {
    return redirect('/')->with('error', 'Nie jesteś właścicielem podanej notatki');
}

$note->delete();

return redirect('/')->with('success', 'Notatka usunięta');

W wypadku gdy nie uda się odnaleźć pasującego rekordu otrzymujemy null. Wiemy wtedy że podana notatka nie istnieje lub istnieje, ale należy do kogoś innego (wymagamy spełnienia obu warunków), możemy więc cofnąć użytownika i wyświetlić mu odpowiednią wiadomość.

Na dobrą sprawę wpis można by zakończyć już w tym miejscu. Warto jednak spojrzeć bardziej perspektywicznie. Podany fragment kodu będzie się przecież powtarzał także dla innych operacji CRUD (np. edycji lub odczytu), nasza aplikacja może też zarządzać więcej niż jednym rodzajem danych (na przykładzie Codice - notatkami oraz etykietami). W efekcie otrzymamy brzydką i niewygodną w utrzymaniu powtarzalność kodu. Zastanówmy się jak można to poprawić.

Rozsądne wydaje się skorzystanie z metody find(), która przyjmuje za konwencję, że polem, po którym będziemy indeksować tabelę jest id. Możemy połączyć to także z możliwością opisaną w dokumentacji Laravela jako query scopes. Do naszego modelu dodajemy:

public function scopeMine($query)
{
    return $query->where('user_id', '=', Auth::id());
}

a wynikowo naszą metodę kontrolera redukujemy do postaci

$note = Note::mine()->find($request->get('id'));

if ($note === null) {
    return redirect('/')->with('error', 'Nie jesteś właścicielem podanej notatki');
}

$note->delete();

return redirect('/')->with('success', 'Notatka usunięta');

Jest lepiej. Powtarzalna część zapytania została przeniesiona do modelu (oraz konwencji używanej przez ORM Laravela). Wciąż jednak widzimy niezmienną część logiki, którą także przydałoby się wypchnąć na zewnątrz, w jakieś wspólne miejsce.

W tym celu możemy wykorzystać wyjątki, bazując zresztą na logice używanej przez wbudowaną metodę findOrFail(). Do naszego modelu dodajemy kolejną metodę pomocniczą:

public static function findMine($id)
{
    $note = self::mine()->find($id);

    if (!$note) {
        throw new NoteNotFoundException;
    }

    return $note;
}

W obrębie aplikacji tworzymy wyjątek NoteNotFoundException i importujemy go w modelu.

<?php

namespace App\Exceptions;

class NoteNotFoundException extends \Exception {}

Logikę obsługi błędów przenosimy natomiast do handlera wyjątków, edytując metodę render() w klasie AppExceptionsHandler.

public function render($request, Exception $e)
{
    if ($e instanceof NoteNotFoundException) {
        return Redirect::route('index')->with('message', 'Nie jesteś właścicielem podanej notatki`);
    }

    // reszta domyślnego kodu, np. ModelNotFoundException
}

Teraz kod użyty w kontrolerze powinien być naprawdę krótki i satysfakcjonujący:

$note = Note::findMine($request->get('id'));

$note->delete();

return redirect('/')->with('success', 'Notatka usunięta');

Jest dobrze? Tak. Może być lepiej? Oczywiście! Wady rozwiązania ujawnią się przy jakiejkolwiek większej skali. Pozostając przy Codice - ten sam kod musiałby być zduplikowany dla notatek i etykiet - dwa różne wyjątki, dwa bloki obsługujące go w handlerze, analogiczne modyfikacje w obu modelach.

Skupimy się więc na wyłączeniu części wspólnych. Wyeliminujemy potrzebę tworzenia indywidualnych wyjątków dla każdego rodzaju danych, a metody wymagane dla każdego z modeli przeniesiemy do traita.

<?php

namespace Codice\Support\Traits;

use Auth;
use Illuminate\Http\Exceptions\HttpResponseException;

trait Owned
{
    /**
     * Find owned model (limited to the current user).
     *
     * @param  int $id Model ID
     * @return static
     */
    public static function findMine($id)
    {
        $model = self::mine()->find($id);

        if (!$model) {
            $modelLangFile = array_reverse(explode('\\', get_called_class()))[0];

            throw new HttpResponseException(redirect()->route('index')->with([
                'message' => trans("$modelLangFile.not-found"),
                'message_type' => 'danger',
            ]));
        }

        return $model;
    }

    /**
     * Query scope for getting owned models (limited to the current user).
     *
     * @param $query Illuminate\Database\QueryBuilder
     * @return Illuminate\Database\QueryBuilder
     */
    public function scopeMine($query)
    {
        return $query->where('user_id', '=', Auth::id());
    }

    /**
     * Define relationship to the User owning the model.
     */
    public function user()
    {
        return $this->belongsTo('CodiceUser');
    }
}

W tym wypadku obsługa po stronie kontrolera wygląda dokładnie tak, jak pokazano wyżej, jednak cała implementacja logiki dla określonego modelu ogranicza się do use Owned, bez żadnego powtarzania schematycznego kodu. Dodatkowym klasom wyjątków również możemy już podziękować, korzystamy z wbudowanego HttpResponseException, więc AppExceptionsHandler może zostać przywrócone do wcześniejszej postaci. Dodatkowo mamy też własny scope mine() (przydatny przy innych rodzajach zapytań niż wybranie pojedynczego rekordu po ID) oraz zdefiniowaną relację do modelu użytkownika.

Ciekawostką jest hack w metodzie findMine(), który pobiera nazwę modelu wywołującego metodę i na tej podstawie buduje klucz do pliku z tłumaczeniami (w celu zróżnicowania komunikatów błędów, np. "nie znaleziono notatki" vs "nie znaleziono etykiety").

Przedstawiłem kilka rożnych metod, skracając nieco drogę, przez którą ten kawałek kodu przeszedł w moim własnym projekcie. Zainteresowanych sprawdzeniem faktycznego kodu mogę odesłać do historii zmian w kontrolerze notatek oraz oczywiście traita Owned


Powyższy wpis przedstawia część doświadczeń zdobytych przy tworzeniu projektu Codice. Po więcej szczegółów zapraszam do pierwszego wpisu oraz na codice.eu. Poza planowaną od dawna serią jest to też realizacja wymogów konkursu "Daj Się Poznać". Więcej o nim możecie przeczytać w tym wpisie. Po nowe materiały poświęcone tworzeniu projektu Codice zapraszam w każdy wtorek. Ponadto więcej wpisów, poświęconych temu projektowi lub ogólnej tematyce bloga przeczytacie co sobotę.

Komentarze wyłączone

Możliwość komentowania na blogu została wyłączona. Zapraszam do kontaktu na Twitterze, Facebooku lub poprzez formularz, o ten tutaj. Do usłyszenia!