Suche
Teil 13: Backend meets Frontend: Nutzer- und Rollenmanagement

Webanwendungen absichern mit Apache Shiro

Sven Ruppert

© Shutterstock / HelenField

Die Grundsteine für Security-Maßnahmen gilt es möglichst früh in einem Projekt zu legen. Das gilt besonders für die Verwaltung von Nutzern und Rollen. Das Apache-Projekt Shiro hilft dabei Webanwendungen abzusichern.

Im letzten Teil haben wir uns mit dem Reload beschäftigt, um sicherzustellen, dass ein Neuladen der Webseite nicht dazu führt, dass der Anwender sich erneut anmelden muss. Hierzu hat sich die Anwendung nach dem Anmeldeprozess gemerkt, ob die Anmeldung erfolgreich war. Wenn dem so war, wurde dieselbe Seite wieder so dargestellt wie vor dem Reload. Konnte nicht festgestellt werden, ob ein Benutzer schon erfolgreich durch den Anmeldeprozess gelaufen ist, wurde auf den Startbildschirm verwiesen. Bisher hatten wir die Anmeldedaten statisch in der Vaadin-Webanwendung hinterlegt. Heute werden wir das ändern.

Apache Shiro

Die Sicherheit von Webanwendungen ist ein heiß diskutiertes und nicht zu vernachlässigendes Thema. Demnach ist es durchaus sinnvoll, schon zu Beginn der Entwicklung zumindest die Grundlagen dafür zu legen. Ein guter Start hierfür ist das Projekt Apache Shiro. Um das Framework Shiro zu verwenden, sind einige Vorbereitungen notwendig. Beginnen wir mit der Erweiterung in der pom.xml und tragen die notwendigen Shiro-Abhängigkeiten ein.

    <dependency>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-web</artifactId>
      <version>${shiro.version}</version>
      <exclusions>
        <exclusion>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-api</artifactId>
        </exclusion>
        <exclusion>
          <groupId>commons-beanutils</groupId>
          <artifactId>commons-beanutils</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

Hier sind die Abhängigkeiten slf4j und beanutils explizit ausgeschlossen, da diese transitiv bereits im Projekt enthalten sind.

Als nächstes werden wir im Servlet-Container sicherstellen, dass die Anfragen durch die Verantwortung von Shiro geleitet werden. Hierfür benötigen wir einen Listener und ein paar definierte Filter, um alle Anfragen abzufangen. Die Definition werden wir in unserem Fall direkt in der Klasse Main vornehmen da wir dort den Servlet-Container (Undertow) konfigurieren und starten. Ich verzichte hier explizit auf die Verwendung einer web.xml, damit wir weiterhin die Möglichkeit haben, alle Konfigurationen später programmatisch steuern zu können.

  public static final String SHIRO_FILTER = "ShiroFilter";
  public static final String SHIRO_FILTER_MAPPING = "/*";

  public static void main(String[] args) {
    DeploymentInfo servletBuilder
        = Servlets.deployment()
                  .setClassLoader(Main.class.getClassLoader())
                  .setContextPath(CONTEXT_PATH)
                  .setDeploymentName("ROOT.war")
                  .setDefaultEncoding("UTF-8")
                  .addListener(new ListenerInfo(EnvironmentLoaderListener.class))
                  .addFilter(new FilterInfo(SHIRO_FILTER, ShiroFilter.class))
                  .addFilterUrlMapping(SHIRO_FILTER, SHIRO_FILTER_MAPPING, REQUEST)
                  .addFilterUrlMapping(SHIRO_FILTER, SHIRO_FILTER_MAPPING, FORWARD)
                  .addFilterUrlMapping(SHIRO_FILTER, SHIRO_FILTER_MAPPING, INCLUDE)
                  .addFilterUrlMapping(SHIRO_FILTER, SHIRO_FILTER_MAPPING, ERROR)
                  .addServlets(
                      servlet(
                          MainServlet.class.getSimpleName(),
                          MainServlet.class).addMapping(SHIRO_FILTER_MAPPING)
                  );

        //SNIPP
   }

Das API von Undertow ist hier sehr einfach. Mit addListener wird eine Instanz der Klasse ListenerInfo übergeben mittels der die Klasse des Listeners und optional eine Factory zur Erzeugung der Listerner-Instanz übergeben wird. Die Filter selbst werden ebenfalls nach dem selben Prinzip registriert. Hier werden die Informationen mittels einer Instanz der Klasse FilterInfo dargereicht. In unserem Fall ist es die implementierende Klasse und der logische Name des Filters. Diesen Namen verwenden wir auch, um die URLMappings dem Filter zuzuordnen (siehe Methode addFilterUrlMapping). Nun sind wir soweit, dass Shiro in Aktion treten kann. Es fehlen nur noch die grundlegenden Informationen über die Benutzer und Rollen. Hier bietet Shiro verschiedene Möglichkeiten. Eine davon ist die Verwendung einer ini-Datei. Damit werden wir nun beginnen.

