Uwierzytelnianie i autoryzacja w GWT za pomocą JAAS

Czas na pierwszy artykuł związany z tym, czym zajmuje się na co dzień. Tym wpisem chcę również zapoczątkować ciąg artykułów na temat GWT, w których będę starał się pokazać co ciekawsze sposoby wykorzystania tej biblioteki. Na dzień dzisiejszy przygotowałem dość ważny temat z punktu widzenia aplikacji webowych, którym jest implementacja mechanizmu bezpieczeństwa za pomocą JAAS (Java Authentication and Authorization Service).

Zaczynając pisać jakąkolwiek aplikację w pewnym momencie zaczynasz myśleć o jej zabezpieczeniu. Piszesz własny formularz logowania, implementujesz obsługę żądania logowania i wylogowywania użytkownika, tworzysz własną obsługę sesji. Jak zapewne wiesz, ten element kodu jest często powtarzalny w każdej nowej aplikacji. Czemu więc nie wyciągnąć go na zewnątrz i nie wykorzystywać jednego fragmentu kodu wszędzie? Z pomocą przychodzi JAAS, framework pozwalający na zapewnienie bezpieczeństwa w każdej aplikacji pisanej w Java. Co daje nam JAAS? Ano to, że nie musimy za każdym razem pisać wielu zbliżonych do siebie linijek kodu, że nie musimy martwić się o błędne zaimplementowanie całego mechanizmu. JAAS pozwala również na wydelegowanie zarządzania użytkownikami i rolami do serwera aplikacji lub innego systemu. Ważnym elementem jest również tak zwane SSO (Single Sing-On), czyli mechanizm jednokrotnego uwierzytelniania do wielu aplikacji korzystających z tych samych dostawców bezpieczeństwa. W tym artykule chcę Ci zaprezentować, jak wdrożyć JAAS w aplikacji opartej na GWT.

Zanim zaczniemy, ustalmy kilka szczegółów. Aplikacja będzie dość prosta, zakładam jednak, że masz już jakąś podstawową wiedzę na temat GWT i umiesz sam stworzyć prosty projekt. Nie będzie więc tu przewodnika „krok po kroku” a jedynie kluczowe fragmenty kodu. Oczywiście zamieszczę pod koniec przykładowy gotowy projekt, wszelkie wątpliwości rozwiejesz więc przeglądając źródła.

Na początek potrzebujemy dwóch modułów GWT: jeden reprezentujący stronę logowania, drugi będzie udawał pewien zasób chroniony. Moduły będą reprezentowane odpowiednio przez pliki login.html i index.html. Zacznijmy od modułu z zasobem chronionym, który będzie się nazywał „Sample”.

Sample.gwt.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC "-//Google Inc.//DTD Google Web Toolkit 1.6.4//EN" "http://google-web-toolkit.googlecode.com/svn/tags/1.6.4/distro-source/core/src/gwt-module.dtd">
<module rename-to="sample">

  <!-- Inherit the core Web Toolkit stuff.                  -->
  <inherits name="com.google.gwt.user.User"/>

  <!-- Specify the app entry point class.                   -->
  <entry-point class="pl.avd.samples.gwt.auth.client.SampleModule"/>

</module>

Jest to plik deskryptora GWT dla modułu zasobu chronionego. W linii nr 3 występuje atrybut „rename-to”, który powoduje skrócenie generowanych nazw modułu przez kompilator GWT. Zamiast „pl.avd.samples.gwt.auth.Sample” będziemy mieli „sample”.

index.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="pl" xml:lang="pl" xmlns:v="urn:schemas-microsoft-com:vlm">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta http-equiv="Content-Language" content="pl-pl" />

    <title>Sample GWT Auth - Protected page</title>
  </head>

  <body>
    <script type="text/javascript" src="sample/sample.nocache.js"></script>
  </body>
</html>

Strona index.html będzie reprezentowała zasób chroniony. Wstrzykujemy do niej zdefiniowany wcześniej moduł GWT (linia nr 11).

SampleModule.java
public class SampleModule implements EntryPoint {

	private Logger LOG = Logger.getLogger("SAMPLE");

	private SecurityServiceAsync security = GWT.create(SecurityService.class);

