Implementacja weryfikacji obrazkowej CAPTCHA w GWT i JAAS

Istotnym problemem stron internetowych są nieproszone boty, które często uprzykrzają życie nie tylko administratorom serwisu, ale też użytkownikom, którzy chcieliby mieć dostęp do treści jak najwyższej jakości. Nic przecież tak nie drażni jak mnogość bezsensowych komentarzy z reklamami przy wpisie, dla którego np. toczyła się ciekawa dyskusja. Od lat podejmuje się próby rozróżnienia użytkownika będącego po drugiej stronie i stwierdzenia, czy mamy do czynienia z człowiekiem czy komputerem. Jednym ze sposobów jest zastosowanie CAPTCHA. Czy jest to sposób w pełni skuteczny? Jak się domyślasz, bądź wiesz – nie, jednak z pewnością eliminuje zdecydowaną większość mało zaawansowanych botów. Ważne jest też, aby CAPTCHA nie była zbytnim utrudnieniem dla właściwego użytkownika, bo nic tak nie irytuje, jak kilkukrotne odświeżanie obrazka, aby w końcu trafić na ten w miarę czytelny.

W niniejszym wpisie nie będę rozwodził się na temat skuteczności CAPTCHA, jak i przekonywał do używania tego typu zabezpieczenia lub nie. Nie chcę też sugerować konkretnego rodzaju zabezpieczenia, bo przecież oprócz najpopularniejszych  obrazków z mało czytelnymi literami i cyframi, istnieją jeszcze zabezpieczenia głosowe, czy takie polegające na wykonaniu jakiegoś zadania matematycznego, bądź inne bardziej wyrafinowane. Po prostu dostałem zadanie w firmowym projekcie polegające na implementacji weryfikacji obrazkowej i zanim się do tego zabiorę, postanowiłem nieco wcześniej rozeznać się w temacie.

W sieci można znaleźć wiele bibliotek dostarczających implementację CAPTCHA. Podczas moich poszukiwań trafiłem między innymi na promowaną przez Google’a reCAPTCHA. Jest to jedna z najlepszych implementacji i cechuje się jednym z wyższych poziomów skuteczności. Pomimo wielu zalet posiada jednak istotną wadę, aby ją używać, konieczne jest odwoływanie się do zewnętrznych usług. Na publicznej stronie jest to jak najbardziej na miejscu, jednak w aplikacji biznesowej, która może działać również w wewnętrznej sieci lokalnej, jest to niedopuszczalne. Jednak dla zainteresowanych integracją reCAPTCHA w swojej aplikacji GWT, podrzucam linka do projektu gwt-recaptcha, będącego wrapper’em AJAX’owego API reCAPTCHA. Biblioteka chyba nie jest już rozwijana, ale być może warto zerknąć w źródła.

Biblioteką, którą wybrałem, jest SimpleCaptcha, która dostarcza implementację weryfikacji obrazkowej i dźwiękowej. Co ważne, biblioteka jest cały czas rozwijana, a na stronie projektu dostępne są proste przykłady, gotowe projekty i dokumentacja kodu. Szału może nie robi, ale lepiej to niż nic. Jakiś czas temu pojawiła się też biblioteka o nazwie kaptcha, będąca swego czasu rozwinięciem SimpleCaptcha, wygląda jednak, że jej rozwój został zaniechany.

Warto też zwrócić uwagę na JCaptcha, którą z pewnością można pochwalić za dość rozbudowaną dokumentację. Stabilna wersja jest już jednak dość stara i bardziej problematyczna w użytkowaniu niż SimpleCaptcha, a nowa wersja 2.0 nie wyszła jeszcze ze stadium alpha. Natrafiłem jeszcze na projekt iCaptcha, ale tu nie znalazłem nawet jednej aktualnej wersji do pobrania.

Tak naprawdę wybór implementacji jest mało ważny. Można nawet powiedzieć, że wiele z przedstawionych bibliotek działa w bardzo podobny sposób. Zazwyczaj głównym składnikiem biblioteki jest serwlet zwracający aktualnie przypisany do obsługi żądania obrazek. Ostatnio wyświetlony obrazek w ramach tej samej sesji http jest zapamiętywany jako jej parametr, a w celu sprawdzenia poprawności po prostu porównujemy treść tego obrazka z wartością zmiennej przekazanej z formularza. Nic nie stoi więc na przeszkodzie, aby podmienić implementację bez wprowadzania drastycznych zmian w aplikacji.