Die shiro.ini ist eine Konfigurationsdatei, in der man das grundsätzliche Verhalten von Shiro steuern und anpassen kann. Zu Beginn werden wir dieses ausschließlich nutzen, um Benutzer und deren Rollen zu definieren. Die Benutzer werden in der Sektion [users] definiert. Hier werden in einer Reihe die Informationen über Benutzername, Passwort und die assoziierten Rollen festgehalten.

[users]
# admin = admin, admin
# user 'root' with password 'secret' and the 'admin' role
root = secret, admin
max = max, child
sven = sven, parent

In unserem Beispiel hier gibt es einen Benutzer mit dem Namen/Login root und dem Passwort secret. Nachfolgend ist die Liste der zu diesem Benutzer zugeordneten Rollen zu sehen. In dem speziellen Fall ist es ausschließlich die Rolle admin. Um nun die Rolle admin praktisch verwenden zu können, muss diese zuvor in der Sektion [roles] definiert werden. Da es sich um eine besondere Rolle handelt, die alle anderen definierten Rollen enthält, kann man auch mit einer Wildcard arbeiten: admin = *.

Die weiteren Rollen, auf die sich die Rolle admin bezieht werden dann nacheinander aufgelistet. Immer eine Rolle pro Zeile. Der Aufbau einer Rolle besteht aus den Elementen Rollenname, Typ, Aktion und der spezifischen ID. Die genauen Auswirkungen werden wir an anderer Stelle beschreiben. Für unseren Fall definieren wir zuerst einmal ein paar grundlegende Rollen in Bezug auf den Inhalt der angebotenen Trainingsinhalte. Diese Rollen werden aber noch nicht ausgewertet. Wir verwenden die shiro.ini erst einmal ausschließlich für die Anmeldung.

[roles]
admin = *
math = CalcComponent
write = WriteComponent
report = mathreport, writereport

parent = math:*, write:*, report:*
child = math:*, write:*

Nachdem wir nun die Benutzer und deren Rollen definiert haben, kommen wir nun zum allgemeinen Ablauf. Beim Start der Anwendung wird das Framework initialisiert und mit den gewünschten Servlets verbunden. Die Anfragen, die nun erfolgen, werden zu Beginn in der init-Methode in unserem Servlet entgegengenommen. Bei dem Aufbau der Komponente LoginComponent werden dann die Verbindungen zu den Eingabemasken hergestellt. In unserem Fall verwenden wir den Button, mit dem der Anmeldeprozess gestartet wird. Beim Durchlauf des ClickListeners gelangen wir an die Stelle, an der ein Subject erzeugt wird und aus den Eingabewerten ein Token generiert.

      final Subject currentUser = SecurityUtils.getSubject();
      final UsernamePasswordToken token = new UsernamePasswordToken(login.getValue(), password.getValue());

Mit diesem Token wird dann der Anmeldeprozess bei Shiro durchgeführt, indem die Methode currentUser.login(token); an dem Subject aufgerufen wird. Hier ist es leider so, dass ein Fehlschlag der Anmeldung mittels Exception quittiert wird. Demnach müssen wir an dieser Stelle die Fallunterscheidung mittels try{}catch() realisieren. Die unterschiedlichen Exceptions geben Auskunft darüber, was genau zur Ablehnung bei der Anmeldung geführt hat. Wenn man möchte, kann man diese Information natürlich anderweitig nutzen. Dazu kommen wir später. In der jetzigen Situation werden wir lediglich ein positiv oder negativ auswerten und demnach ausschließlich die AuthenticationException auswerten. Wenn der Anmeldevorgang erfolgreich ist, soll die Session initialisiert werden und eine Instanz der Klasse User mit den vorhandenen Daten des Benutzers für den schnellen Zugriff in der Session abgelegt werden. Ist der Anmeldevorgang jedoch negativ, soll die Session beendet und eventuell vorhandene Daten wieder freigegeben werden.

      try {
        currentUser.login(token);
        token.setRememberMe(true);
        VaadinService.reinitializeSession(VaadinService.getCurrentRequest());
        current.getSession()
               .setAttribute(SESSION_ATTRIBUTE_USER, userService.loadUser(login.getValue()));
        current.setContent(mainViewSupplier.get());
      } catch (AuthenticationException e) {
        e.printStackTrace();
        token.setRememberMe(false);
        currentUser.logout();
        current.setContent(this);
        current.getSession()
               .setAttribute(SESSION_ATTRIBUTE_USER, null);
      }

