Apache Shiro – uwierzytelnić lepiej?

Jakiś czas temu opisywałem, jak szybko zaimplementować uwierzytelnianie w aplikacji GWT przy pomocy JAAS’a. Wtedy byłem trochę zafascynowany tym ostatnim i nie dostrzegałem wielu jego wad. Prosty moduł uwierzytelniania (mniej więcej taki, jaki wtedy stworzyliśmy) nie przysparza jeszcze większych trudności, jednak każde dodatkowe wymagania sprawiły, że JAAS coraz bardziej zaczął mnie zniechęcać. Nie twierdzę, że jest zły, tylko jego silna integracja z mechanizmami serwerów aplikacji sprawia, że aplikacja staje się mało przenaszalna i praktycznie przed każdym wdrożeniem na inny serwer moduł logowania trzeba implementować od nowa. Najgorsze w tym wszystkim jest to, że niektóre serwery udostępniają łatwe i przyjazne API do rozszerzania funkcjonalności uwierzytelniania, a inne z kolei utrudniają bądź wręcz to uniemożliwiają. Jeśli wiemy, że nasza aplikacja od początku do końca będzie współpracować z jednym tylko serwerem – problem znika, nie mniej i tak się namęczymy z implementacją kilku rzeczy.

Użerając się wciąż z JAAS’em w końcu postanowiłem spróbować czegoś innego. Padło na Apache Shiro – framework ułatwiający implementację uwierzytelniania, autoryzacji, szyfrowania i zarządzania sesjami w aplikacjach Java. Shiro wywodzi się ze starej biblioteki JSecurity i obecnie jest aktywnie rozwijane przez wielu deweloperów. Może być użyte w zwykłych aplikacjach Java oraz aplikacjach webowych, a mocna modularyzacja kodu pozwala na łatwe podstawianie swoich implementacji do tego co potrzebujemy. Przy okazji zawiera też wiele podstawowych metod uwierzytelniania, z których można skorzystać praktycznie od razu. Dodatkowo jeśli nasza aplikacja musi obsługiwać mechanizmy serwerowe, w Shiro można napisać własną implementację modułu logowania JAAS.

Dość jednak chwalenia, trzeba przejść do wymagań. Te sprecyzuje na podstawie aplikacji firmowej, w której aktualnie implementuje Shiro. Przy okazji pokuszę się o małe porównanie Shiro z JAAS’em.

Wymagania

Integracja z aplikacją GWT

Ekran logowania nie musi być częścią jednego głównego modułu aplikacji GWT, a wręcz wskazane jest, aby był to osobny moduł. Od framework’a wymagam możliwości napisania takiego modułu, a plusem będzie na pewno możliwość przesyłania danych logowania przez GWT-RPC.

JAAS’a łatwo jest wdrożyć, jeśli korzysta się ze standardowego formularza logowania do aplikacji webowych. Taki formularz zawiera domyślnie jedynie pole nazwy użytkownika i hasła, a dane z formularza przesyłane są metodą POST. Niestety ciężko jest zastąpić to przez GWT-RPC, bo JAAS nie udostępnia standardowych mechanizmów do zapamiętywania adresu strony, na którą wszedł użytkownik i przekierowania na tą stronę po poprawnym uwierzytelnieniu. Wszystko da się zrobić, jednak wymaga to napisania sporej ilości kodu.

W Shiro jest już o wiele lepiej, bo framework udostępnia szereg klas, z których możemy skorzystać do implementacji całości. Nawet stworzenie osobnego modułu logowania z danymi przesyłanymi przez GWT-RPC nie sprawia większych trudności.

Dodatkowe pola na formularzu logowania

W firmowej aplikacji na formularzu potrzebujemy dodatkowego pola z nazwą firmy, aby na podstawie wartości tego pola i podanej nazwy użytkownika utworzyć pełny login użytkownika. Wymagane jest, aby obiekt przekazany do systemu uwierzytelniania i który można pobrać później wewnątrz aplikacji, zawierał ten pełny login użytkownika.

