Springfox Swagger Extensions für Spring Security

2 Kommentare

Eine populäre Methode, um REST APIs zu dokumentieren ist Swagger 2. Für Spring(-Boot)-Projekte bietet sich Springfox an. Springfox integriert sich recht nahtlos in ein Spring-Projekt und stellt für konfigurierte REST Endpoints eine Browser-basierte Swagger UI Representation zur Verfügung. Mittels Annotations im Code können umfangreiche Details und Informationen zur API-Dokumentation hinzugefügt werden, z. B. erweiterte Informationen zu http-Statuscodes oder Beschreibungen zu einzelnen Feldern von Ressource-Modellen. Allerdings gibt es keine OOTB-Verbindung zwischen Swagger UI und der Spring-Security-Konfiguration. Dieser Artikel beschreibt eine Springfox-Swagger-UI-Erweiterung, um beide miteinander zu verbinden.

Übersicht Spring Security und Spring REST Controller

Durch die Verknüpfung von normalem Spring-Code mit Springfox API Annotations zur Generierung der Swagger-Dokumentation lässt sich ein altbekanntes Problem umgehen, nämlich dass Code und Dokumentation auseinanderlaufen und damit die Dokumentation häufig nicht dem aktuellen Code entspricht.

Spring-Security-Beispiel

Allerdings lässt sich mit Springfox die verwendete Spring-Security-Konfiguration nicht dokumentieren. Spring Security sichert per (Java-)Konfiguration ein REST-API ab und stellt eine Brücke zwischen Authentifizierung und Autorisierung her. Das folgende Code-Snippet beispielsweise sichert den REST-Endpoint „/user“ für GET- und POST-Zugriffe ab:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        super.configure(httpSecurity);
 
        httpSecurity.csrf().disable();
        httpSecurity.httpBasic();
 
        httpSecurity.authorizeRequests()
            .antMatchers(HttpMethod.POST, "/user").hasRole("admin")
            .antMatchers(HttpMethod.GET, "/user/*").hasAnyRole("admin", "user");
 
        httpSecurity.authorizeRequests().anyRequest().fullyAuthenticated();
    }
}

Die obige Spring-Security-Konfiguration definiert, dass die Ressource „user“ sowohl von Usern mit der Rolle „user“ als auch mit der Rolle „admin“ abgefragt (GET) werden darf, während der schreibende Zugriff (POST) auf dieselbe Ressource nur durch Nutzer mit der Rolle „admin“ erlaubt ist.

Spring-REST-Controller-Beispiel

Eine Spring-Security-Java-Konfiguration ist relativ einfach zu lesen und zu verwalten. Ein Problem ist aber, dass diese Config von der eigentlichen Ressource, also dem REST Endpoint, losgelöst ist:

@RestController
public class MyRestController {
    @RequestMapping(method = RequestMethod.GET, value = "/user/{username}", produces = APPLICATION_JSON_VALUE)
    @ApiOperation("Get details of a user with the given username")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Details about the given user"),
            @ApiResponse(code = 401, message = "Cannot authenticate"),
    })
    public UserDTO getExampleData(@Valid
                                  @ApiParam("Non-empty username") @PathVariable(name = "username", required = true) String username) {
        /*
            Get your user resource somehow and return
         */
    }
    @PostMapping(value = "/user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseStatus(HttpStatus.CREATED)
    @ApiOperation("Create a new user or update an existing user (based on username)")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "User was successfully updated"),
            @ApiResponse(code = 201, message = "User was successfully created"),
            @ApiResponse(code = 401, message = "Cannot authenticate"),
    })
    public void createUser(@Valid @RequestBody UserDTO userDTO) {
        /*
            Save the user resource somehow
        */
    }
}

Der hier dargestellte REST-Controller wird durch Springfox API Annotations dokumentiert (Springfox ist sehr viel mächtiger als hier dargestellt, siehe Springfox Reference Documentation). Ein Blick auf das generierte Swagger UI zeigt allerdings, dass die Spring-Security-Konfiguration aus der Klasse SecurityConfig nicht in die Dokumentation einfließt. D.h. es ist nicht ersichtlich, welche Ressource durch welche Rollen aufrufbar ist:

Standard Swagger UI

Wie kann also Springfox so erweitert werden, dass die Spring-Security-Konfiguration automatisch Teil der Swagger-Dokumentation wird und damit eine höhere Kohäsion zwischen Implementierung und Dokumentation entsteht?

