Teil 14: Backend meets Frontend: Trainer for kids 4

Rollen, Rechte und ihre Auswirkungen auf das UI

Sven Ruppert

© Shutterstock / HelenField

Rechte und Rollen können bei einer Webanwendung ganz schön tricky werden. Man sollte vor allem nicht am Anfang schludern, sonst treffen Änderungen oder Erweiterungen einen umso härter. Apache Shiro hilft bei den Rechten und Rollen, das muss man dann aber auch sauber integrieren.

In diesem Teil werden wir uns damit beschäftigen, wie wir das im letzten Teil eingeführte Framework Apache Shiro besser in unsere Anwendung integrieren. Hierzu werden wir uns mit der Verzahnung zwischen Benutzerführung und Rechtesystem von Shiro auseinandersetzen. Aber vorweg ein klein wenig zum Thema Versionen und Maven.

Maven und Versionen

Jeder, der mit Maven schon einmal gearbeitet hat, muss sich auch damit auseinandersetzen, wie man die Versionen der Abhängigkeiten im Auge behält. Dazu gibt es verschiedene Möglichkeiten. Wer diese Aufgabe gerne selbst und manuell erledigt, kann sich bei größeren Projekten auf einiges an Zeitaufwand einstellen. Allerdings kann man auch auf Anbieter zurückgreifen, die diese Funktion als Dienst anbieten. Hier ist exemplarisch der Dienst VersionEye zu nennen, da er eine freie Version für öffentliche Repositories anbietet. Prinzipiell funktionieren solche Dienste derart, das ein Repository angegeben wird, dessen pom.xml gescannt wird. Der Report kommt dann per Mail oder kann auf der Webseite eingesehen werden. Sehr gut finde ich auf jeden Fall, dass der Report auch sicherheitskritische Updates und Warnungen anzeigt. Ein Blick lohnt auf jeden Fall. Nun gibt es natürlich auch einen anderen Weg. Es gibt ein Plug-in für Maven unter den Koordinaten:

      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>versions-maven-plugin</artifactId>
        <version>2.4</version>
      </plugin>

Die Versionsnummer kann natürlich mittlerweile eine aktuellere sein. Hier bekommt man einige Maven Tasks, mit denen man sich die Versionen, die im Maven Central vorliegen, oder auch alle anderen in der pom.xml angegebenen Repositories im Vergleich anzeigen lasen kann. Wenn wir das auf unser Projekt anwenden, bekommen wir folgenden Output.

[INFO] The following dependencies in Dependencies have newer versions:
[INFO]   com.vaadin:vaadin-testbench ............. 5.1-SNAPSHOT -> 5.1.0.alpha1
[INFO]   io.undertow:undertow-core ............... 1.4.16.Final -> 2.0.0.Alpha1
[INFO]   io.undertow:undertow-servlet ............ 1.4.16.Final -> 2.0.0.Alpha1
[INFO]   javax.servlet:javax.servlet-api ................... 3.1.0 -> 4.0.0-b07
[INFO]   org.apache.shiro:shiro-web ............................ 1.3.2 -> 1.4.0
[INFO]   org.junit.jupiter:junit-jupiter-api ............. 5.0.0-M4 -> 5.0.0-M5
[INFO]   org.junit.jupiter:junit-jupiter-params .......... 5.0.0-M4 -> 5.0.0-M5
[INFO]   org.slf4j:slf4j-api ........................... 1.7.25 -> 1.8.0-alpha2
[INFO]   org.slf4j:slf4j-simple ........................ 1.7.25 -> 1.8.0-alpha2
[INFO]   org.testcontainers:selenium ........................... 1.3.0 -> 1.3.1