Standardowy formularz logowania JAAS (tzw. uwierzytelnianie FORM-BASED) zawiera pole z nazwą użytkownika i hasłem. Dokładanie kolejnych pól jest możliwe, ale nie należy do najprzyjemniejszych. Trzeba dokładać kolejne implementacje Callback‚ów oraz rozszerzać implementację CallbackHandler‚a, która dodatkowo może się różnić w zależności od serwera aplikacji. Obiekt Principal wybierany za pomocą metody HttpServletRequest.getUserPrincipal niestety w nazwie zawiera tylko nazwę użytkownika przekazaną w formularzu logowania, na niektórych serwerach (np. GlassFish v3) nie udało mi się przekazać tam wartości z dwóch pól formularza.

Shiro domyślnie obsługuje dane logowania zawierające nazwę użytkownika, hasło oraz wartość pola „Zapamiętaj mnie”. Nie ma jednak większego problemu, aby przesłać takich danych więcej i skorzystać z nich we własnym module uwierzytelniania. Wewnątrz aplikacji możemy mieć dostęp do wszystkich atrybutów obiektu uwierzytelnionego, jakie sobie ustawimy podczas procesu uwierzytelniania.

Przenaszalność między serwerami aplikacji

Nasi firmowi klienci korzystają z różnych serwerów aplikacji i na każdym z nich nasza aplikacja musi działać. Są to między innymi GlassFish v3, WebSphere 6.1, OC4J 10g, no i Jetty z trybu deweloperskiego GWT.

Tu JAAS ewidentnie leży. O ile każdy serwer wspiera moduły logowania JAAS, to prawie każdy z nich wymaga też implementacji własnych interfejsów czy rozszerzania własnych klas. To sprawia, że moduł logowania trzeba pisać specjalnie pod każdy serwer, co w połączeniu z innymi trudnościami potrafi być naprawdę czasochłonne.

Shiro opiera się tylko na własnych interfejsach i klasach, więc jedna implementacja ruszy bez większych przeszkód praktycznie wszędzie.

Implementacja własnych metod uwierzytelniania

Nasza aplikacja standardowo uwierzytelnia użytkowników logując się za pomocą przekazanej nazwy użytkownika i hasła do bazy danych. Na życzenie klienta wspieramy też integrację z LDAP’em, lub innymi bazami danych uwierzytelniania.

Wspierane mechanizmy uwierzytelniania w JAAS zależą od danego serwera aplikacji. Większość z nich udostępnia proste korzystanie z plików properties, sprawdzanie danych w tabeli bazy danych, czy nawet w LDAP’ie, jednak nasze potrzeby wymagają możliwości zdefiniowania własnej metody uwierzytelniania. JAAS to wspiera, ale jak zwykle na każdym serwerze inaczej :)

W Apache Shiro ponownie jedna implementacja sprawdza się tak samo na każdym serwerze.

Różne komunikaty błędów logowania

Ze względu na większą ilość pól na formularza logowania, potrzebujemy też więcej komunikatów zwrotnych o błędnie wprowadzonych danych. Oprócz standardowego mówiącego o błędnym loginie lub haśle musi też być zwracana błędna nazwa firmy, czy niepoprawny kod z obrazka CAPTCHA.

Nie wiem kto wymyślał standardowe mechanizmy bezpieczeństwa w aplikacjach webowych JEE, ale przy korzystaniu z metody opartej na formularzu możliwe jest podanie jedynie jednej strony zgłaszającej błąd. Jeśli chcemy mieć różne komunikaty, musimy takie błędy sami zapisywać i odczytywać w jakiś automagiczny sposób, lub powinniśmy pomęczyć się trochę z wykorzystaniem GWT-RPC do przesyłania danych.

Shiro już w podstawowej instrukcji użycia wspomina o różnych możliwych błędach, a wszystkie z nich bez większych problemów można przekazać dalej dla użytkownika.

