[Spring Security] Custom AuthenticationFilter API를 swagger로 제공하기

2023. 5. 25. 01:45Spring/Security

문제

프로젝트를 진행하다가 프론트엔드 개발자분께 로그인 관련 API가 swagger 문서에 없다는 말씀을 들었고 확인해보니 적용되어 있지 않았다. springdoc은 기본적으로 Spring Security의 필터를 자동적으로 등록해주지 않는다.

해결 방법

1. swagger에 UsernamePasswordAuthenticationFilter 적용하기

Spring Security는 기본적으로 로그인 로직을 처리하기 위해 UsernamePasswordAuthenticationFilter를 제공한다. 그리고 springdoc 개발자 분들도 이러한 요구사항을 알기 때문에 간단하게 로그인 API를 노출하는 방법을 제공한다. application.yml에서 springdoc.show-login-endpoint를 true로 설정하면 된다.
https://springdoc.org/#how-can-i-make-spring-security-login-endpoint-visible

OpenAPI 3 Library for spring-boot

Library for OpenAPI 3 with spring boot projects. Is based on swagger-ui, to display the OpenAPI description.Generates automatically the OpenAPI file.

springdoc.org

하지만 당연스럽게도 문제는 해결되지 않았다. 왜냐하면 우리 서비스는 UsernamePassowrdAuthenticationFilter가 아닌 커스텀하게 제작한 ajax 기반으로 이메일과 비밀번호를 제공받아 처리하는 필터를 기반으로 로그인을 처리하고 있었기 때문이다. 그렇다면 UsernamePassowrdAuthenticationFilter가 swagger에 등록되는 코드를 확인하면 이를 활용할 수 있을 거라 생각이 들어서 라이브러리의 코드를 확인해 보았다. 아래 코드는 org.springdoc.security.SpringDocSecurityConfiguration에 있다.

@Bean
@ConditionalOnProperty(SPRINGDOC_SHOW_LOGIN_ENDPOINT)
@Lazy(false)
OpenApiCustomiser springSecurityLoginEndpointCustomiser(ApplicationContext applicationContext) {
    FilterChainProxy filterChainProxy = applicationContext.getBean(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class);
    return openAPI -> {
        for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) {
            Optional<UsernamePasswordAuthenticationFilter> optionalFilter =
                filterChain.getFilters().stream()
                    .filter(UsernamePasswordAuthenticationFilter.class::isInstance)
                    .map(UsernamePasswordAuthenticationFilter.class::cast)
                    .findAny();
            if (optionalFilter.isPresent()) {
                UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get();
                Operation operation = new Operation();
                Schema<?> schema = new ObjectSchema()
                    .addProperties(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema())
                    .addProperties(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema());
                RequestBody requestBody = new RequestBody().content(new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, new MediaType().schema(schema)));
                operation.requestBody(requestBody);
                ApiResponses apiResponses = new ApiResponses();
                apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase()));
                apiResponses.addApiResponse(String.valueOf(HttpStatus.FORBIDDEN.value()), new ApiResponse().description(HttpStatus.FORBIDDEN.getReasonPhrase()));
                operation.responses(apiResponses);
                operation.addTagsItem("login-endpoint");
                PathItem pathItem = new PathItem().post(operation);
                openAPI.getPaths().addPathItem("/login", pathItem);
            }
        }
    };
}

위처럼 직접 OpenApiCustomiser를 구현해서 swagger에 등록하고 있었다. 그렇다면 우리가 만든 필터도 이렇게 적용하면 되겠다 싶었고 바로 실행에 옮겼다.

2. swagger에 커스텀 로그인 API 적용하기

SwaggerConfig에 다음과 같이 빈을 등록했다.

// ApplicationContext는 생성자로 주입받은 상태이다.
@Bean
public OpenApiCustomiser springSecurityLoginEndpointCustomiser() {
    FilterChainProxy filterChainProxy = applicationContext.getBean(
        AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class);
    return openAPI -> {
        for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) {
            Optional<AjaxEmailPasswordAuthenticationFilter> optionalFilter =
                filterChain.getFilters().stream()
                        .filter(AjaxEmailPasswordAuthenticationFilter.class::isInstance)
                        .map(AjaxEmailPasswordAuthenticationFilter.class::cast)
                        .findAny();
            if (optionalFilter.isPresent()) {
                AjaxEmailPasswordAuthenticationFilter ajaxEmailPasswordAuthenticationFilter = optionalFilter.get();
                Operation operation = new Operation();
                Schema<?> schema = new ObjectSchema()
                    .addProperties(ajaxEmailPasswordAuthenticationFilter.getEmailParameter(), new StringSchema())
                    .addProperties(ajaxEmailPasswordAuthenticationFilter.getPasswordParameter(),
                        new StringSchema());
                RequestBody requestBody = new RequestBody().content(
                    new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE,
                    new MediaType().schema(schema)));
                operation.requestBody(requestBody);
                ApiResponses apiResponses = new ApiResponses();
                apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()),
                    new ApiResponse().description(HttpStatus.OK.getReasonPhrase()));
                apiResponses.addApiResponse(String.valueOf(HttpStatus.BAD_REQUEST.value()),
                    new ApiResponse().description(HttpStatus.BAD_REQUEST.getReasonPhrase()));
                operation.responses(apiResponses);
                operation.addTagsItem("email-login-endpoint");
                PathItem pathItem = new PathItem().post(operation);
                openAPI.getPaths().addPathItem(LOGIN_WITH_EMAIL, pathItem);
            }
        }
    };
}

그리고 이를 빌더에 등록해주었다.

@Bean
public GroupOpenApi publicApi() {
    return GroupOpenApi.builder()
        .group("F12")
        .pathsToMatch("/api/**")
        .addOpenApiCustomiser(springSecurityLoginEndpointCustomiser())
        .build();
}

이렇게 addOpenApiCustomiser로 등록한 것들은 swagger-ui에 접근하는 경우에 적용된다. 즉 페이지를 생성해야 할 때까진 람다로 내부에서 가지고만 있다가 사용자가 페이지를 요청했을 때 Lazy하게 적용된다.
 

결과

OpenApiCustomiser를 활용하여 성공적으로 Spring Security 기반의 로그인 API를 제공할 수 있었다.

마치며

springdoc은 기본적으로 Servlet의 Filter에 대해서는 API를 제공해주지 않는다. 따라서 Filter 기반으로 처리하는 API가 있거나 커스텀하게 구현한 API를 swagger로 제공하려면 OpenApiCustomiser를 활용하면 된다.