Swagger-UI-Erweiterung für Spring Security

Im Artikel Springfox Swagger mit externem Markdown erweitern hat Markus Höfer von codecentric bereits dargestellt, wie einfach Springfox mit Custom Annotations erweitert werden kann. Darauf basierend entstand der hier beschriebene Lösungsweg, um Spring-Security-Rollen in das Swagger UI zu übernehmen.

Folgende grundlegende Schritte sind notwendig, um eine Custom Annotation für Springfox Swagger UI zu erstellen:

  1. Erzeugen einer Custom Annotation
  2. Implementierung eines Custom Springfox OperationBuilderPlugins
  3. Custom Annotation an alle REST-Controller Endpoints hinzufügen

Weiterhin muss die Spring-Security-Konfiguration für die Springfox-Erweiterung „auslesbar“ gemacht werden, was im Folgenden zuerst beschrieben wird.

Spring Security auslesbar machen

Wie ein REST-API mit Spring Security gesichert werden kann, wurde bereits oben kurz dargestellt.

Spring Security verwendet Ant-Matcher Pattern, um http-Methoden und URL-Pfade zu spezifizieren, auf die definierte Rollen zugreifen dürfen. Diese Konfiguration ist jedoch „write-only“, d.h. die Klasse HttpSecurity (genauer gesagt die Klasse ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry aus dem Package org.springframework.security.config.annotation.web.configurers) lässt nicht zu, dass die Konfiguration der Ant-Matcher wieder ausgelesen wird. Das ist aber für unseren Use Case notwendig, denn wir wollen genau diese Information in unserer Swagger-Doku haben, müssen sie also auslesen können. Die vorgeschlagene Lösung verwaltet daher die Ant-Matcher-Konfiguration in einer eigenständigen Klasse namens HttpMethodResourceAntMatchers, die sowohl der HttpSecurity Configuration als auch der Swagger-Doku als Spring Component zur Verfügung steht:

public class HttpMethodResourceAntMatchers {
    Logger logger = LoggerFactory.getLogger(HttpMethodResourceAntMatchers.class);
    // list of all defined matchers
    List matcherList = new ArrayList<>();
    /**
     * Applies all existing Matchers to the provided httpSecurity object as .authorizeRequest().antMatchers().hasAnyRole()
     * @param httpSecurity
     * @throws Exception
     */
    public void configure(org.springframework.security.config.annotation.web.builders.HttpSecurity httpSecurity) throws Exception {
        for (HttpMethodResourceAntMatcher matcher : this.matcherList) {
            httpSecurity.authorizeRequests().antMatchers(matcher.getMethod(), matcher.getAntPattern()).hasAnyRole(matcher.getRoles());
        }
    }
    /**
     * Add a new Matcher with HttpMethod and URL-Path
     * @param method
     * @param antPattern
     * @return
     */
    public Role antMatchers(org.springframework.http.HttpMethod method, String antPattern) {
        // create a new matcher
        HttpMethodResourceAntMatcher matcher = new HttpMethodResourceAntMatcher(method, antPattern);
        // add matcher to list of matchers
        this.matcherList.add(matcher);
        // return a Role wrapper object, which forces the user to add the role(s) to the matcher
        Role role = new Role(matcher, this);
        return role;
    }
    /**
     * Helper class for a builder-like creation pattern
     */
    public class Role {
        HttpMethodResourceAntMatcher matcher;
        HttpMethodResourceAntMatchers matchers;
 
        public Role(HttpMethodResourceAntMatcher matcher, HttpMethodResourceAntMatchers matchers) {
            this.matcher = matcher;
            this.matchers = matchers;
        }
        /**
         * Define which role has access to the given resource identified by the Ant-Matcher
         * @param role
         * @return
         */
        public HttpMethodResourceAntMatchers hasRole(String role) {
            matcher.setRoles(role);
            return matchers;
        }
        /**
         * Add a list of roles which have access to the given resource identified by the Ant-Matcher
         * @param roles
         * @return
         */
        public HttpMethodResourceAntMatchers hasAnyRole(String... roles) {
            matcher.setRoles(roles);
            return matchers;
        }
    }
}

Die Klasse HttpMethodResourceAntMatchers imitiert die Spring HttpSecurity antMatchers() und hasRole(). Die hier referenzierte Klasse HttpMethodResourceAntMatcher ist ein einfacher Pojo mit den Membern „httpMethod“, „antPattern“ und „roles“.