Da wir nun soweit sind, dass wir mittels Shiro den Log-in validieren können, extrahieren wir die einzelnen Komponenten wieder in den LoginService aus. Hierzu implementieren wir das Interface LoginService zuerast mit der Klasse LoginServiceShiroSimple. Die technischen Dinge, die im Zusammenspiel mit Shiro notwendig sind, befinden sich in der Methode check(). Die Dinge, die für die grafischen Elemente zuständig sind, bleiben in der `LoginComponent.

   ok.addClickListener((Button.ClickListener) event -&amp;gt; {
      final String loginValue = login.getValue();
      final String passwordValue = password.getValue();
      clearInputFields();
      UI current = UI.getCurrent();
      if (loginService.check(loginValue, passwordValue)) {
        VaadinService.reinitializeSession(VaadinService.getCurrentRequest());
        current.getSession()
               .setAttribute(SESSION_ATTRIBUTE_USER, userService.loadUser(loginValue));
        current.setContent(mainViewSupplier.get());
      }
      else {
        current.setContent(this);
        current.getSession()
               .setAttribute(SESSION_ATTRIBUTE_USER, null);
      }
    });

Die Implementierung der Klasse LoginServiceShiroSimple ist recht geradlinig.

public class LoginServiceShiroSimple implements LoginService {
  @Override
  public boolean check(String login, String password) {
    final Subject currentUser = SecurityUtils.getSubject();
    final UsernamePasswordToken token = new UsernamePasswordToken(login, password);
    try {
      currentUser.login(token);
      token.setRememberMe(true);
      return true;
    } catch (AuthenticationException e) {
      e.printStackTrace();
      token.setRememberMe(false);
      currentUser.logout();
      return false;
    }
  }
}

Somit sind die grafischen Dinge und die Authentifizierung voneinander getrennt. Ab diesem Zeitpunkt kann man beginnen kreativ zu werden, welche Funktionen in dem Bereich der Authentifizierung noch möglich sind.

Ein Log-in, viele Passwörter

Wer kennt das nicht: Eigentlich ist man sich ja so sicher das die eingegebene Kombination von Log-in und Passwort die richtige ist. Nur leider bekommt man dann nach dem zehnten Versuch die Meldung, dass der Benutzer nun erst einmal für einige Zeit gesperrt ist. Was auf der einen Seite recht nervig ist, kann auf der anderen Seite vor Schaden bewahren. Wie kann man das dieses einfache Verhalten implementieren? Was wir hierfür benötigen, ist eine zentrale Stelle, an der die Versuche gespeichert werden. Erfolgt ein Anmeldeversuch, wird erst dort nachgesehen, ob für den angegebenen Log-in derzeitig überhaupt eine Anmeldung erlaubt ist. Wenn dem nicht so ist, muss im System die Kombination erst gar nicht überprüft werden. Jedoch möchte man diese Sperre nicht auf ewig. So gibt es zwei Wege die Sperre wieder zu entfernen. Der erste Weg ist, nach der angegebenen Zeit einen erfolgreichen Anmeldeversuch durchzuführen. Der zweite Weg ist eher ein Aufräumen in der Art, das Log-in-Daten, die seit längerer Zeit nicht angefragt wurden, wieder aus dem Cache entfernt werden. Somit merkt man sich nicht zu lange alle jemals verwendeten Benutzernamen, die ausprobiert worden sind. Als Cache verwende ich hier in der ersten Form eine ConcurrentHashMap. Diese wird statisch definiert, damit innerhalb der JVM von allen Stellen darauf zugegriffen werden kann. Ebenfalls wichtig ist natürlich, dass diese Information mehrere Sessions überlebt.

  private static final Map<String, Pair<LocalDateTime, Integer>> failedLogins = new ConcurrentHashMap<>();
  public static final int MAX_FAILED_LOGINS = 3;
  public static final int MINUTES_TO_WAIT = 1;       //to short 😉
  public static final int MINUTES_TO_BE_CLEANED = 2; //to short 😉
  public static final int MILLISECONDS_TO_BE_CLEANED = 1_000 * 60 * MINUTES_TO_BE_CLEANED;

  public static final int MILLISECONDS_INITIAL_DELAY = 100;

  @Override
  public boolean check(String login, String password) {
    if (failedLogins.containsKey(login)) {
      Pair<LocalDateTime, Integer> pair = failedLogins.get(login);
      LocalDateTime failedLoginDate = pair.getT1();
      Integer failedLoginCount = pair.getT2();
      if (failedLoginCount > MAX_FAILED_LOGINS) {
        out.println("failedLoginCount > MAX_FAILED_LOGINS " + failedLoginCount);
        final Duration duration = between(failedLoginDate, LocalDateTime.now());
        long minutes = duration.toMinutes();
        if (minutes > MINUTES_TO_WAIT) {
          out.println("minutes > MINUTES_TO_WAIT (remove login) " + failedLoginCount);
          failedLogins.remove(login); // start from zero
        } else {
          out.println("failedLoginCount <= MAX_FAILED_LOGINS " + failedLoginCount);
          failedLogins.compute(
              login, 
              (s, faildPair) -> new Pair<>(LocalDateTime.now(), failedLoginCount + 1));
          return false;
        }
      } else {
        out.println("failedLoginCount => " + login + " - " + failedLoginCount);
      }
    }

    final UsernamePasswordToken token = new UsernamePasswordToken(login, password);
    final Subject subject = SecurityUtils.getSubject();
    try {
      subject.login(token);
      failedLogins.remove(login);
    } catch (AuthenticationException e) {
      out.println("login failed " + login);
      //e.printStackTrace();
      failedLogins.putIfAbsent(login, new Pair<>(LocalDateTime.now(), 0));
      failedLogins.compute(
          login, 
          (s, oldPair) -> new Pair<>(LocalDateTime.now(), oldPair.getT2() + 1));
    }
    return subject.isAuthenticated();
  }

Das Aufräumen selbst wird im Hintergrund erledigt. Hierzu wird in dieser Implementierung die Klasse java.util.Timer verwendet, um in regelmäßigen Abständen die Map durchzugehen und nicht mehr benötigte Einträge zu entfernen.

  public static class FailedLoginCleaner {
    private final Timer failedLoginCleanUpTimer = new Timer();

    public FailedLoginCleaner(TimerTask tasknew) {
      failedLoginCleanUpTimer.schedule(tasknew, MILLISECONDS_INITIAL_DELAY, MILLISECONDS_TO_BE_CLEANED);
    }
  }

  private static final FailedLoginCleaner FAILED_LOGIN_CLEANER = new FailedLoginCleaner(new TimerTask() {
    @Override
    public void run() {
      out.println(" start cleaning " + LocalDateTime.now());
      failedLogins
          .keySet()
          .forEach((String key) -> {
            Pair<LocalDateTime, Integer> pair = failedLogins.get(key);
            if (pair != null) {
              out.println("work on login/pair = " + key + " - " + pair);
              final Duration duration = between(pair.getT1(), LocalDateTime.now());
              long minutes = duration.toMinutes();
              if (minutes > MINUTES_TO_BE_CLEANED) {
                failedLogins.remove(key); // start from zero
                out.println("  ==>  cleaned key = " + key);
              }
            }
          });
    }
  });

Die Implementierung ist in der Klasse LoginServiceShiro zu finden.

Backend meets Frontend

In der Artikelserie Backend meets Frontend stellt Sven Ruppert (Vaadin) Konzepte und Technologien rund um das UI-Framework Vaadin vor. Sein Fokus liegt dabei auf modernem Web-Design für Java-Backend-Entwickler.

Zum ersten Teil und damit dem Start der Tutorien rund um die UI-Entwicklung mit Java geht es hier entlang. Alle Teile der Serie Backend meets Frontend finden sich hier.

Fazit

Nun haben wir einiges zusammen. Wir können an zentraler Stelle die Benutzer und deren Rollen definieren. Diese werden bei einem Anmeldeversuch aber noch nicht ausgewertet. Als zugrundeliegende Implementierung verwenden wir das Apache Projekt Shiro. Als kleine Erweiterung haben wir das Verhalten hinzugefügt, das in einer bestimmten Zeit nur eine begrenzte Menge an Anmeldeversuchen durchgeführt werden kann. Hier zeigt sich deutlich, dass es sinnvoll ist, dieses Verhalten noch weiter herauszulösen und damit die Erweiterung um weitere Regeln zu vereinfachen. Wir haben noch nicht beleuchtet, wie wir die Rolleninformationen auswerten können, um diese mit der Navigation innerhalb der Anwendung zu koppeln. Ebenfalls haben wir noch keine TestBench-Tests geschrieben, um das Verhalten zu testen. Es kommen also noch einige Dinge auf uns zu.

Den Quelltext findet ihr auf GitHub. Bei Fragen und Anregungen einfach melden unter sven@vaadin.com oder per Twitter @SvenRuppert.

Happy Coding!

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.