W ramach dzisiejszego przykładu rozwiniemy projekt utworzony podczas implementacji logowania JAAS w GWT i kontynuowany przy rozszerzaniu go o zapamiętywanie i walidację danych logowania. Jeśli nie używasz JAAS’a, nic nie szkodzi. Przykład będzie na tyle prosty, że z pewnością poradzisz sobie z wyciągnięciem właściwych fragmentów kodu.

Strona kliencka

Dla zapewnienia większej czytelności względem poprzedniej wersji przerobiłem część kliencką na kod UiBinder’a.

LoginWidget.ui.xml

<!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">
<ui:UiBinder xmlns:ui="urn:ui:com.google.gwt.uibinder"
  xmlns:g="urn:import:com.google.gwt.user.client.ui">

  <ui:style>
    .login-table {
    border: 1px solid black;
    background-color: #ddddff;
  }
  </ui:style>

  <g:HTMLPanel>
    <table class="{style.login-table}">
      <tr><th colspan="2">Panel Logowania</th></tr>

      <tr><td>Login:</td><td><g:TextBox ui:field="username"/></td></tr>
      <tr><td>Hasło:</td><td><g:TextBox ui:field="password"/></td></tr>

      <tr><td>Captcha:</td><td><g:Image ui:field="captcha" url="/captcha"/></td></tr>
      <tr><td>Kod:</td><td><g:TextBox name="captcha-answer"/></td></tr>

      <tr><td colspan="2"><g:Button ui:field="submit"/></td></tr>
      <tr><td colspan="2"><g:Label ui:field="info"/></td></tr>
    </table>
  </g:HTMLPanel>
</ui:UiBinder>

W linii 19-tej zdefiniowany został widżet, do którego będzie ładowany aktualny obrazek walidacji. Jego adresem URL jest „/captcha” i to właśnie pod ten adres przypiszemy później serwlet SimpleCaptcha. TextBox z linii 20-tej, to pole w które będziemy wpisywali kod widoczny na obrazku.

LoginWidget.java

public class LoginWidget extends Composite {

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

  interface LoginWidgetUiBinder extends UiBinder<Widget, LoginWidget> {
  }

  @UiField(provided = true) TextBox username;
  @UiField(provided = true) PasswordTextBox password;
  @UiField Image captcha;
  @UiField(provided = true) Button submit;
  @UiField Label info;

  public LoginWidget() {
    username = TextBox.wrap(Document.get().getElementById("form-login-field-username"));
    password = PasswordTextBox.wrap(Document.get().getElementById("form-login-field-password"));
    submit = SubmitButton.wrap(Document.get().getElementById("form-login-submit"));    

    initWidget(uiBinder.createAndBindUi(this));

    if (isError()) {
      setErrorMessage("Wprowadzono niepoprawne dane!");
    }
  }

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

  @UiHandler("captcha")
  void onCaptchaRefresh(ClickEvent e) {
    captcha.setUrl(captcha.getUrl() + "?timestamp=" + new Date().getTime());
  }

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

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

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

Jeśli nie znasz jeszcze UiBinder’a, na tym etapie wystarczy wiedzieć, że klasa ta jest drugą częścią składową pojedynczego elementu widoku. Związana jest ściśle z zaprezentowanym wyżej plikiem ui.xml i zawiera deklaracje pól tam używanych (adnotacja @UiField). Atrybut adnotacji provided=true oznacza, że utworzeniem danego elementu ma się zająć programista, a nie UiBinder (normalnie wszystkie elementy użyte w pliku ui.xml powoływane są automatycznie). Odpowiednie elementy są więc tworzone w liniach 35, 36 i 37.
Potrzebujemy też dwóch handler’ów odczytujących zdarzenia odpowiednio po kliknięciu na przycisk i kliknięciu na obrazek CAPTCHA. Pierwszy z nich będzie zatwierdzał nasz formularz logowania i został zaimplementowany w liniach 46-52. Drugi odświeża i generuje nowy obrazek, jeśli poprzedni jest np. nieczytelny (linie 54-57). Samo odświeżanie obrazka polega na podmianie adresu URL w obiekcie klasy Image i wymaga podawania za każdym razem nowego adresu (w tym celu dokładany jest parametr timestamp z aktualnym czasem). Musi być właśnie tak, gdyż GWT nie wie, że obrazek generowany jest dynamicznie i nie odświeży go, dopóki nie zmieni się jego adres. Parametr timestamp nie jest więc używany w żadnym innym miejscu i w zasadzie może zostać zastąpiony dowolną losowo generowaną zmienną.

LoginModule.java

public class LoginModule implements EntryPoint {

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

  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() {
    LoginWidget widget = new LoginWidget();
    RootPanel.get("login-panel").add(widget);
  }
}

W linii 35 formularz logowania wstrzykiwany jest do elementu „login-panel”, który zadeklarowany zostanie w pliku HTML modułu.

Strona serwerowa

W części serwerowej najważniejszą klasą jest w zasadzie implementacja naszego modułu logowania. To właśnie tu będziemy weryfikowali wprowadzone przez użytkownika dane i pobierali ostatnio wygenerowane CAPTCHA dla bieżącej sesji. Wadą zastosowania uwierzytelniania JAAS jest tu z pewnością wymóg implementacji modułu logowania dla każdego serwera, na którym chcemy osadzić naszą aplikację. Nie jest to jednak trudne i zazwyczaj sprowadza się do zaimplementowania kilku prostych metod. Tak jest, jeśli uwierzytelnianie przebiega tylko na podstawie nazwy użytkownika i hasła. Gorzej, jeśli chcemy trochę powydziwiać – wtedy zaczynają się schody. Aby dobrać się do obiektu HttpSession (w którym przechowywana jest przecież nasza CAPTCHA), spędziłem na poszukiwaniu rozwiązania dobre 2 godziny. Kontener Jetty jest zresztą słabo udokumentowany pod tym względem i często sposobu trzeba poszukiwać metodą prób i błędów. No dobrze, tak to się robi w Jetty:

CaptchaCallback.java

public class CaptchaCallback implements Callback {