Die eigentliche Konfiguration der Matcher mithilfe der Klasse HttpMethodResourceAntMatchers findet durch die Custom Spring Component MatchersSecurityConfiguration statt:

@Component
public class MatchersSecurityConfiguration {
 
    private HttpMethodResourceAntMatchers matchers;
 
    /**
     * Returns all http matchers
     * @return
     */
    public HttpMethodResourceAntMatchers getMatchers() {
        if (matchers == null) {
            matchers = new HttpMethodResourceAntMatchers();
            matchers.antMatchers(HttpMethod.POST, "/user").hasRole("admin")
                    .antMatchers(HttpMethod.GET, "/user/*").hasAnyRole("admin", "user");
        }
        return matchers;
    }
}

MatchersSecurityConfiguration wird sowohl in die Spring Security Config injiziert als auch in die nachfolgende Swagger-Hilfsklasse für unsere Custom Annotation.

Damit kann unsere Spring Security Config folgendermaßen angepasst werden:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Autowired
    private MatchersSecurityConfiguration matchersSecurityConfiguration;
 
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        super.configure(httpSecurity);
 
        httpSecurity.csrf().disable();
        httpSecurity.httpBasic();
        // add matchers for REST API to httpSecurity
        this.matchersSecurityConfiguration.getMatchers().configure(httpSecurity);
 
        httpSecurity.authorizeRequests().anyRequest().fullyAuthenticated();
    }
}

Nachdem wir die Spring-Security-Konfiguration angepasst haben, müssen die Swagger Annotation und die Springfox-Erweiterung erstellt werden.

Custom Annotation

Für unseren Use Case erstellen wir die Custom API Annotation ApiRoleAccessNotes:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiRoleAccessNotes {
}

Die Annotation hat keinerlei Parameter, sie ist ein Marker für unser OperationBuilderPlugin.

Custom Springfox OperationBuilderPlugin

Damit die oben definierte Annotation verwendet werden kann, muss das OperationBuilderPlugin Interface als Spring Component implementiert werden. Die apply-Methode wird vom Springfox DocumentationPluginsManager für jede REST-Resource im Classpath aufgerufen:

@Component
@Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER)
public class OperationNotesResourcesReader implements springfox.documentation.spi.service.OperationBuilderPlugin {
    private final DescriptionResolver descriptions;
 
    @Autowired
    private MatchersSecurityConfiguration matchersSecurityConfiguration;
 
    final static Logger logger = LoggerFactory.getLogger(OperationNotesResourcesReader.class);
 
    @Autowired
    public OperationNotesResourcesReader(DescriptionResolver descriptions) {
        this.descriptions = descriptions;
    }
 
    @Override
    public void apply(OperationContext context) {
        try {
            Optional methodAnnotation = context.findAnnotation(ApiRoleAccessNotes.class);
            if ( !methodAnnotation.isPresent() || this.matchersSecurityConfiguration == null) {
                // the REST Resource does not have the @ApiRoleAccessNotes annotation --> ignore
                return;
            }
            String apiRoleAccessNoteText = "Accessible by users having one of the following roles: ";
            HttpMethodResourceAntMatchers matchers = matchersSecurityConfiguration.getMatchers();
            // get all configured ant-matchers and try to match with the current REST resource
            for (HttpMethodResourceAntMatcher matcher : matchers.matcherList) {
                // get the RequestMapping annotation, which contains the http-method
                Optional requestMappingOptional = context.findAnnotation(RequestMapping.class);
                if (matcher.getMethod() == getHttpMethod(requestMappingOptional)) {
                    AntPathMatcher antPathMatcher = new AntPathMatcher();
                    String path = context.requestMappingPattern();
                    if (path == null) {
                        continue;
                    }
                    boolean matches = antPathMatcher.match(matcher.getAntPattern(), path);
                    if (matches) {
                        // we found a match for both http-method and URL-path, get the roles
                        // add the roles to the notes. Use Markdown notation to create a list
                        apiRoleAccessNoteText = apiRoleAccessNoteText + "\n * " +  String.join("\n * ", matcher.getRoles());
                    }
                }
 
            }
            // add the note text to the Swagger UI
            context.operationBuilder().notes(descriptions.resolve(apiRoleAccessNoteText));
        } catch (Exception e) {
            logger.error("Error when creating swagger documentation for security roles: " + e);
        }
    }
 