Hierbei handelt es sich um eine Ausgabe vom 7.11.2017. Nun kann entschieden werden, ob und welche Abhängigkeit angepasst werden sollen. Man kann dieses Plug-in natürlich auch gut für interne Abhängigkeiten in einem verteilten Team verwenden. Der Report wird dann zum Beispiel von einer CI-Strecke erzeugt und als Web-Report zur Verfügung gestellt. Beim nächsten Meeting kann dann darüber vortrefflich diskutiert werden. Das Meeting selbst ersparen wir uns an dieser Stelle und setzen einfach die Versionen auf die dort angegebenen.

Shiro: Wie alles angefangen hat

Das letzte mal hatten wir uns damit beshäftigt, wie wir die Benutzerauthentifikation
realisieren und haben das Framework Apache Shiro eingebunden. Bisher haben wir zu Beginn den Benutzer mittels Benutzername und Passwort verifiziert und dann den Zugriff auf die Webanwendung gewährt. Allerdings kann man da noch einiges mehr machen. Aber schauen wir uns zuerst ein wenig genauer an, wie das Rechte/Rollen-System von Shiro aussieht. Wir werden es nicht bis in alle Details beleuchten, aber doch soweit, dass es für uns seinen Dienst tun wird.

In Shiro kann man jedem Benutzer eine Liste von Rollen zuweisen. Das geschieht, indem man hinter der Benutzerdefinition eine Komma-separierte Liste von Rollennamen angibt. Da wir derzeit noch mit der shiro.ini arbeiten, sieht das dann wie folgt aus:

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

Hier haben wir drei Benutzer definiert. Der Benutzer root hat die Rolle admin, der Benutzer max die Rolle child und so weiter. Nun wird in der Sektion [roles] begonnen diese Rollen zu formulieren. Die Rolle admin ist eine Superuser-Rolle und hat hier per Definition alle definierten Rollen.

[roles]
admin = *

Hier wird mit einer Wildcard gearbeitet. Das kann an anderer Stelle ebenfalls geschehen, wie wir noch sehen werden. Nun sehen wir uns die Definition der Rollen child an:

child = math:*, write:*

Hier arbeiten wir ebefalls mit Wildcards. Die Definition math:* erlaubt es, dem Benutzer mit der Rolle child auf alle Elemente unterhalb von math zuzugreifen. Dassselbe gilt dann auch für write:*. Hier spricht man davon, das der Benutzer mit der Rolle child die Berechtigungen math:* und write:* besitzt. Nun fehlt an der Stelle noch die Definition der Berechtigung math:*.

math = CalcComponent

Wie man an dieser Stelle erkennen kann, ist die Wahl der Begriffe frei. Allerdings gibt es ein Grundmuster, das besagt, dass eine Berechtigung aus drei Ebenen besteht.

# The 'goodguy' role is allowed to 'drive' (action) the winnebago (type) with
# license plate 'eagle5' (instance specific id)
goodguy = winnebago:drive:eagle5

Dieses Beispiel ist übrigens aus der originalen Dokumentation. Hier erkennt man: Type, Action und ID. Allerdings wird an dieser Stelle nicht so recht erklärt, wie das nun genau funktioniert. Also schreiben wir uns einen kleinen Test, mit dem wir das ausprobieren. Um es besser nachvollziehen zu können, hier nochmals die komplette shiro.ini als Listing.

[users]
root = secret, admin
max = max, child
sven = sven, parent

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

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

Der Test selbst ist recht einfach. Wir definieren uns einige Kombinationen in der shiro.ini und testen, ob die Auswertungen der Rechte unseren eigenen Vorstellungen entsprechen. Der Test selbst basiert auf jUnit5 M5 und beginnt damit, dass wir Shiro initialisieren bzw. nach jedem Test den Benutzer wieder ausloggen. Der Test selbst verwendet für den Log-in eine Benutzer/Passwort-Kombination. Danach prüfen wir, ob der Benutzer authentifiziert ist, ob er den Benutzernamen max hat und ob der Benutzer die Rolle child hat.

public class ShiroTest {

  private Subject subject;

  @BeforeEach
  void setUp() {
    subject = SecurityUtils.getSubject();
  }

