//

Springfox Swagger Extensions für Spring Security

1.11.2018 | 8 Minuten Lesezeit

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:

1@Configuration
2public class SecurityConfig extends WebSecurityConfigurerAdapter {
3    @Override
4    protected void configure(HttpSecurity httpSecurity) throws Exception {
5        super.configure(httpSecurity);
6 
7        httpSecurity.csrf().disable();
8        httpSecurity.httpBasic();
9 
10        httpSecurity.authorizeRequests()
11            .antMatchers(HttpMethod.POST, "/user").hasRole("admin")
12            .antMatchers(HttpMethod.GET, "/user/*").hasAnyRole("admin", "user");
13 
14        httpSecurity.authorizeRequests().anyRequest().fullyAuthenticated();
15    }
16}
17

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:

1@RestController
2public class MyRestController {
3    @RequestMapping(method = RequestMethod.GET, value = "/user/{username}", produces = APPLICATION_JSON_VALUE)
4    @ApiOperation("Get details of a user with the given username")
5    @ApiResponses(value = {
6            @ApiResponse(code = 200, message = "Details about the given user"),
7            @ApiResponse(code = 401, message = "Cannot authenticate"),
8    })
9    public UserDTO getExampleData(@Valid
10                                  @ApiParam("Non-empty username") @PathVariable(name = "username", required = true) String username) {
11        /*
12            Get your user resource somehow and return
13         */
14    }
15    @PostMapping(value = "/user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
16    @ResponseStatus(HttpStatus.CREATED)
17    @ApiOperation("Create a new user or update an existing user (based on username)")
18    @ApiResponses(value = {
19            @ApiResponse(code = 200, message = "User was successfully updated"),
20            @ApiResponse(code = 201, message = "User was successfully created"),
21            @ApiResponse(code = 401, message = "Cannot authenticate"),
22    })
23    public void createUser(@Valid @RequestBody UserDTO userDTO) {
24        /*
25            Save the user resource somehow
26        */
27    }
28}
29

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:

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:

1public class HttpMethodResourceAntMatchers {
2    Logger logger = LoggerFactory.getLogger(HttpMethodResourceAntMatchers.class);
3    // list of all defined matchers
4    List matcherList = new ArrayList<>();
5    /**
6     * Applies all existing Matchers to the provided httpSecurity object as .authorizeRequest().antMatchers().hasAnyRole()
7     * @param httpSecurity
8     * @throws Exception
9     */
10    public void configure(org.springframework.security.config.annotation.web.builders.HttpSecurity httpSecurity) throws Exception {
11        for (HttpMethodResourceAntMatcher matcher : this.matcherList) {
12            httpSecurity.authorizeRequests().antMatchers(matcher.getMethod(), matcher.getAntPattern()).hasAnyRole(matcher.getRoles());
13        }
14    }
15    /**
16     * Add a new Matcher with HttpMethod and URL-Path
17     * @param method
18     * @param antPattern
19     * @return
20     */
21    public Role antMatchers(org.springframework.http.HttpMethod method, String antPattern) {
22        // create a new matcher
23        HttpMethodResourceAntMatcher matcher = new HttpMethodResourceAntMatcher(method, antPattern);
24        // add matcher to list of matchers
25        this.matcherList.add(matcher);
26        // return a Role wrapper object, which forces the user to add the role(s) to the matcher
27        Role role = new Role(matcher, this);
28        return role;
29    }
30    /**
31     * Helper class for a builder-like creation pattern
32     */
33    public class Role {
34        HttpMethodResourceAntMatcher matcher;
35        HttpMethodResourceAntMatchers matchers;
36 
37        public Role(HttpMethodResourceAntMatcher matcher, HttpMethodResourceAntMatchers matchers) {
38            this.matcher = matcher;
39            this.matchers = matchers;
40        }
41        /**
42         * Define which role has access to the given resource identified by the Ant-Matcher
43         * @param role
44         * @return
45         */
46        public HttpMethodResourceAntMatchers hasRole(String role) {
47            matcher.setRoles(role);
48            return matchers;
49        }
50        /**
51         * Add a list of roles which have access to the given resource identified by the Ant-Matcher
52         * @param roles
53         * @return
54         */
55        public HttpMethodResourceAntMatchers hasAnyRole(String... roles) {
56            matcher.setRoles(roles);
57            return matchers;
58        }
59    }
60}
61

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:

1@Component
2public class MatchersSecurityConfiguration {
3 
4    private HttpMethodResourceAntMatchers matchers;
5 
6    /**
7     * Returns all http matchers
8     * @return
9     */
10    public HttpMethodResourceAntMatchers getMatchers() {
11        if (matchers == null) {
12            matchers = new HttpMethodResourceAntMatchers();
13            matchers.antMatchers(HttpMethod.POST, "/user").hasRole("admin")
14                    .antMatchers(HttpMethod.GET, "/user/*").hasAnyRole("admin", "user");
15        }
16        return matchers;
17    }
18}
19

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:

1@Configuration
2public class SecurityConfig extends WebSecurityConfigurerAdapter {
3 
4    @Autowired
5    private MatchersSecurityConfiguration matchersSecurityConfiguration;
6 
7    @Override
8    protected void configure(HttpSecurity httpSecurity) throws Exception {
9        super.configure(httpSecurity);
10 
11        httpSecurity.csrf().disable();
12        httpSecurity.httpBasic();
13        // add matchers for REST API to httpSecurity
14        this.matchersSecurityConfiguration.getMatchers().configure(httpSecurity);
15 
16        httpSecurity.authorizeRequests().anyRequest().fullyAuthenticated();
17    }
18}
19

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:

1import java.lang.annotation.ElementType;
2import java.lang.annotation.Retention;
3import java.lang.annotation.RetentionPolicy;
4import java.lang.annotation.Target;
5 
6@Target({ElementType.METHOD})
7@Retention(RetentionPolicy.RUNTIME)
8public @interface ApiRoleAccessNotes {
9}
10

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:

1@Component
2@Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER)
3public class OperationNotesResourcesReader implements springfox.documentation.spi.service.OperationBuilderPlugin {
4    private final DescriptionResolver descriptions;
5 
6    @Autowired
7    private MatchersSecurityConfiguration matchersSecurityConfiguration;
8 
9    final static Logger logger = LoggerFactory.getLogger(OperationNotesResourcesReader.class);
10 
11    @Autowired
12    public OperationNotesResourcesReader(DescriptionResolver descriptions) {
13        this.descriptions = descriptions;
14    }
15 
16    @Override
17    public void apply(OperationContext context) {
18        try {
19            Optional methodAnnotation = context.findAnnotation(ApiRoleAccessNotes.class);
20            if ( !methodAnnotation.isPresent() || this.matchersSecurityConfiguration == null) {
21                // the REST Resource does not have the @ApiRoleAccessNotes annotation --> ignore
22                return;
23            }
24            String apiRoleAccessNoteText = "Accessible by users having one of the following roles: ";
25            HttpMethodResourceAntMatchers matchers = matchersSecurityConfiguration.getMatchers();
26            // get all configured ant-matchers and try to match with the current REST resource
27            for (HttpMethodResourceAntMatcher matcher : matchers.matcherList) {
28                // get the RequestMapping annotation, which contains the http-method
29                Optional requestMappingOptional = context.findAnnotation(RequestMapping.class);
30                if (matcher.getMethod() == getHttpMethod(requestMappingOptional)) {
31                    AntPathMatcher antPathMatcher = new AntPathMatcher();
32                    String path = context.requestMappingPattern();
33                    if (path == null) {
34                        continue;
35                    }
36                    boolean matches = antPathMatcher.match(matcher.getAntPattern(), path);
37                    if (matches) {
38                        // we found a match for both http-method and URL-path, get the roles
39                        // add the roles to the notes. Use Markdown notation to create a list
40                        apiRoleAccessNoteText = apiRoleAccessNoteText + "\n * " +  String.join("\n * ", matcher.getRoles());
41                    }
42                }
43 
44            }
45            // add the note text to the Swagger UI
46            context.operationBuilder().notes(descriptions.resolve(apiRoleAccessNoteText));
47        } catch (Exception e) {
48            logger.error("Error when creating swagger documentation for security roles: " + e);
49        }
50    }
51 
52    private HttpMethod getHttpMethod(Optional requestMappingOptional) {
53        if (!requestMappingOptional.isPresent()) return null;
54        if (requestMappingOptional.get().method() == null || requestMappingOptional.get().method()[0] == null)
55            return null;
56        RequestMethod requestMethod = requestMappingOptional.get().method()[0];
57        switch (requestMethod) {
58            case GET:
59                return HttpMethod.GET;
60            case PUT:
61                return HttpMethod.PUT;
62            case POST:
63                return HttpMethod.POST;
64            case DELETE:
65                return HttpMethod.DELETE;
66        }
67        return null;
68    }
69 
70    @Override
71    public boolean supports(DocumentationType delimiter) {
72        return SwaggerPluginSupport.pluginDoesApply(delimiter);
73    }
74}
75

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:

1@RestController
2public class MyRestController {
3    @RequestMapping(method = RequestMethod.GET, value = "/user/{username}", produces = APPLICATION_JSON_VALUE)
4    @ApiOperation("Get details of a user with the given username")
5    @ApiResponses(value = {
6            @ApiResponse(code = 200, message = "Details about the given user"),
7            @ApiResponse(code = 401, message = "Cannot authenticate"),
8    })
9    @ApiRoleAccessNotes
10    public UserDTO getExampleData(@Valid
11                                  @ApiParam("Non-empty username") @PathVariable(name = "username", required = true) String username) {
12        /*
13            Get your user resource somehow and return
14         */
15    }
16 
17    @PostMapping(value = "/user", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
18    @ResponseStatus(HttpStatus.CREATED)
19    @ApiOperation("Create a new user or update an existing user (based on username)")
20    @ApiResponses(value = {
21            @ApiResponse(code = 200, message = "User was successfully updated"),
22            @ApiResponse(code = 200, message = "User was successfully created"),
23            @ApiResponse(code = 401, message = "Cannot authenticate"),
24    })
25    @ApiRoleAccessNotes
26    public void createUser(@Valid @RequestBody UserDTO userDTO) {
27        /*
28            Save the user resource somehow
29        */
30    }
31}
32

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

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!

Beitrag teilen

Gefällt mir

0

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.