  private Captcha captcha;

  public Captcha getCaptcha() {
    return captcha;
  }

  public void setCaptcha(Captcha captcha) {
    this.captcha = captcha;
  }
}

Potrzebujemy implementacji interfejsu javax.security.auth.callback.Callback, do której przekażemy nasz obiekt Captcha. Callback’i powinny być raczej używane do odbierania danych przekazanych bezpośrednio przez użytkownika, jednak w przypadku Jetty użycie Callback’a okazało się jedynym sposobem na dobranie się do obiektu HttpSession.

CaptchaCallbackHandler.java

public class CaptchaCallbackHandler extends DefaultCallbackHandler {

  @Override
  public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
    try {
      super.handle(callbacks);
    } catch (UnsupportedCallbackException e) {
      int handled = 0;

      for (Callback callback : callbacks) {
        if (callback instanceof CaptchaCallback) {
          HttpServletRequest request = getRequest();
          Captcha captcha = (Captcha) request.getSession().getAttribute(Captcha.NAME);
          ((CaptchaCallback) callback).setCaptcha(captcha);
          handled++;
        }
      }

      if (handled != 1) {
        throw e;
      }
    }
  }

  protected HttpServletRequest getRequest() {
    try {
      Field requestField = DefaultCallbackHandler.class.getDeclaredField("request");
      requestField.setAccessible(true);
      HttpServletRequest request = (HttpServletRequest) requestField.get(this);
      return request;
    } catch (Exception e) {
      e.printStackTrace();
      return null;
    }
  }
}

CallbackHandler ma za zadanie odczytywać wartości przekazane w formularzu logowania przez użytkownika i wrzucać je do odpowiednich Callback’ów. Wszystkie te czynności delegujemy więc do DefaultCallbackHandler’a dziedzicząc właśnie po tej klasie. Jeśli wśród Callback’ów znajdzie się nasz CaptchaCallback, musimy pobrać i wstawić do niego parametr z obiektu HttpSession. Obiekt HttpSession zaszyty jest w klasie DefaultCallbackHandler, jednak przechowywany jest w składowej prywatnej, do której nie ma dostępu przez jakiegokolwiek getter’a. W liniach 38-48 posługujemy się więc refleksją i uzyskujemy dostęp do zmiennej przechowującej obiekt HttpServletRequest. Stąd już prosta droga do wyciągnięcia obiektu sesji i wybrania poszukiwanego parametru (linia 26).

SampleLoginModule.java

public class SampleLoginModule extends PropertyFileLoginModule {

  @Override
  public Callback[] configureCallbacks() {
    Callback[] callbacks = new Callback[4];
    callbacks[0] = new NameCallback("Enter user name");
    callbacks[1] = new PasswordCallback("Enter password", false);

    RequestParameterCallback c = new RequestParameterCallback();
    c.setParameterName("captcha-answer");
    callbacks[2] = c;

    callbacks[3] = new CaptchaCallback();
    return callbacks;
  }

  @Override
  public boolean login() throws LoginException {
    try {
      if (getCallbackHandler() == null)
        throw new LoginException("No callback handler");

      Callback[] callbacks = configureCallbacks();
      getCallbackHandler().handle(callbacks);

      String webUserName = ((NameCallback) callbacks[0]).getName();
      char[] webPassword = ((PasswordCallback) callbacks[1]).getPassword();
      String webCaptchaAnswer = getValueFromCallback(callbacks[2]);
      Captcha captcha = ((CaptchaCallback) callbacks[3]).getCaptcha();

      if ((webUserName == null) || (webPassword == null)) {
        setAuthenticated(false);
        return isAuthenticated();
      }

      if (captcha == null || webCaptchaAnswer == null) {
        setAuthenticated(false);
        return isAuthenticated();
      }

      if (!captcha.isCorrect(webCaptchaAnswer)) {
        setAuthenticated(false);
        return isAuthenticated();
      }

      UserInfo userInfo = getUserInfo(webUserName);

      if (userInfo == null) {
        setAuthenticated(false);
        return isAuthenticated();
      }

      setCurrentUser(new JAASUserInfo(userInfo));
      setAuthenticated(getCurrentUser().checkCredential(String.valueOf(webPassword)));
      return isAuthenticated();
    } catch (IOException e) {
      throw new LoginException(e.toString());
    } catch (UnsupportedCallbackException e) {
      throw new LoginException(e.toString());
    } catch (Exception e) {
      e.printStackTrace();
      throw new LoginException(e.toString());
    }
  }

  protected String getValueFromCallback(Callback callback) {
    if (callback instanceof RequestParameterCallback) {
      RequestParameterCallback rpc = (RequestParameterCallback) callback;
      return getFirstValueFromList(rpc.getParameterValues());
    }

    return null;
  }

  protected String getFirstValueFromList(List<?> list) {
    return (String) list.get(0);
  }
}

W Jetty jeśli chcemy utworzyć własny moduł logowania, zaleca się dziedziczyć po klasie org.mortbay.jetty.plus.jaas.spi.AbstractLoginModule. Skorzystamy jednak z gotowej implementacji modułu sprawdzającego dane logowania w pliku .properties. Dziedziczenie po org.mortbay.jetty.plus.jaas.spi.PropertyFileLoginModule w naszym przypadku wymaga nadpisania dokładnie tych samych metod, które byśmy modyfikowali implementując całkowicie własny moduł logowania.
Nadpisując metodę configureCallbacks, zwracamy tablicę Callback’ów przechowujących niezbędne dane (standardowo zwracany jest NameCallback i PasswordCallback). RequestParameterCallback pozwala na wyciągnięcie dowolnego parametru przekazywanego przez formularz logowania. Dzięki niemu dobieramy się do pola o nazwie „captcha-answer”, w które użytkownik wpisuje kod odczytany z CAPTCHA. Gdybyśmy implementowali CAPTCHA bez użycia JAAS’a, dostęp do tego parametru uzyskalibyśmy pobierając go bezpośrednio z obiektu HttpServletRequest. Do CaptchaCallback, jak pisałem wcześniej, zostanie wstawiony obiekt Captcha zawierający informacje o aktualnym kodzie weryfikującym.
Metoda login jest w zasadzie żywcem przeniesiona z klasy bazowej, wzbogacona została jedynie o linie 45-46 i 53-61. To co nas interesuje, czyli weryfikacja wprowadzonego kodu, odbywa się w linii 58. Co ciekawe, wewnątrz metody login obiekt zwracany poprzez metodę getCallbackHandler nie jest naszym CaptchaCallbackHandler’em, a obiektem klasy javax.security.auth.login.LoginContext$SecureCallbackHandler. Przeglądałem kod źródłowy Jetty i zupełnie nie mogłem znaleźć miejsca, gdzie następuje ta podmiana, wręcz wszystko wskazywało na to, że powinienem otrzymać właściwą implementację. Tak się jednak nie działo i przyznaję, że nie wiem czemu :)

 

Pozostałe pliki nie zmieniły się zbytnio względem poprzedniej wersji projektu, w celu zapoznania się z nimi zachęcam do przeczytania poprzednich wpisów. Do pliku web.xml musimy jeszcze wstawić deklarację serwletu SimpleCaptcha:

<servlet>
  <servlet-name>captcha</servlet-name>
  <servlet-class>nl.captcha.servlet.SimpleCaptchaServlet</servlet-class>
</servlet>

<servlet-mapping>
  <servlet-name>captcha</servlet-name>
  <url-pattern>/captcha</url-pattern>
</servlet-mapping>

Plik jetty-web.xml natomiast musi zostać uzupełniony o ustawienie naszego CallbackHandler’a:

<Set name="userRealm">
  <New class="org.mortbay.jetty.plus.jaas.JAASUserRealm">
    <Set name="name">SAMPLE</Set>
    <Set name="LoginModuleName">sample-module</Set>
    <Set name="CallbackHandlerClass">pl.avd.samples.gwt.auth.server.CaptchaCallbackHandler</Set>
  </New>
</Set>

No i to w zasadzie tyle. Po uruchomieniu projektu powinniśmy uzyskać następujący efekt:

 

Biblioteka SimpleCaptcha umożliwia dostosowanie sposobu generowania obrazka do własnych potrzeb. Pewne elementy konfiguracyjne można zawrzeć w pliku web.xml, większe zmiany wymagają napisania własnej implementacji serwletu. Po szczegóły odsyłam do dokumentacji biblioteki.

Kody źródłowe projektu można oczywiście pobrać z repozytorium. Gotowa paczka ze źródłami aplikacji znajduje się tutaj. Jeszcze jedno: gdybyś miał problem z uruchomieniem projektu, bo biblioteka SimpleCaptcha nie będzie dostępna na żadnym repozytorium, należy pobrać ją ręcznie i umieścić na lokalnym repozytorium poleceniem:

mvn install:install-file -Dfile=simplecaptcha-1.2.1.jar -DgroupId=nl.captcha -DartifactId=simplecaptcha -Dversion=1.2.1 -Dpackaging=jar
Skomentuj

10 Komentarze.

  1. Czegoś nie rozumiem: jeżeli projekt ma być do użytku wewnętrznego, to po co CAPTCHA? :) Jeżeli nie – to dlaczego nie reCAPTCHA? :)

    • Duże firmy, dodatkowo zbzikowane na punkcie bezpieczeństwa i wykorzystujące tylko technologie sprzed wielu lat (bo nowe są jeszcze zbyt nowe), krzywo patrzą na jakiekolwiek rozwiązania, które dodatkowo muszą dociągać dane z zewnątrz. Niestety nie ja o tym decyduje :)

  2. Obecnie od weryfikacji chyba lepiej sprawują się proste pytania, byle nie w stylu 2+2, ale np jak ma na imię Jan Nowak?
    hotfix ostatnio napisał(a)… Ultracienki laptop Fuijtsu Lifebook SH771

    • Jest wręcz przeciwnie. Może w przypadku niewielkich serwisów, do których raczej nikt się specjalnie nie włamuje, to się to sprawdza. Wszelkie boty, które automatycznie przeszukują wszystkie strony, zostaną w ten sposób zatrzymane. Gorzej, jeśli ktoś zajmie się właśnie taką konkretną stroną, wtedy zbudowanie bazy dostępnych pytań i odpowiedzi dla potencjalnego włamywacza nie będzie stanowiło żadnego problemu. Dlatego najlepsze rozwiązania są w stylu reCAPTCHA, gdyż w ich przypadku stworzenie bazy odpowiedzi jest praktycznie niemożliwe.

      • myślę, że jeśli ktoś będzie chciał i zajmie się tylko konkretną stroną, to powinieneś martwić się innymi atakami i problemami a nie tylko PRÓBĄ rozróżnienia człowieka od automatu

        • Oczywiście że o inne zabezpieczenia też trzeba dbać, jednak twierdzenie, że captcha w postaci pytań i odpowiedzi jest lepsza od tradycyjnej jest błędne. Jedyną zaletą jest dość niszowe wykorzystanie i róźne zestawy pytań na różnych stronach. Domyślcie się co by było gdyby wykorzystać coś takiego na masową skalę z tym samym zestawem pytań.

  3. Z moich doświadczeń wynika, że lepszym zabezpieczeniem przed botami jest jakieś proste pytanie np. „Co ma Ala?”. Jest to również bardziej przyjazne dla użytkowników niż tradycyjna Captcha.
    GR ostatnio napisał(a)… Goalunited

    • Tak jak pisałem wyżej, tego typu zabezpieczenie jest dobre, jeśli bronimy się przed botami skanującymi wszystko co popadnie. Jeśli jednak ktoś zajmie się konkretnie naszą stroną, stworzenie automatu odpowiadającego na takie pytania jest bajecznie proste.

  4. Według mnie prosta captcha „przyjazna dla użytkowników” nie ma sensu. Łatwo to oszukać.
    Ciekawe czy nie ma problemu z taką implementacją na różnych serwerach?

    • Mówisz o SimpleCaptcha? Sprawdzałem już na Jetty 6, GlassFish v3 i WebSphere 6.1 – działa bez problemów. W paczce pod jave 5 jest błąd w skrypcie ant’a i biblioteka buduje sie pod jave 6, ale po drobnej poprawce wszystko chodzi.

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