  @AfterEach
  void tearDown() {
    subject.logout();
  }

  @Test
  void test001() {
    subject.login(new UsernamePasswordToken("max", "max"));
    Assert.assertTrue(subject.isAuthenticated());
    Assert.assertEquals("max", subject.getPrincipal());

    Assert.assertTrue(subject.hasRole("child"));
  }
}

Damit haben wir die grundlegenden Dinge überprüft und beginnen, ein wenig mit den Berechtigungen zu spielen oder besser gesagt nachzufragen, welche Rechte der Benutzer besitzt. Um auf eine Berechtigung hin zu überprüfen, gibt es die Methoden isPermitted(..). Hier bekommt man mittels Boolean- Werten die Antworten.

    Assert.assertTrue(subject.isPermitted("math:*"));
    Assert.assertTrue(subject.isPermitted("math:CalcComponent"));
    Assert.assertTrue(subject.isPermitted("math:HoppelPoppel"));

Welche Tests werden erfolgreich sein? Beim ersten Test ist es recht einfach, da wir genau das in der shiro.ini definiert haben. Beim zweiten Test bekommen wir ebenfalls grünes Licht. Da hier die Auflösung zu einer Konkatenation von Rollenname und Typ führt: math:CalcComponent. Beim dritten Test wird ebenfalls auf eine Type/Action-Kombination geprüft. Hier eben nach "math:HoppelPoppel. Nur leider haben wir das nicht definiert. Den Typ HoppelPoppel gibt es nicht. Aber leider ist auch dieser Test grün. Das liegt schlicht und ergreifend an der Definition child = math:*. Hiermit ist alles unterhalb von math erlaubt. Leider auch das, was nicht definiert ist. Wichtig ist also, dass man die Rollen präzise definiert. Solche Lücken können recht empfindlich werden. Dieses Verhalten pflanzt sich leider bis hin zur ID fort.

Shiro: Rollen und die Navigation

Wie wir gerade gesehen haben, ist das Rollenkonzept von Shiro auf der einen Seite mächtig und flexibel, auf der anderen Seite allerdings auch mit großer Sorgfalt zu behandeln. Aber realisieren wir zuerst einmal den ersten Fall in unserem System. Benutzer mit der Rolle child sollen Zugriff auf die Komponenten zum Rechnen und Schreiben bekommen. Die Benutzer mit der Rolle parent bekommen zusätzlich die Möglichkeit, auf die Auswertungen zuzugreifen. In diesem Fall ist noch nicht angedacht, einer definierten Menge an Eltern eine feste Menge an Kindern zuzuordnen. Es findet also keine Separation der Ergebnisse statt. Die Definition der Benutzer haben wir, der Anmeldeprozess ist realisiert, nun fehlt noch die Anpassung am Hauptmenü.

Im Hauptmenü sind sechs Elemente zu sehen, wenn alles angezeigt werden soll. Je nachdem welche Rechte ein Benutzer hat, können es auch weniger sein. Nun ist es eine schlechte Idee, Elemente einfach auszublenden, wenn man auch die Wahl hat, diese erst gar nicht zu erzeugen. Frei nach dem Motto, was nicht vorhanden ist, kann auch nicht stören. Also sehen wir uns die Stelle an, an der die Buttons erzeugt werden. Bisher sah es wie folgt aus:

createMenuButton(VaadinIcons.VIEWPORT, "Dashboard", DashboardComponent::new),
createMenuButton(VaadinIcons.SITEMAP, "Sitemap", DashboardComponent::new),
createMenuButton(VaadinIcons.ABACUS, "Calculate", CalcComponent::new),
createMenuButton(VaadinIcons.EDIT, "Write", WriteComponent::new),
createMenuButton(VaadinIcons.BAR_CHART, "Report", ReportComponent::new),
createMenuButtonForNotification(VaadinIcons.EXIT, "Logout", "You want to go?")