Implementacja Single Sign-On

Kilka aplikacji – jedno wspólne logowanie. Minimum, to możliwość udostępnienia wspólnego logowania dla aplikacji wgranych na ten sam serwer aplikacji.

JAAS jak zwykle uzależniony jest od serwera. Choć muszę przyznać, że na niektórych uaktywnienie SSO, to jedynie kilka kliknięć w ustawieniach serwera (np. GlassFish v3). Problem może być na tych, które standardowo nie oferują mechanizmów SSO.

Dla Shiro można podstawić różne implementacje dostępu do sesji. Można np. pobierać dane o sesjach ze wspólnej bazy dla kilku aplikacji, lub tak skonfigurować cache (tu wykorzystywany jest Ehcache), aby korzystał ze wspólnej pamięci dla jednego JVM’a. Bardziej zaawansowane implementacje opierają się na rozwiązaniach Terracota, Gigaspaces, Zookeeper, itp., które służą do tworzenia rozproszonego cache’u. Wadą jest marna dokumentacja.

Implementacja

Tu będzie szybko z dwóch powodów. Po pierwsze cały szkielet aplikacji już mamy, po drugie w Shiro wszystko robi się szybko :)

Dane wprowadzone przez użytkownika na formularzu logowania będą wysyłane poprzez GWT-RPC. Do tego celu stworzono następujący serwis:

SecurityService.java
@RemoteServiceRelativePath("security")
public interface SecurityService extends RemoteService {

  LoginResult login(String username, String password, boolean rememberMe);

  void logout();
}

Na cele przykładowego projektu wykorzystamy tylko dwie metody serwisu, do logowania i wylogowania użytkownika.

SecurityServiceImpl.java
public class SecurityServiceImpl extends RemoteServiceServlet implements SecurityService {
  private static final long serialVersionUID = -4935507026700430314L;

  private Logger LOG = LoggerFactory.getLogger(SecurityServiceImpl.class);

  @Override
  public LoginResult login(String username, String password, boolean rememberMe) {
    LOG.info("LOGIN (username: " + username + " password: " + password + ")");
    Subject subject = SecurityUtils.getSubject();
    LoginResult result = new LoginResult();
    result.setSuccess(false);
    result.setReferrerUrl(getReferrerUrl());

    if (subject.isAuthenticated()) {
      result.setSuccess(true);
      return result;
    }

    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    token.setRememberMe(rememberMe);
    doLogin(subject, token, result);

    return result;
  }

  @Override
  public void logout() {
    LOG.info("LOGOUT");
    Subject currentUser = SecurityUtils.getSubject();
    currentUser.logout();
  }

  protected void doLogin(Subject subject, AuthenticationToken token, LoginResult result) {
    try {
      subject.login(token);
      result.setSuccess(true);
    } catch (UnknownAccountException uae) {
      // username wasn't in the system, show them an error message?
      result.setErrorMessage("Konto nie istnieje");
    } catch (IncorrectCredentialsException ice) {
      // password didn't match, try again?
      result.setErrorMessage("Wprowadzona nazwa użytkownika lub hasło jest nieprawidłowe.");
    } catch (LockedAccountException lae) {
      // account for that username is locked - can't login. Show them a message?
      result.setErrorMessage("Konto jest zablokowane");
    } catch (AuthenticationException ae) {
      // unexpected condition - error?
      result.setErrorMessage("Wystąpił nieznany błąd");
    }
  }

  protected String getReferrerUrl() {
    SavedRequest sr = WebUtils.getSavedRequest(getThreadLocalRequest());
    if (sr != null) {
      LOG.info("Request Url: " + sr.getRequestUrl());
      return sr.getRequestUrl();
    }

    return null;
  }
}

