Der neue Endpunkt ist erstellt, alles funktioniert wie gewünscht und man freut sich das neue Feature präsentieren zu können. Doch dann in der Review die Ernüchterung. Auf den neuen Endpunkt lässt sich nicht nur mit der geforderten User-Rolle erfolgreich zugreifen, sondern auch mit jeder anderen. Und auch mit keiner. Das war so nicht gewünscht und hätte natürlich schon durch entsprechende Tests im Vorfeld auffallen müssen. Doch wir sind alles nur Menschen und Fehler passieren. Aber kann man das nicht automatisch verhindern? Sodass der Zugriff komplett verwehrt wird, wenn keine entsprechende Security-Annotation am Endpunkt gesetzt wurde und der Entwickler seinen Fehler beim ersten Test sofort bemerkt? Ja, aber dafür muss ein wenig was getan werden, denn leider bringt Spring keine einfache Möglichkeit mit direkt den Zugriff auf nicht geschützte Endpunkte zu verhindern (abgesehen von der eigentlichen Authentifizierung natürlich).
Dabei bedienen wir uns der Spring-Klasse AbstractFallbackMethodSecurityMetadataSource. Diese abstrakte Klasse ermöglicht uns eine eigene Fallback-Klasse für Method-Securities zu erstellen. In dieser können wir die bereitgestellte Methode findAttributes(Method method, Class<?> targetClass)
überschreiben. Diese Methode wird für jede Methode aufgerufen, die in einer von Spring verwalteten Klasse gefunden wurde. Dabei wird die gefundene Methode übergeben und wir haben die Möglichkeit diverse Prüfungen durchzuführen und ggf. den Zugriff auf die gefundene Methode komplett abzulehnen. In unserem Fall wollen wir ja verhindern, dass der Zugriff auf einen Endpunkt ohne Security-Annotation, also z.B. @Preauthorize
abgelehnt wird. Das sieht dann folgendermaßen aus:
@Override protected Collection<ConfigAttribute> findAttributes( final Method method, final Class<?> targetClass) { MergedAnnotations methodAnnotations = MergedAnnotations.from(method, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY_AND_ENCLOSING_CLASSES); if (!methodAnnotations.isPresent(PreAuthorize.class)) { return new ArrayList<>(Collections.singletonList(DENY_ALL_ATTRIBUTE)); } return null; }
Die Methode wird mit der gefunden Methode aufgerufen und wir holen uns alle Annotations zu dieser, die direkt oder in der Annotation-Hierarchie präsent sind. In den gefundenen Elementen prüfen wir, ob unsere erwartete Annotation vorhanden ist. Wenn nicht, dann können wir in der Rückgabeliste den Wert DENY_ALL_ATTRIBUTE
mitgeben, wodurch der Zugriff auf die Methode komplett verweigert wird. Diesen Code können wir allerdings noch nicht 1:1 verwenden, weil nun jede Methode, die keine Security-Annotation aufweist, nicht mehr aufgerufen werden kann. Wir möchten diese Einschränkung aber ja nur für Endpunkte. D.h. es ist notwendig in dieser Prüfung auch zu validieren, ob es sich bei der gefunden Methode überhaupt um einen Endpunkt handelt. In Spring können dafür als Anhaltspunkt sehr gut die Annotations @RestController
und @RequestMapping
verwendet werden. Über die erste Annotation prüfen wir, ob die Klasse überhaupt Endpunkte bereitstellt. Wenn nicht, dann kann diese direkt übersprungen und ignoriert werden. Und über die zweite Annoation prüfen wir, ob es sich bei der gefunden Methode um einen Endpunkt handelt. Nur dann ist es erforderlich zu prüfen, ob eine Security-Annotation vorhanden ist. Wir können den obigen Code also folgendermaßen erweitern:
@Override protected Collection<ConfigAttribute> findAttributes( final Method method, final Class<?> targetClass) { MergedAnnotations classAnnotations = MergedAnnotations.from(targetClass); if (classAnnotations.isDirectlyPresent(RestController.class)) { MergedAnnotations methodAnnotations = MergedAnnotations.from(method, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY_AND_ENCLOSING_CLASSES); if (methodAnnotations.isPresent(RequestMapping.class)) { if (!methodAnnotations.isPresent(PreAuthorize.class)) { return new ArrayList<>(Collections.singletonList(DENY_ALL_ATTRIBUTE)); } } } return null; }
Jetzt kann man sich fragen: ja schön und gut, aber wann passiert das eigentlich alles und ist das nicht ggf. unperformant, wenn das vielleicht sogar bei jedem Methodenaufruf wieder erfolgt? Hier kann man beruhigt sein, denn diese Klasse und Methode wird nur einmalig beim Starten der Applikation für jede gefundene Methode durchlaufen. Das Ergebnis zu jeder einzelnen Prüfung merkt Spring sich dann, sodass es während der Laufzeit zu keinen Performanceinbußen kommt. Okay, aber dann dauert der Startup meiner Applikation länger? Ja, aber auch hier kann man noch einige Optimierungen vornehmen, sodass dieser Overhead auf ein notwendiges Minimum reduziert werden kann. Eine sinnvolle und definitiv empfehlenswerte Einschränkung ist die Prüfung auf den eigenen Package Namen. Dadurch werden die meisten Klassen direkt ignoriert und wir verhindern, dass Zugriffe auf Methoden außerhalb unserer Domain verändert werden. Dadurch reduzieren wir nicht nur den Overhead beim Startup, sondern ersparen uns auch böse Überraschungen zur Laufzeit.
@Override protected Collection<ConfigAttribute> findAttributes( final Method method, final Class<?> targetClass) { MergedAnnotations classAnnotations = MergedAnnotations.from(targetClass); if (classAnnotations.isDirectlyPresent(RestController.class) && targetClass.getPackage().getName() != null && targetClass.getPackage().getName().startsWith("my.package.name")) { // further checks... } return null; }
Bevor der Code funktioniert ist allerdings noch eine Kleinigkeit notwendig. Aktuell würde die Klasse nicht durchlaufen werden, da Spring gar nicht weiß, dass es sie überhaupt gibt. Aus diesem Grund müssen wir die Klasse Spring noch bekannt machen. Und das geschieht über die GlobalMethodSecurityConfiguration:
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) @EnableConfigurationProperties({PrivilegeContext.class}) public class MvcMethodSecurityConfig extends GlobalMethodSecurityConfiguration { @Override protected MethodSecurityMetadataSource customMethodSecurityMetadataSource() { return new CustomPermissionAllowedMethodSecurityMetadataSource(); } // ... }
Fertig. Damit haben wir eine einfache Klasse geschaffen, die prüft, ob an einem durch uns bereitgestellten Endpunkt eine Security-Annotation vorhanden ist und wenn nicht, den Zugriff auf diesen komplett ablehnt. Somit wird dem Entwickler direkt beim ersten Test auffallen, dass er hier etwas vergessen hat, da der Zugriff auf den Endpunkt auch mit der erwarteten Rolle nicht funktionieren wird, solange keine Security-Annotation vorhanden ist.