Poza autentykacją, autoryzacja użytkownika jest jednym z najpopularniejszych i najczęstszych problemów przed jakimi stajemy tworząc niemal każdą, bardziej rozbudowaną aplikację. I dotyczy to zarówno back-endu jak i front-endu. W dzisiejszym wpisie pokażę Ci jak w jednym z projektów, w których brałem udział, poradziłem sobie z zadaniem jakim jest autoryzacja react-router na podstawie ról użytkownika po stronie front-endu (aplikacja oczywiście w React). W skrócie chodzi o to, aby na podstawie ról posiadanych przez zalogowanego użytkownika, ograniczyć możliwość przejścia na konkretne ścieżki react-routera - ale o tym więcej za moment.

P.S.1 Wpis ten miał już kiedyś swoją premierę na moim anglojęzycznym blogu: Front-end Insights. Jego treść może więc być dla niektórych znana. Postanowiłem jednak przetłumaczyć go (oraz może trochę ulepszyć jeśli się da) i zamieścić również tutaj. Myślę, że może się to okazać przydatne dla wielu czytelników bloga.

P.S.2 Na bazie tego co poniżej opisałem powstał projekt dostępny na GitHub, o nazwie react-router-role-authorization. Jest to biblioteka dwóch komponentów React, która dostępna jest również w npm. Możesz więc łatwo użyć jej w swoim projekcie! Jeśli zaś masz ochotę i pomysły na usprawnienia -zachęcam do dorzucenia swoich trzech groszy do tego projektu jako kontrybutor!

P.S.3 Wszystko co tutaj opisałem jest tylko moją propozycją rozwiązania opisywanego problemu. Na pewno nie jest to jedyny sposób na autoryzację react-routera i nie upieram się, że mój pomysł jest na pewno najlepszy. Ponadto pamiętaj, że autoryzacja po stronie klienta służy tylko i wyłącznie do celów prezentacyjnych. Powinna ona zawsze iść w parze z autoryzacją żądań po stronie serwera, a ten nie powinien nigdy zwracać zasobów niedostępnych dla użytkownika bez danej roli!

Moje wymagania i założenia

Zanim zacznę przedstawiać moje rozwiązanie myślę, że warto byłoby napisać parę słów o tym co właściwie chciałem osiągnąć. Pierwszym moim wymaganiem było ograniczenie dostępu do pewnych “route” tylko do konkretnych ról użytkownika. Dla przykładu, mając ścieżkę /admin/profile, chciałem aby mogli w nią wejść tylko użytkownicy posiadający rolę admin. W przypadku gdy użytkownik nie miał tej roli, powinien był zostać przekierowany na stronę błędu - na przykład 404.

Druga sprawa, z którą musiałem sobie poradzić to strona startowa, która miała dokładnie ten sam adres dla wszystkich użytkowników, niezależnie od roli jaką posiadają. Był to swego rodzaju “dashboard”, gdzie wyświetlały się różne komponenty. Jak się domyślasz, pulpit ten miał wyświetlać inne panele (komponenty) w zależności od tego czy był to zwykły pracownik (rola employee) czy też użytkownik z uprawnieniami admin.

Wydaje mi się, że te dwa wymagania mogą występować w wielu aplikacjach, dlatego też fajnie byłoby znaleźć dobre, re-używalne rozwiązanie tego problemu (stąd też późniejszy projekt na GitHubie oraz w npm). Spójrzmy zatem jak sobie z tym poradziłem.

Ograniczanie dostępu do danego “route” tylko do wybranych ról

No dobra, zacznijmy może od razu od przykładu. Wyobraź sobie taką oto definicję “routingu” w naszej przykładowej aplikacji React:

<Router history={history}>
 <Route component={MainContainer} path="/">
   <IndexRoute component={Home} />
   <Route component={Profile}>
     <Route component={PhotosList} path="/profile/photos" />
   </Route>
   <Route component={EmployeesManagement}>
     <Route component={EmployeesList} path="/employees/list" />
     <Route component={EmployeeAdd} path="/employees/add" />
   </Route>
 </Route>
</Router>

Zgodnie ze wspomnianymi wymaganiami, chciałbym teraz aby “route” dla komponentu Profile (oraz wszystkie jego dzieci) był dostępny tylko dla użytkowników aplikacji z rolą employee. Z kolei “route” dla komponentu EmployeesManagement wraz z jego dziećmi, ma być dostępny dla użytkowników z rolą admin.

Rozszerzenie konfiguracji “routingu”

Aby to zrobić można wykorzystać fakt, że podczas konfiguracji “routingu” możemy przekazać do komponentu Route dodatkowe atrybuty (poza standardowymi). Informacje przekazane w ten sposób będą następnie dostępne w komponencie powiązanym z danym “route”. Spójrz poniżej, w jaki sposób dodałem dodatkowy parametr authorize do głównych “routów” aplikacji:

<Router history={history}>
  <Route component={MainContainer} path="/">
    <IndexRoute authorize={['employee', 'admin']} component={Home} />
    <Route authorize={['employee']} component={Profile}>
      <Route component={PhotosList} path="/profile/photos" />
    </Route>
    <Route authorize={['admin']} component={EmployeesManagement}>
      <Route component={EmployeesList} path="/employees/list" />
      <Route component={EmployeeAdd} path="/employees/add" />
    </Route>
  </Route>
</Router>

Jak możesz zauważyć, atrybut authorize dodałem do wszystkich trzech głównych “routów”: IndexRoute dostępny ma być dla ról employee oraz admin; “route” dla komponentu Profile dostępny jest tylko dla roli employee; natomiast “route” dla komponentu EmployeesManagement ma być osiągalny dla roli admin.

Komponent autoryzacyjny

Ale to oczywiście nie wszystko co trzeba zrobić. Musimy teraz jakoś porównać przekazane ustawienia z zestawem ról zalogowanego użytkownika (zwykle przekazywane są one przez serwer podczas procesu logowania). Aby to zrobić, stworzyłem nowy komponent React, który nazwałem AuthorizedComponent.js:

import React, { PropTypes } from 'react';
import _ from 'lodash';

class AuthorizedComponent extends React.Component {
  static propTypes = {
    routes: PropTypes.array.isRequired
  };

  static contextTypes = {
    router: PropTypes.object.isRequired
  };

  componentWillMount() {
    const { routes } = this.props; // array of routes
    const { router } = this.context;

    // check if user data available
    const user = JSON.parse(localStorage.getItem('user'));
    if (!user) {
      // redirect to login if not
      router.push('/login');
    }

    // get all roles available for this route
    const routeRoles = _.chain(routes)
      .filter(item => item.authorize) // access to custom attribute
      .map(item => item.authorize)
      .flattenDeep()
      .value();

    // compare routes with user data
    if (_.intersection(routeRoles, user.roles).length === 0) {
      router.push('/not-authorized');
    }
  }
}

export default AuthorizedComponent;

Objaśnienie kodu

Pierwsze, na co chciałbym abyś zwrócił uwagę, są dwa statyczne obiekty zadeklarowane na początku powyższego przykładu. Są to obiekty propTypes oraz contextTypes, które informują React, że komponent ten oczekuje właściwości routes w this.props oraz właściwości router w this.context. Jeśli czytałeś moje wcześniejsze wpisy na temat React to na pewno “propsy” są Ci już znane. Natomiast jeśli chodzi o this.context, to więcej na jego temat przeczytasz w dokumentacji. W skrócie jest to obiekt podobny do “propsów” jednak bardziej globalny - nie trzeba go ręcznie przekazywać do komponentów-dzieci. Na potrzeby tego wpisu wystarczy nam wiedzieć, że pewne właściwości react-routera dostępne są właśnie w this.context.

Jedźmy jednak dalej z koksem… Główna funkcjonalność powyższego komponentu mieści się w implementacji metody componentWillMount. Jak możesz zauważyć, pobieram tam tablicę routes z obiektu this.props. Tablica ta zawiera informacje o drzewie “routów”, które pasują do aktualnego adresu. Czyli dla naszej przykładowej definicji “routingu”, jeśli aktualnym adresem jest /profile/photos to tablica ta będzie zawierać informacje o: “routach” dla komponentów MainContainer, Profile oraz PhotosList. To dlatego, że nasz “routing” jest w ten właśnie sposób zagnieżdżony.

To co tutaj najistotniejsze to to, że obiekty, które znajdują się w tablicy routes mogą zawierać właściwość authorize (jeśli została dla danego Route zdefiniowana). W dalszej części metody componentWillMount porównuję tę właściwość z rolami, które znajdują się w obiekcie użytkownika pobranym podczas logowania (w przykładzie trzymam je w localStorage ale Ty oczywiście możesz to robić inaczej). W powyższym przykładzie: linia 25 - pobieranie wszystkich ról przypisanych do atrybutów authorize oraz linia 32 - porównanie z rolami użytkownika.

routes

W przypadku gdy, w wyniku porównania okazuje się, że żadna z ról użytkownika nie pasuje do jakiejkolwiek roli pobranej z atrybutów authorize, wykorzystuję metodę push obiektu router (pobranego z kontekstu) do przekierowania użytkownika na stronę z informacją o błędzie autoryzacji.

Wykorzystanie komponentu autoryzacyjnego

Ostatnia rzecz do wykonania to użycie stworzonego właśnie komponentu autoryzacyjnego. Aby móc skorzystać z jego dobrodziejstw, muszę komponent, który podpięty jest pod Route z atrybutem authorize odziedziczyć z klasy AuthorizeComponent. Dla przykładu, spójrzmy na implementację komponentu Profile:

import React from 'react';
import RouteHandler from './RouteHandler';
import AuthorizedComponent from './AuthorizedComponent';