W metodzie login oprócz przeprowadzenia procedury logowania zapamiętujemy też adres, z którego nastąpiło przekierowanie do strony logowania. Za jego pobranie odpowiada metoda getReferrerUrl(). W metodzie doLogin(…) można zobaczyć, że Shiro przy niepowiedzeniu uwierzytelniania informuje nas o błędach za pomocą różnych wyjątków.

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

	<display-name>SampleGWTShiro</display-name>
	<description>Sample GWT: Shiro</description>

	<welcome-file-list>
		<welcome-file>index.html</welcome-file>
	</welcome-file-list>

	<filter>
	    <filter-name>ShiroFilter</filter-name>
	    <filter-class>org.apache.shiro.web.servlet.IniShiroFilter</filter-class>
	</filter>

	<!-- Make sure any request you want accessible to Shiro is filtered. /* catches all -->
	<!-- requests.  Usually this filter mapping is defined first (before all others) to -->
	<!-- ensure that Shiro works in subsequent filters in the filter chain:             -->
	<filter-mapping>
	    <filter-name>ShiroFilter</filter-name>
	    <url-pattern>/*</url-pattern>
	</filter-mapping>

	<servlet>
    	<servlet-name>security</servlet-name>
    	<servlet-class>pl.avd.samples.gwt.shiro.server.SecurityServiceImpl</servlet-class>
    </servlet>

    <servlet-mapping>
    	<servlet-name>security</servlet-name>
    	<url-pattern>/login/security</url-pattern>
    </servlet-mapping>

    <servlet-mapping>
    	<servlet-name>security</servlet-name>
    	<url-pattern>/sample/security</url-pattern>
    </servlet-mapping>
</web-app>

W pliku deskryptora aplikacji webowej umieszczamy definicję filtru serwletów Shiro. Filtr ten będzie się wykonywał przy każdym każdym żądaniu do plików aplikacji. Dzięki temu w konfiguracji Shiro można zabezpieczać zarówno poszczególne wywołania serwletów, jak i zasoby statyczne. Wszystko to robimy w osobnym pliku konfiguracyjnym Shiro, a nie tak jak w przypadku JAAS’a, w deskryptorze web.xml.

shiro.ini
[main]
propRealm = org.apache.shiro.realm.text.PropertiesRealm

securityManager.realms = $propRealm

authc = org.apache.shiro.web.filter.authc.PassThruAuthenticationFilter
authc.loginUrl = /login.html?gwt.codesvr=127.0.0.1:9997

[urls]
/login.html = anon
/favicon.ico = anon
/* = authc

W sekcji [main] znajdują się podstawowe elementy konfiguracji Shiro. Na potrzeby przykładu wystarczyło tu skonfigurowanie domeny uwierzytelniania korzystającej z pliku properties, gdzie znajdują się loginy, hasła oraz role użytkowników zgodne z formatem Shiro. Dla potrzeb aplikacji GWT zastępujemy domyślny filtr authc, jakim jest org.apache.shiro.web.filter.authc.FormAuthenticationFilter, przez org.apache.shiro.web.filter.authc.PassThruAuthenticationFilter. Różnica między nimi jest taka, że FormAuthenticationFilter przekierowuje niezalogowanego użytkownika do standardowego formularza logowania, sam odbiera dane przesłane metodą POST i przekierowuje do docelowego adresu, a skorzystanie z PassThruAuthenticationFilter umożliwia przeprowadzenie całej tej procedury ręcznie. Dzięki temu możemy sami przesłać dane przez GWT-RPC, a po uwierzytelnieniu użytkownika, przekierować go do strony docelowej już po stronie klienckiej GWT.
Sekcja [urls] definiuje, które filtry mają zabezpieczać konkretne zasoby aplikacji. Dla anonimowych użytkowników udostępniamy tu stronę logowania oraz plik favicon.ico – ten ostatni zapewnia rozwiązanie problemu związanego z przeglądarką Chrome, która domyślnie przy każdym odwołaniu do strony szuka takiego pliku na serwerze. Cała reszta żądań do aplikacji opatrzona jest wcześniej skonfigurowanym filtrem authc.

LoginWidget.java
public class LoginWidget extends Composite {

  private static LoginWidgetUiBinder uiBinder = GWT.create(LoginWidgetUiBinder.class);

  interface LoginWidgetUiBinder extends UiBinder<Widget, LoginWidget> {
  }

  @UiField TextBox username;
  @UiField PasswordTextBox password;
  @UiField CheckBox rememberMe;
  @UiField Button submit;
  @UiField Label info;

  public LoginWidget() {
    initWidget(uiBinder.createAndBindUi(this));
  }

  @UiHandler("submit")
  void onFormSubmit(ClickEvent e) {
    if (!isFormValid()) {
      e.preventDefault();
      setErrorMessage("Musisz podać login i hasło!");
    } else {
      callLoginService();
    }
  }

  private void setErrorMessage(String message) {
    info.setText(message);
  }

  private boolean isFormValid() {
    return username.getValue().length() > 0 && password.getValue().length() > 0;
  }

  private void callLoginService() {
    SecurityServiceAsync service = GWT.create(SecurityService.class);

    AsyncCallback<LoginResult> callback = new AsyncCallback<LoginResult>() {

      @Override
      public void onFailure(Throwable caught) {
        setErrorMessage("Wystąpił błąd: " + caught.getMessage());
      }

      @Override
      public void onSuccess(LoginResult result) {
        if (result.isSuccess()) {
          redirectOnSuccess(result.getReferrerUrl());
        } else {
          setErrorMessage(result.getErrorMessage());
        }
      }
    };

    service.login(username.getValue(), password.getValue(), rememberMe.getValue(), callback);
  }

  private void redirectOnSuccess(String referrerUrl) {
    if (referrerUrl != null) {
      Window.Location.replace(referrerUrl);
    } else {
      Window.Location.replace(GWT.getHostPageBaseURL());
    }
  }
}

Strona logowania jest bardzo prosta, w zasadzie warte uwagi jest tutaj tylko same wysłanie danych przez GWT-RPC oraz przekierowanie do adresu docelowego po uwierzytelnieniu użytkownika.

W projekcie rozwiązaliśmy tylko kilka postawionych przeze mnie wymagań, ale to postaram się nadrobić przy okazji kolejnych wpisów. Mogę już zapowiedzieć, że kolejnym razem skupię się na realizacji SSO za pomocą Shiro oraz kilku innych ciekawostkach z tym związanych.

Pełny kod źródłowy aplikacji możesz pobrać bezpośrednio stąd. Jak zawsze zachęcam również do skorzystania z repozytorium.

Skomentuj

3 Komentarze.

  1. Popraw mnie jesli sie myle, ale o ile pamietam to pliki .ini jest domyslna konfiguracja.
    Jesli jest taka potrzeba, to mozna te same dane pobierac z innego zrodla

    • Plik shiro.ini jest domyślnym plikiem konfiguracyjnym, można zmienić jego lokalizację w konfiguracji serwletu w pliku web.xml:

      <filter>
      <filter-name>ShiroFilter</filter-name>
      <filter-class>org.apache.shiro.web.servlet.IniShiroFilter</filter-class>
      <init-param>
      <param-name>configPath</param-name>
      <param-value>file:shiro.ini</param-value>
      </init-param>
      </filter>

      Można też umieścić konfigurację Shiro bezpośrednio w konfiguracji serwletu, jednak wyciągnięcie tego do osobnego pliku jest lepszym rozwiązaniem :) Pewnie da radę też konfigurować Shiro z poziomu kodu Javy, ale nie zagłębiałem się w to aż tak.

  2. Swietny artykul! Apache Shiro rzeczywiscie jawi sie jako duzo bardziej elastyczne i przyjazne uzytkownikowi rozwiazanie. Dodatkowo jako wisienka na torcie dla zainteresowanych polecam ten podcast: http://www.youtube.com/watch?v=5ZepGFzYHpE

    Pozdrawiam!

Skomentuj


UWAGA - Możesz używać HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

CommentLuv badge