	public void onModuleLoad() {
		GWT.setUncaughtExceptionHandler(new GWT.UncaughtExceptionHandler() {

			public void onUncaughtException(Throwable caught) {
				LOG.log(Level.SEVERE, "uncaught exception", caught);
			}
		});

		Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() {

			public void execute() {
				init();
			}
		});
	}

	private void init() {
		FlowPanel layout = new FlowPanel();
		layout.add(new Label("Witaj w zasobie chronionym!"));

		Button logoutBtn = new Button("Wyloguj");
		logoutBtn.addClickHandler(new ClickHandler() {

			public void onClick(ClickEvent event) {
				callLogoutService();
			}
		});
		layout.add(logoutBtn);

		RootPanel.get().add(layout);
	}

	private void callLogoutService() {
		AsyncCallback callback = new AsyncCallback() {

			public void onFailure(Throwable caught) {
				LOG.log(Level.SEVERE, "logout exception", caught);
			}

			public void onSuccess(Void result) {
				Window.Location.reload();
			}
		};

		security.logout(callback);
	}
}

To co dzieje się w metodzie onModuleLoad() ma na celu zainicjowanie rejestrowania nieprzechwyconych w aplikacji wyjątków, nie będę więc tego szczegółowo omawiał. Metoda init() tworzy prosty panel z chronioną zawartością oraz przycisk, który posłuży nam do wylogowywania. Metoda callLogoutService() wywołuje stronę serwerową, która wylogowuje użytkownika, oraz odświeża aktualną stronę, dzięki czemu sprawdzimy, czy dostęp do chronionego zasobu będzie dalej możliwy.

Przejdźmy teraz do modułu, który będzie pełnił rolę strony logowania. Nazwijmy go „Login”.

Login.gwt.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC "-//Google Inc.//DTD Google Web Toolkit 1.6.4//EN" "http://google-web-toolkit.googlecode.com/svn/tags/1.6.4/distro-source/core/src/gwt-module.dtd">
<module rename-to="login">

  <!-- Inherit the core Web Toolkit stuff.                  -->
  <inherits name="com.google.gwt.user.User"/>

  <!-- Specify the app entry point class.                   -->
  <entry-point class="pl.avd.samples.gwt.auth.client.LoginModule"/>

</module>

Ponownie mamy plik deskryptora GWT, tym razem dla modułu logowania. Poprzez atrybut „rename-to” skracamy generowaną nazwę modułu do „login”.

login.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="pl" xml:lang="pl" xmlns:v="urn:schemas-microsoft-com:vlm">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta http-equiv="Content-Language" content="pl-pl" />

    <link rel="stylesheet" href="login/css/style.css" type="text/css" />

    <title>Sample GWT Auth - Login Module</title>
  </head>

  <body>
    <script type="text/javascript" src="login/login.nocache.js"></script>

    <form method="post" action="j_security_check">
      <div id="login-panel"></div>
    </form>
  </body>
</html>

Strona login.html reprezentuje nasz formularz logowania. W linii nr 13 wstrzykujemy odpowiedni moduł GWT. Poniżej mamy zdefiniowany formularz, który posłuży nam do przekazania danych o użytkowniku. Zwróć uwagę na atrybuty elementu form: formularz wysyłamy metodą POST, a jako akcję podajemy „j_security_check”. Akcja ta spowoduje wysłanie żądania do wbudowanego mechanizmu uwierzytelniania, z którego skorzystamy. Wewnątrz formularza zdefiniowany został element o identyfikatorze „login-panel”, do którego wstrzyknięty zostanie panel GWT.

LoginModule.java
public class LoginModule implements EntryPoint {

	private Logger LOG = Logger.getLogger("LOGIN");

	private TextBox loginField;
	private PasswordTextBox passwordField;

	private Button submitBtn;

