Documenting Spring's login/logout API in Swagger

21,358

Solution 1

A bit late for the party, but since SpringFox relies on Spring beans for building the documentation, we can easily manipulate it. Hope this can help someone!

Register it as a bean

@Primary
@Bean
public ApiListingScanner addExtraOperations(ApiDescriptionReader apiDescriptionReader, ApiModelReader apiModelReader, DocumentationPluginsManager pluginsManager)
{
    return new FormLoginOperations(apiDescriptionReader, apiModelReader, pluginsManager);
}

The class used to add any operation manually:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;

import com.fasterxml.classmate.TypeResolver;
import com.google.common.collect.Multimap;

import springfox.documentation.builders.ApiListingBuilder;
import springfox.documentation.builders.OperationBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiDescription;
import springfox.documentation.service.ApiListing;
import springfox.documentation.service.Operation;
import springfox.documentation.spring.web.plugins.DocumentationPluginsManager;
import springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator;
import springfox.documentation.spring.web.scanners.ApiDescriptionReader;
import springfox.documentation.spring.web.scanners.ApiListingScanner;
import springfox.documentation.spring.web.scanners.ApiListingScanningContext;
import springfox.documentation.spring.web.scanners.ApiModelReader;

public class FormLoginOperations extends ApiListingScanner
{
    @Autowired
    private TypeResolver typeResolver;

    @Autowired
    public FormLoginOperations(ApiDescriptionReader apiDescriptionReader, ApiModelReader apiModelReader, DocumentationPluginsManager pluginsManager)
    {
        super(apiDescriptionReader, apiModelReader, pluginsManager);
    }

    @Override
    public Multimap<String, ApiListing> scan(ApiListingScanningContext context)
    {
        final Multimap<String, ApiListing> def = super.scan(context);

        final List<ApiDescription> apis = new LinkedList<>();

        final List<Operation> operations = new ArrayList<>();
        operations.add(new OperationBuilder(new CachingOperationNameGenerator())
            .method(HttpMethod.POST)
            .uniqueId("login")
            .parameters(Arrays.asList(new ParameterBuilder()
                .name("username")
                .description("The username")
                .parameterType("query")            
                .type(typeResolver.resolve(String.class))
                .modelRef(new ModelRef("string"))
                .build(), 
                new ParameterBuilder()
                .name("password")
                .description("The password")
                .parameterType("query")            
                .type(typeResolver.resolve(String.class))
                .modelRef(new ModelRef("string"))
                .build()))
            .summary("Log in") // 
            .notes("Here you can log in")
            .build());
        apis.add(new ApiDescription("/api/login/", "Authentication documentation", operations, false));

        def.put("authentication", new ApiListingBuilder(context.getDocumentationContext().getApiDescriptionOrdering())
            .apis(apis)
            .description("Custom authentication")
            .build());

        return def;
    }
}

Rendering Swagger json:

"/api/login/" : {
      "post" : {
        "summary" : "Log in",
        "description" : "Here you can log in",
        "operationId" : "loginUsingPOST",
        "parameters" : [ {
          "name" : "username",
          "in" : "query",
          "description" : "The username",
          "required" : false,
          "type" : "string"
        }, {
          "name" : "password",
          "in" : "query",
          "description" : "The password",
          "required" : false,
          "type" : "string"
        } ]
      }
    }

Solution 2

You can add a fake login and logout method in your API just to generate the Swagger documentation, it'll be automatically overriden by Spring Security filters.

@ApiOperation("Login.")
@PostMapping("/login")
public void fakeLogin(@ApiParam("User") @RequestParam String email, @ApiParam("Password") @RequestParam String password) {
    throw new IllegalStateException("This method shouldn't be called. It's implemented by Spring Security filters.");
}

@ApiOperation("Logout.")
@PostMapping("/logout")
public void fakeLogout() {
    throw new IllegalStateException("This method shouldn't be called. It's implemented by Spring Security filters.");
}

Solution 3

Just adding a little correction. If you want to make real POST-request (through HTML page of swagger-ui for example), you need to make little changes to Morten's answer.

Morten's code makes POST request to /login like this:

http://<hostname>/api/login?username=<user>&password=<password>

But if you want to make a POST request you need to pass a body with it, not just query parameters. To make that happen, you need to add parameter with name body and parameter type body like this:

@Override
public Multimap<String, ApiListing> scan(ApiListingScanningContext context)
{
    final Multimap<String, ApiListing> def = super.scan(context);

    final List<ApiDescription> apis = new LinkedList<>();

    final List<Operation> operations = new ArrayList<>();
    operations.add(new OperationBuilder(new CachingOperationNameGenerator())
        .method(HttpMethod.POST)
        .uniqueId("login")
        .parameters(Arrays.asList(new ParameterBuilder()
            .name("body")
            .required(true)
            .description("The body of request")
            .parameterType("body")            
            .type(typeResolver.resolve(String.class))
            .modelRef(new ModelRef("string"))
            .build()))
        .summary("Log in") // 
        .notes("Here you can log in")
        .build());
    apis.add(new ApiDescription("/api/login/", "Authentication documentation", operations, false));

    def.put("authentication", new ApiListingBuilder(context.getDocumentationContext().getApiDescriptionOrdering())
        .apis(apis)
        .description("Custom authentication")
        .build());

    return def;
}

Now we can pass a body with our POST request. A body could be JSON, for example:

{"username":"admin","password":"admin"}

Solution 4

You can use an interface describing the authentication API. The acutal implementation is provided by Spring Security. (This a variation of Italo's answer, where an interface is used instead of a fake implementation.)

/**
 * Authentication API specification for Swagger documentation and Code Generation.
 * Implemented by Spring Security.
 */
@Api("Authentication")
@RequestMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE)
public interface AuthApi {
    /**
     * Implemented by Spring Security
     */
    @ApiOperation(value = "Login", notes = "Login with the given credentials.")
    @ApiResponses({@ApiResponse(code = 200, message = "", response = Authentication.class)})
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    default void login(
        @RequestParam("username") String username,
        @RequestParam("password") String password
    ) {
        throw new IllegalStateException("Add Spring Security to handle authentication");
    }

    /**
     * Implemented by Spring Security
     */
    @ApiOperation(value = "Logout", notes = "Logout the current user.")
    @ApiResponses({@ApiResponse(code = 200, message = "")})
    @RequestMapping(value = "/logout", method = RequestMethod.POST)
    default void logout() {
        throw new IllegalStateException("Add Spring Security to handle authentication");
    }
}
Share:
21,358
Maciej Dobrowolski
Author by

Maciej Dobrowolski

¯\_(ツ)_/¯

Updated on February 07, 2020

Comments

  • Maciej Dobrowolski
    Maciej Dobrowolski about 4 years

    I am developing demo REST service using Spring Boot where user has to login in order to to perform certain subset of operations. After adding Swagger UI (using springfox library) with that simple configuration:

    @Bean
    public Docket docApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                    .apis(any())
                    .paths(PathSelectors.ant("/api/**"))
                    .build()
                .pathMapping("/")
                .apiInfo(apiInfo())
                .directModelSubstitute(LocalDate.class, String.class)
                .useDefaultResponseMessages(true)
                .enableUrlTemplating(true);
    }
    

    I end up with all apis with all operations listed on Swagger UI page. Unfortunately I don't have login/logout endpoints listed among them.

    The problem is that part of that operations cannot be performed via Swagger UI built-in form (I find it really nice feature and would like make it work), because user is not logged in. Is there any solution to that problem? Can I define manually some endpoints in Swagger?

    If there was a form to submit credentials (i.e. login/logout endpoints) I could perform authorization before using that secured endpoints. Then, Swagger user could extract token/sessionid from response and paste it to custom query parameter defined via @ApiImplicitParams.

    Below you can find my security configuration:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginProcessingUrl("/api/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .successHandler(new CustomAuthenticationSuccessHandler())
                    .failureHandler(new CustomAuthenticationFailureHandler())
                    .permitAll()
                    .and()
                .logout()
                    .logoutUrl("/api/logout")
                    .logoutSuccessHandler(new CustomLogoutSuccessHandler())
                    .deleteCookies("JSESSIONID")
                    .permitAll()
                    .and()
                .csrf()
                    .disable()
                .exceptionHandling()
                    .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                    .and()
                .authorizeRequests()
                .and()
                    .headers()
                    .frameOptions()
                    .disable();
    }
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }
    
  • Dimon
    Dimon almost 6 years
    Thank You very match! Bless You!
  • HungUnicorn
    HungUnicorn almost 3 years
    This will be my choice. But I don't see anything unless putting @Controller and also change the interface to public class AuthApi. Any idea?
  • Rajeev
    Rajeev over 2 years
    i am also getting the same issue. any resolution?
  • testing_22
    testing_22 almost 2 years
    Where should I put this code?