Wenn man nun entscheiden möchte, ob ein Button hinzugefügt wird, kann man natürlich einfach jeden Methodenaufruf mit einem if(..) selektiv an- und ausschalten. Das könnte dann in etwa so aussehen:

if(checkPermission(..)) createMenuButton(VaadinIcons.VIEWPORT,...),
if(checkPermission(..)) createMenuButton(VaadinIcons.SITEMAP,...),
if(checkPermission(..)) createMenuButton(VaadinIcons.ABACUS,...),
if(checkPermission(..)) createMenuButton(VaadinIcons.EDIT,...),
if(checkPermission(..)) createMenuButton(VaadinIcons.BAR_CHART,...),
if(checkPermission(..)) createMenuButtonForNotification(VaadinIcons.EXIT,...)

Das kann eine Lösung für ein kleines Projekte sein, empfehlen kann ich das allerdings nicht. Aber beginnen wir, diese Variante ein wenig umzuschreiben. Wenn man sich den obigen Quelltext ein wenig genauer ansieht, kann man erkennen, dass die Abfrage mittels if(..) immer dieselbe ist.
Hier bietet sich die Verwendung von Streams an.

Stream.of(
    createMenuButton(VaadinIcons.VIEWPORT, "Dashboard", DashboardComponent::new),
    createMenuButton(VaadinIcons.SITEMAP, "Sitemap", DashboardComponent::new),
    createMenuButton(VaadinIcons.ABACUS, "Calculate", CalcComponent::new),
    createMenuButton(VaadinIcons.EDIT, "Write", WriteComponent::new),
    createMenuButton(VaadinIcons.BAR_CHART, "Report", ReportComponent::new),
    createMenuButtonForNotification(VaadinIcons.EXIT, "Logout", "You want to go?")
)
.filter(b -> checkPermission(..))

Nun haben wir zumindest den Quelltext ein wenig reduziert. Nun kommen wir aber zu dem schwierigen Teil. Wie verbinden wir die Rechte und Rollen von Shiro mit den UI-Elementen? Beginnen wir mit einer trivialen Lösung.

Um hier die Endscheidung treffen zu können, muss man das derzeitig aktive Subjekt von Shiro verwenden, um die Berechtigung zu prüfen, die der jeweilige Button benötigt:
getSubject().isPermitted("PERMISSION TO CHECK"). Wo also kommt die Information her, das ein Button eine spezifische Berechtigung benötigt? Da dieses durchaus mehr Aufwand ist, behelfen wir uns an der Stelle vorerst damit, eine Mapping-Funktion zu schreiben. Mir ist an der Stelle bewusst, dass es sich nicht um eine belastbare Lösung handelt. Jedoch lösen wir erst einmal dieses eine Problem an dieser Stelle. Implizit ist die Information in der Beschreibung der Buttons vorhanden. Es reicht demnach eine Mapping-Funktion aus, die auf Basis der Beschriftung das dazu benötigte Recht liefert.

  //not nice
  private String mapToShiroRole(String caption) {
    return (caption.equals("Calculate")) ? "math:CalcComponent" :
           (caption.equals("Write")) ? "write:WriteComponent" :
           (caption.equals("Report")) ? "report:*" :
           "all:default";
  }

Wenn wir also voraussetzen, dass diese Funktion vorhanden ist, können wir die Filterfunktion des Streams verwenden, um die Elemente herauszufiltern, die nicht angezeigt werden sollen. Hierzu passen wir die Methode zum Erzeugen der Buttons ein wenig an. Es wird nun nicht mehr ausschließlich der Button zurückgeliefert, sondern eine Instanz der Klasse Pair, bestehend aus dem Button und der benötigten Berechtigung.

   private Pair<String, Button> createMenuButton(
                                    VaadinIcons icon, 
                                    String caption, 
                                    Supplier<CustomComponent> content) {
    final Button button = new Button(caption, (e) -> {
      contentLayout.removeAllComponents();
      contentLayout.addComponent(content.get());
    });
    button.setIcon(icon);
    button.addStyleName(ValoTheme.BUTTON_HUGE);
    button.addStyleName(ValoTheme.BUTTON_ICON_ALIGN_TOP);
    button.addStyleName(ValoTheme.BUTTON_BORDERLESS);
    button.addStyleName(ValoTheme.MENU_ITEM);
    button.setWidth("100%");

    button.setId(buttonID().apply(this.getClass(), caption));
    return new Pair<>(mapToShiroRole(caption), button);
  }