	public void onModuleLoad() {
		GWT.setUncaughtExceptionHandler(new GWT.UncaughtExceptionHandler() {

			public void onUncaughtException(Throwable caught) {
				LOG.log(Level.SEVERE, "uncaught exception", caught);
			}
		});

		Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() {

			public void execute() {
				init();
			}
		});
	}

	private void init() {
		loginField = new TextBox();
		loginField.setName("j_username");

		passwordField = new PasswordTextBox();
		passwordField.setName("j_password");

		submitBtn = new SubmitButton("Zaloguj");

		FlexTable layout = new FlexTable();

		FlexCellFormatter cellFormatter = layout.getFlexCellFormatter();
		cellFormatter.setColSpan(0, 0, 2);
		cellFormatter.setColSpan(3, 0, 2);
		cellFormatter.setHorizontalAlignment(0, 0, HasHorizontalAlignment.ALIGN_CENTER);
		cellFormatter.setHorizontalAlignment(3, 0, HasHorizontalAlignment.ALIGN_CENTER);

		layout.setHTML(0, 0, "Panel logowania");
		layout.setHTML(1, 0, "Login:");
		layout.setWidget(1, 1, loginField);
		layout.setHTML(2, 0, "Hasło:");
		layout.setWidget(2, 1, passwordField);
		layout.setWidget(3, 0, submitBtn);

		if (isError()) {
			cellFormatter.setColSpan(4, 0, 2);
			cellFormatter.setHorizontalAlignment(4, 0, HasHorizontalAlignment.ALIGN_CENTER);

			layout.setHTML(4, 0, "Nieprawidłowy login lub hasło!");
		}

		RootPanel.get("login-panel").add(layout);
	}

	private boolean isError() {
		String error = Window.Location.getParameter("error");
		return (error != null &amp;&amp; error.equals("1"));
	}
}

Metoda onModuleLoad() jest identyczna jak w poprzednim module. W metodzie init() tworzone są niezbędne pola formularza oraz przycisk wysyłający dane formularza. Zwróć uwagę na nazwy nadane polu loginu oraz hasła użytkownika (j_username i j_password). Do odpowiedniego rozmieszczenia pól wraz z ich etykietami został użyty komponent FlexTable. Dodatkowo metoda isError() sprawdza parametr w adresie URL, który będzie nam zgłaszał wystąpienie błędu przy logowaniu. Jeśli parametr „error” wystąpi i będzie miał wartość „1”, stosowny komunikat zostanie wyświetlony. Stworzony panel wstrzykujemy do elementu „login-panel”, który wcześniej zdefiniowaliśmy w pliku login.html.

Zatrzymamy się w tym miejscu na chwilę, gdyż  muszę wyjaśnić takie, a nie inne tworzenie formularza. Formularz w GWT można utworzyć również za pomocą klasy FormPanel, jednak w tym przypadku cała jego obsługa (wysyłanie danych formularza) odbywa się poprzez wbudowaną ramkę, tak aby nie była odświeżana cała strona, na której formularz występuje. Niestety takie podejście gryzie się z wbudowanym w Javę mechanizmem uwierzytelniania i powoduje, że nie jesteśmy automatycznie przełączani do strony z zasobem chronionym, ani nie uzyskamy parametru w adresie URL, który zakomunikuje nam wystąpienie błędu. W internecie istnieje kilka obejść tego problemu, wprowadzają jednak kilka ograniczeń, jak chociażby zaszycie nazwy strony z adresem chronionym w module logowania. Z tego też powodu nie działałoby wspomniane wcześniej przeze mnie SSO, a tego przecież nie chcemy!

Zanim zabezpieczymy nasz zasób, dodajmy jeszcze serwis RPC odpowiedzialny za wylogowywanie użytkownika.

SecurityServiceImpl
public class SecurityServiceImpl extends RemoteServiceServlet implements SecurityService {

	public void logout() {
		getThreadLocalRequest().getSession().invalidate();
	}
}

W celu wylogowania użytkownika wystarczy pobrać jego sesję i ją unieważnić. Służy do tego metoda invalidate() wywoływana na obiekcie sesji. W praktyce często robi się jeszcze inne czynności przed wylogowaniem użytkownika, chociażby czyszczenie jego konfiguracji – to wszystko można umieścić wewnątrz metody logout() przed unieważnieniem sesji. Jako że znasz już trochę GWT (gdybyś nie znał, pewnie byś tego nie czytał?), domyślasz się jak powinny wyglądać interfejsy SecurityService i SecurityServiceAsync. Dodam tylko, że serwis RPC umieściłem pod adresem „sample/security”.