class Profile extends AuthorizedComponent {
  render() {
    return (
      <div className="profile-container">
        <RouteHandler {...this.props} />;
      </div>
    );
  }
}

export default Profile;

Jak widzisz, jest to bardzo prosty komponent-kontener, który pokazuje inne komponenty-dzieci w zależności od aktualnego “route” (zajmuje się tym komponent RouteHandler, który odpowiada za odpowiednią obsługę komponentów dzieci). Dzięki odziedziczeniu tego komponentu z komponentu autoryzacyjnego, filtruje on próby otwarcia danej ścieżki porównując dozwolone role z rolami zalogowanego użytkownika.

Oczywiście, powyższy przykład pokazuje sam pomysł na to jak można osiągnąć to co sobie założyłem. Dużo bardziej re-używalna wersja tego rozwiązania została przeze mnie zaimplementowana w postaci pakietu npm, który możesz podejrzeć i przeanalizować na GitHubie.Zachęcam do tego gorąco!

Pokazywanie komponentów w zależności od ról użytkownika

Pierwszy “case” mamy omówiony. Czas przejść do drugiego wymagania jakie przede mną stanęło, a więc zwykłe pokazywanie/ukrywanie danego komponentu w zależności od ról zalogowanego użytkownika. Tak jak wcześniej wspomniałem, strona domowa mojego projektu miała ten sam adres dla każdego rodzaju użytkowników - zarówno dla adminów jak i dla szeregowych pracowników. Musiałem więc poradzić sobie z problemem wyświetlania na ekranie innych komponentów w zależności od roli użytkownika.

Komponent bazowy

Spójrzmy jak do tego doszedłem! Tutaj również wykorzystałem dziedziczenie. Na początku stworzyłem komponent bazowy, który nazwałem RoleAwareComponent.js:

import React from 'react';
import _ from 'lodash';

class RoleAwareComponent extends React.Component {
  constructor(props) {
    super(props);
    this.authorize = [];
  }

  shouldBeVisible() {
    const user = JSON.parse(localStorage.getItem('user'));
    if (user) {
      return _.intersection(this.authorize, user.roles).length > 0;
    }

    return false;
  }
}

export default RoleAwareComponent;

Najpierw spójrz na konstruktor powyższej klasy. Zawiera on deklarację właściwości authorize tej klasy, która inicjowana jest pustą tablicą. Właściwość ta zawierać będzie role, których posiadanie pozwalać będzie na pokazanie komponentu dziedziczącego po tej klasie. Jak się domyślasz, tablica jest tutaj pusta celowo, a napełnimy ją w klasie dziedziczącej.

Poza konstruktorem, komponent ten zawiera jeszcze metodę shouldBeVisible. Metoda ta porównuje role zapisane w tablicy this.authorize z rolami zalogowanego użytkownika (w przykładzie ponownie pobieram obiekt user z localStorage ale Ty możesz to zrobić w inny sposób). Zgodnie z nazwą metody, zwraca ona true jeśli użytkownik posiada jedną z dozwolonych ról, w przeciwnym wypadku zwraca false.

Wykorzystanie komponentu bazowego

Spójrzmy teraz jak wykorzystałem ten komponent:

import React from 'react';
import RoleAwareComponent from './RoleAwareComponent';

class PhotoBox extends RoleAwareComponent {
  constructor(props) {
    super(props);

    // component will be visible for the roles below:
    this.authorize = ['employee'];
  }

  render() {
    const jsx = (
      <div className="pure-u-13-24 box photo-box">
        <div className="box-wrapper">
          <h1>Your photo</h1>
          <img src="http://some.url/img1.jpg" />
        </div>
      </div>
    );

    return this.shouldBeVisible() ? jsx : null;
  }
}

export default PhotoBox;

Tak jak wspomniałem, w konstruktorze tej klasy napełniamy tablicę this.authorize dozwolonymi dla tego komponentu rolami. Dalej, w metodzie render komponentu wykorzystuję odziedziczoną metodę shouldBeVisible do zdecydowania czy zwrócić widok JSX przypisany do stałej jsx czy też nie.

Autoryzacja react-router - podsumowanie

To tyle na, jakże ciekawy, temat jakim jest autoryzacja react-router. Do wrzucenia tego wpisu skłoniła mnie popularność projektu npm (921 pobrań w zeszłym miesiącu), który na jego podstawie stworzyłem. Projekt od jakiegoś czasu leżał trochę odłogiem ale raz na jakiś czas dostawałem pytania na jego temat od programistów z całego świata. Znaczy to chyba, że projekt cieszy się pewnym zainteresowaniem… Dlatego też ostatnio trochę do niego przysiadłem, wypuściłem nową wersję, a w tzw. międzyczasie miałem nawet zewnętrznego kontrybutora! Myślę więc, że to rozwiązanie może zainteresować również czytelników tego bloga.

Oczywiście zachęcam do zajrzenia do mojego projektu react-router-role-authorization oraz zgłaszania bugów i pull requestów!