Die letzte Zeile verknüpft die Beschriftung mit der Berechtigung. return new Pair<>(mapToShiroRole(caption), button);. Nun sind wir soweit, dass nur noch die Elemente angezeigt werden, die zu den Berechtigungen des jeweiligen Benutzers passen.

  private Component[] getComponents() {
    return Stream
        .of(
            createMenuButton(VaadinIcons.VIEWPORT, "Dashboard", DashboardComponent::new),
            createMenuButton(VaadinIcons.SITEMAP, "Sitemap", DashboardComponent::new),
            createMenuButton(VaadinIcons.ABACUS, "Calculate", CalcComponent::new),
            createMenuButton(VaadinIcons.EDIT, "Write", WriteComponent::new),
            createMenuButton(VaadinIcons.BAR_CHART, "Report", ReportComponent::new),
            createMenuButtonForNotification(VaadinIcons.EXIT, "Logout", "You want to go?")
        )
        .filter(p -> getSubject().isPermitted(p.getT1()))
        .map(Pair::getT2)
        .map(Component.class::cast)
        .toArray(Component[]::new);
  }

Abb. 1: Es werden nur die Bedienelemente angezeigt, die der Benutzer auch bedienen darf

Nun haben wir in der Klasse MainUI verschiedene Dinge, bei denen es sich lohnt sie zu separieren. Als erstes werden die Anteile, die das schon recht komplexe Menü enthalten, in eine MenuComponent ausgelagert. Ebenfalls ist die Klasse MainUI noch immer nicht als CustomComponent realisiert. Auch hier kann man gleich aufräumen. Somit ist die Klasse MainUI doch wieder recht schlank geworden.

public class MainView extends AbstractBaseCustomComponent {

  @Override
  protected Component createComponent() {
    final CssLayout contentLayout = new CssLayout(new Label("Content"));
    contentLayout.setSizeFull();
    contentLayout.setId(cssLayoutID().apply(MainUI.class, "Content"));

    final VerticalLayout menuLayout = new VerticalLayout();
    menuLayout.setId(verticalLayoutID().apply(MainUI.class, "MenuLayout"));
    menuLayout.setStyleName(ValoTheme.MENU_ROOT);
    menuLayout.setWidth(100, Unit.PERCENTAGE);
    menuLayout.setHeight(100, Unit.PERCENTAGE);
    menuLayout.setSizeFull();

    menuLayout.addComponent(new MenuComponent(contentLayout));

    final HorizontalLayout mainLayout = new HorizontalLayout();
    mainLayout.setId(horizontalLayoutID().apply(MainUI.class, "MainLayout"));
    mainLayout.setSizeFull();
    mainLayout.addComponent(menuLayout);
    mainLayout.addComponent(contentLayout);

    mainLayout.setExpandRatio(menuLayout, 0.20f);
    mainLayout.setExpandRatio(contentLayout, 0.80f);

    return mainLayout;
  }
}

Der Vollständigkeit halber wird die Klasse MenuComponent hier ebenfalls gezeigt.

public class MenuComponent extends AbstractBaseCustomComponent {

  private final CssLayout contentLayout;

  public MenuComponent(CssLayout contentLayout) {
    this.contentLayout = contentLayout;
  }