Przygotowaliśmy więc już część kliencką aplikacji GWT wraz z odpowiednimi plikami HTML oraz stworzyliśmy serwis, który wylogowuje użytkownika unieważniając jego sesję. Przejdźmy więc do zabezpieczenia naszego zasobu chronionego.

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>SampleGWTAuth</display-name>
  <description>Sample GWT: Auth</description>

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

  <security-constraint>
    <web-resource-collection>
      <web-resource-name>Protected resource</web-resource-name>
      <url-pattern>/index.html</url-pattern>
    </web-resource-collection>
    <web-resource-collection>
      <web-resource-name>Protected service</web-resource-name>
      <url-pattern>/sample/security</url-pattern>
    </web-resource-collection>
    <auth-constraint>
      <role-name>USER</role-name>
    </auth-constraint>
  </security-constraint>

  <security-role>
    <role-name>USER</role-name>
  </security-role>

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

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

Plik web.xml reprezentuje strukturę aplikacji webowej i pozwala na zabezpieczenie poszczególnych jej obszarów. Za pomocą elementu <security-constraint> można określić reguły, którymi serwer kieruje się dopuszczając użytkowników do zasobów aplikacji. W naszym przykładzie mamy dwa zasoby: plik index.html oraz serwis RPC pod adresem /sample/security. Dostęp do tych zasobów zostanie udzielony użytkownikom należącym do roli USER. W elemencie określamy role dostępne dla naszej aplikacji. Poniżej znajduje się jeszcze definicja serwletu pełniącego rolę serwisu RPC, która pewnie wydaje Ci się znajoma. Jak widać, mapowanie serwletu na adres URL zgadza się z adresem URL określonym jako zasób chroniony.

Potrzebujemy jeszcze miejsca, w którym zdefiniujemy naszych użytkowników i nadamy im odpowiednie role. Zazwyczaj robi się to na serwerze aplikacji (np. GlassFish, JBoss), my jednak potrzebujemy działającej aplikacji w trybie deweloperskim GWT. Jako że obecna wersja GWT używa kontenera serwletów Jetty, to właśnie dla niego przygotujemy stosowną konfigurację.

jetty-web.xml
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://jetty.mortbay.org/configure.dtd">
<Configure class="org.mortbay.jetty.webapp.WebAppContext">

  <Call class="java.lang.System" name="setProperty">
    <Arg>java.security.auth.login.config</Arg>
    <Arg>etc/login.conf</Arg>
  </Call>

  <Get name="securityHandler">
    <Set name="userRealm">
      <New class="org.mortbay.jetty.plus.jaas.JAASUserRealm">
        <Set name="name">SAMPLE</Set>
        <Set name="LoginModuleName">sample-module</Set>
      </New>
    </Set>

    <Set name="authenticator">
      <New class="org.mortbay.jetty.security.FormAuthenticator">
        <Set name="loginPage">/login.html?gwt.codesvr=127.0.0.1:9997</Set>
        <Set name="errorPage">/login.html?gwt.codesvr=127.0.0.1:9997&error=1</Set>
      </New>
    </Set>
  </Get>
</Configure>

Nie będę omawiał tu możliwości konfiguracyjnych Jetty, więc jeśli jesteś zainteresowany, odsyłam Cię do dokumentacji. Warto tu zaznaczyć, że jetty-web.xml jest chyba jedynym plikiem wczytywanym przez Jetty wbudowane w GWT podczas uruchamiania trybu deweloperskiego. Nie można więc tej konfiguracji umieścić w głównym pliku konfiguracyjnym jetty.xml. Niesie to za sobą pewne ograniczenia, ale o tym może opowiem przy okazji artykułu na nieco inny temat. W naszym przykładzie ustawiamy na początku właściwość dla klasy java.lang.System, która wskaże nam lokalizację pliku ustawień modułu bezpieczeństwa. W pliku tym określimy, skąd będziemy pobierali użytkowników i role. Następnie tworzymy nową domenę bezpieczeństwa i nadajemy jej nazwę „SAMPLE”. Określamy również moduł logowania jako „sample-module”. Nazwą modułu logowania posłużymy się we wskazanym wcześniej pliku z ustawieniami. Kolejnym krokiem jest określenie adresu strony logowania oraz strony błędu, w naszym przypadku jest to ten sam adres, a błąd zgłaszamy poprzez parametr „error”. Jako że ta konfiguracja będzie używana tylko przez Jetty z trybu deweloperskiego, potrzebujemy też parametru „gwt.codesvr”. Korzystamy tutaj ze sposobu logowania tak zwanego FORM-Based, który powoduje wyświetlenie własnego formularza logowania. Istnieją inne sposoby logowania, jak chociażby BASIC – wyświetlający standardowe okno przeglądarki z loginem i hasłem do wpisania. Naszym celem było jednak oparcie wszystkiego na GWT, co jest lepsze chociażby ze względów estetycznych. Stronę logowania można też zdefiniować w pliku web.xml i właśnie z takim podejściem spotkasz się o wiele częściej. Konfiguracja tego w pliku jetty-web.xml ma tą przewagę, że wgrywając naszą aplikację na inny serwer aplikacji będziemy mogli wybrać zupełnie inne ustawienia. Oczywiście można też przygotować kilka plików web.xml i podmieniać je odpowiednio przed wgraniem aplikacji na dany serwer. Konfiguracja domeny bezpieczeństwa w tym miejscu jest konieczna, inaczej Jetty nie będzie mogło poprawnie zainicjować pliku ustawień modułu bezpieczeństwa.

login.conf
sample-module {
  org.mortbay.jetty.plus.jaas.spi.PropertyFileLoginModule required
  debug=true
  file="etc/login.properties";
};

Plik ustawień JAAS zawiera wpis dotyczący zdefiniowanego wcześniej przez nas modułu logowania. Można tutaj określić klasę odpowiedzialną za pobieranie użytkowników i ról. Jetty w dodatkowej bibliotece jetty-plus dostarcza cztery gotowe moduły, które potrafią wczytywać użytkowników i role z bazy danych (połączenie JDBC lub DataSource), z katalogu Ldap’a, lub pliku *.properties. Jako że nasza aplikacja ma być maksymalnie uproszczona, skorzystamy z ostatniego sposobu.

login.properties
test1=pass1,USER
test2=pass2,USER

Struktura pliku jest bardzo prosta. Jeden wiersz, to jeden użytkownik. Login użytkownika oddzielony jest znakiem = od hasła użytkownika, następnie po przecinku wymieniane są role, do których użytkownik należy.

Zgodnie z obietnicą, do artykułu dołączam plik z powyższym projektem. Projekt uruchomisz za pomocą narzędzia maven wydając polecenie „mvn gwt:run”.

Czas na pewne podsumowanie. Stworzony projekt skutecznie zabezpiecza zasoby zdefiniowane jako chronione w pliku web.xml i dopuszcza do nich jedynie użytkowników należących do roli USER. W momencie odwołania do jakiegokolwiek zasobu chronionego serwer przekierowuje żądanie do strony logowania, a po poprawnym uwierzytelnieniu powraca do poprzedniego adresu. Zgodnie z oczekiwaniami działa również wylogowywanie, po przeładowaniu strony użytkownik przekierowywany jest do strony logowania. Projekt nie jest jednak doskonały i ma kilka istotnych wad. Przede wszystkim chodzi o przesyłanie hasła z przeglądarki do serwera bez szyfrowania. Najlepszym rozwiązaniem jest otwieranie strony logowania zabezpieczonej SSL’em, wtedy hasło przesyłane jest zaszyfrowanym kanałem. Nie zawsze jednak tak można i wtedy trzeba się pokusić o szyfrowanie/hashowanie hasła po stronie klienta w GWT. Projektu nie uruchomiliśmy jeszcze na normalnym serwerze, co wymagałoby utworzenia dodatkowej konfiguracji. Tymi tematami chciałbym się jednak zająć w kolejnych artykułach, w których myślę, że razem ze mną będziesz rozbudowywał stworzoną dziś aplikację.

Zamieszczam też kilka przydatnych linków, gdybyś chciał poszerzyć swoją wiedzę (co jest nawet wskazane, jeśli przebrnąłeś z zaciekawieniem przez ten artykuł):

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