    private HttpMethod getHttpMethod(Optional requestMappingOptional) {
        if (!requestMappingOptional.isPresent()) return null;
        if (requestMappingOptional.get().method() == null || requestMappingOptional.get().method()[0] == null)
            return null;
        RequestMethod requestMethod = requestMappingOptional.get().method()[0];
        switch (requestMethod) {
            case GET:
                return HttpMethod.GET;
            case PUT:
                return HttpMethod.PUT;
            case POST:
                return HttpMethod.POST;
            case DELETE:
                return HttpMethod.DELETE;
        }
        return null;
    }
 
    @Override
    public boolean supports(DocumentationType delimiter) {
        return SwaggerPluginSupport.pluginDoesApply(delimiter);
    }
}

Innerhalb der Klasse wird geprüft, ob die aktuell von Springfox verarbeitete Ressource unsere ApiRoleAccessNotes-Annotation besitzt. Ist dies der Fall, dann holen wir uns alle Spring Security Matcher und prüfen, welche davon auf die die aktuelle Ressource passen – sowohl in Bezug auf die Http-Methode also auch auf den konfigurierten URL-Path.

Custom Annotion @ REST Controller

Abschließend muss die Custom Annotation @ApiRoleAccessNotes zu allen REST-Ressourcen hinzugefügt werden, für die wir im Swagger UI die konfigurierten Rollen sehen wollen:

@RestController
public class MyRestController {
    @RequestMapping(method = RequestMethod.GET, value = "/user/{username}", produces = APPLICATION_JSON_VALUE)
    @ApiOperation("Get details of a user with the given username")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Details about the given user"),
            @ApiResponse(code = 401, message = "Cannot authenticate"),
    })
    @ApiRoleAccessNotes
    public UserDTO getExampleData(@Valid
                                  @ApiParam("Non-empty username") @PathVariable(name = "username", required = true) String username) {
        /*
            Get your user resource somehow and return
         */
    }
 
    @PostMapping(value = "/user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseStatus(HttpStatus.CREATED)
    @ApiOperation("Create a new user or update an existing user (based on username)")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "User was successfully updated"),
            @ApiResponse(code = 200, message = "User was successfully created"),
            @ApiResponse(code = 401, message = "Cannot authenticate"),
    })
    @ApiRoleAccessNotes
    public void createUser(@Valid @RequestBody UserDTO userDTO) {
        /*
            Save the user resource somehow
        */
    }
}

Im Swagger UI sieht das Ganze dann folgendermaßen aus:

Custom Swagger UI

Fazit

Mit ein paar wenigen Kniffen haben wir es geschafft, dass unsere Dokumentation wieder etwas näher an den Sourcecode gerückt ist. Der Sourcecode steht unter https://github.com/HenningWaack/SpringFoxSwaggerExtensionDemo zur Verfügung. Viel Spaß damit, und ich freue mich über alle Kommentare zu diesem Post!

Henning Waack

Henning arbeitet seit mehr als 10 Jahren als IT Berater mit Fokus auf skalierbare und robuste System-Integration. In letzter Zeit treiben ihn besonders die Themen DevOps, Test-Automatisierung und natürlich Cloud um.

Kommentare

  • Mahatma_Fatal_Error

    3. November 2018 von Mahatma_Fatal_Error

    Toller Artikel! Hast du vielleicht eine Idee wie man zusätzlich Spring Security Annotationen an den Endpoints wie z.B. @Secured oder @PreAuthorize in die Swagger Dokumentation integrieren kann?

    Das entsprechende GitHub Issue kommt nicht wirklich voran https://github.com/springfox/springfox/issues/1655 und steht auf ‚help wanted‘.

    • Henning Waack

      4. November 2018 von Henning Waack

      Hallo. Das Prinzip sollte ziemlich das gleich sein. Im Grunde genommen muss die Methode apply() in der Klasse OperationNotesResourcesReader.class angepasst werden, damit die von die genannten Annotations auch mit verarbeitet werden. Ich weiß nicht genau, was die Annotation @Secured z.B. an Informationen mitbringt, also müsstest du recherchieren, welche Information zur Laufzeit über die Annotation vorliegt und welche du dir andersweitig holen musst. Ich hoffe, dass dich das einen kleinen Schritt weiter bringt 🙂 Grüße, Henning

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.