  @Override
  protected Component createComponent() {

    final CssLayout menuButtons = new CssLayout();
    menuButtons.setSizeFull();
    menuButtons.addStyleName(ValoTheme.MENU_PART);
    menuButtons.addStyleName(ValoTheme.MENU_PART_LARGE_ICONS);
    menuButtons.addComponents(getComponents());

    return menuButtons;
  }

  private Component[] getComponents() {
    return Stream
        .of(
            createMenuButton(VaadinIcons.VIEWPORT, "Dashboard", DashboardComponent::new),
            createMenuButton(VaadinIcons.SITEMAP, "Sitemap", DashboardComponent::new),
            createMenuButton(VaadinIcons.ABACUS, "Calculate", CalcComponent::new),
            createMenuButton(VaadinIcons.EDIT, "Write", WriteComponent::new),
            createMenuButton(VaadinIcons.BAR_CHART, "Report", ReportComponent::new),
            createMenuButtonForNotification(VaadinIcons.EXIT, "Logout", "You want to go?")
        )
        .filter(p -> getSubject().isPermitted(p.getT1()))
        .map(Pair::getT2)
        .map(Component.class::cast)
        .toArray(Component[]::new);
  }

  private Pair<String, Button> createMenuButtonForNotification(
                                    VaadinIcons icon, 
                                    String caption, 
                                    String message) {
    final Button button
        = new Button(caption,
                     (e) -> {
                       UI ui = UI.getCurrent();
                       ConfirmDialog.show(
                           ui,
                           message,
                           (ConfirmDialog.Listener) dialog -> {
                             if (dialog.isConfirmed()) {

                               getSubject().logout(); 
                               VaadinSession vaadinSession = ui.getSession();
                               vaadinSession.setAttribute(SESSION_ATTRIBUTE_USER, null);
                               vaadinSession.close();
                               ui.getPage().setLocation("/");
                             }
                             else {
                               // User did not confirm
                               // CANCEL STUFF
                             }
                           });
                     });

    button.setIcon(icon);
    button.addStyleName(ValoTheme.BUTTON_HUGE);
    button.addStyleName(ValoTheme.BUTTON_ICON_ALIGN_TOP);
    button.addStyleName(ValoTheme.BUTTON_BORDERLESS);
    button.addStyleName(ValoTheme.MENU_ITEM);
    button.setWidth("100%");

    button.setId(buttonID().apply(MainView.class, caption));

    return new Pair<>(mapToShiroRole(caption), button);
  }

  private Pair<String, Button> createMenuButton(
                                VaadinIcons icon, 
                                String caption, 
                                Supplier<CustomComponent> content) {
                                
    final Button button = new Button(caption, (e) -> {
      contentLayout.removeAllComponents();
      contentLayout.addComponent(content.get());
    });
    button.setIcon(icon);
    button.addStyleName(ValoTheme.BUTTON_HUGE);
    button.addStyleName(ValoTheme.BUTTON_ICON_ALIGN_TOP);
    button.addStyleName(ValoTheme.BUTTON_BORDERLESS);
    button.addStyleName(ValoTheme.MENU_ITEM);
    button.setWidth("100%");

    button.setId(buttonID().apply(this.getClass(), caption));
    return new Pair<>(mapToShiroRole(caption), button);
  }

  //not nice
  private String mapToShiroRole(String caption) {
    return (caption.equals("Calculate")) ? "math:CalcComponent" :
           (caption.equals("Write")) ? "write:WriteComponent" :
           (caption.equals("Report")) ? "report:*" :
           "all:default";
  }

}

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

Wir haben auf der einen Seite unser Ziel erreicht, aber auf der anderen Seite auch gesehen, dass
es noch einiges an Aufwand ist, dieses für ein größeres Projekt umzusetzen. Fehleranfällig ist es auch, das die Mappings von Hand auf dem selben Stand gehalten werden müssen. Hier werden wir uns in den nächsten Teilen noch mehr mit beschäftigen. 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

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: