야미의 개발

[SpringBoot | Kotlin] @PublicApi 어노테이션 하나로 Spring Security와 Swagger에서 API 인증 제외하기 본문

스프링/웹 개발

[SpringBoot | Kotlin] @PublicApi 어노테이션 하나로 Spring Security와 Swagger에서 API 인증 제외하기

채야미 2025. 10. 4. 19:52

개선 목표

기존에는 Swagger와 Spring Security에서 공개 API를 따로 관리해야 했습니다.

  • Swagger → @PublicApi 애노테이션으로만 자물쇠 제거
  • Security → PUBLIC_WHITELIST 배열에 직접 경로 추가

즉, 하나의 API를 공개하려면 애노테이션 + SecurityConfig 양쪽을 수정해야 했습니다.
이에 기존의 어노테이션을 재활용하여,

@PublicApi 애노테이션 하나로 Swagger와 Security를 동시에 제어 할 수 있도록 코드를 개선해보았습니다.

 

 

코드 설명

@Component
class PublicApiAuthorizationManager(
    @Qualifier("requestMappingHandlerMapping")
    private val handlerMapping: RequestMappingHandlerMapping
) : AuthorizationManager<RequestAuthorizationContext> {
  • AuthorizationManager<RequestAuthorizationContext>를 구현한 커스텀 인증 관리자
  • requestMappingHandlerMapping을 주입받아 현재 요청에 매핑된 HandlerMethod를 조회

override fun check(
    authentication: Supplier<Authentication>?,
    context: RequestAuthorizationContext
): AuthorizationDecision {
    val handlerMethod = handlerMapping
        .getHandler(context.request)
        ?.handler as? HandlerMethod
  • 요청이 들어올 때 현재 실행될 HandlerMethod(Controller 메서드)를 가져옵니다.
  • 예: /api/v1/cafes → CafeController.getCafes()

    val isPublic = handlerMethod?.hasMethodAnnotation(PublicApi::class.java) == true ||
            handlerMethod?.beanType?.isAnnotationPresent(PublicApi::class.java) == true

    if (isPublic) return AuthorizationDecision(true)
  • @PublicApi 애노테이션이 붙어 있다면 무조건 허용 (AuthorizationDecision(true))
  • 메소드 레벨과 클래스 레벨을 모두 지원

    val auth = authentication?.get()
    val isAuthenticated = auth != null &&
            auth.isAuthenticated &&
            auth !is AnonymousAuthenticationToken

    return AuthorizationDecision(isAuthenticated)
}
  • 그렇지 않으면 일반적인 인증 과정을 거침
  • 단, AnonymousAuthenticationToken은 제외하여 실제 로그인 사용자인지 확인
  • Spring Security는 “SecurityContext에 Authentication이 무조건 존재한다”는 전제를 깔고 있음
    그래서 로그인 안 한 상태로 들어온 요청이라도, 완전 빈 상태(null)로 두지 않고 AnonymousAuthenticationToken을 세팅
  • AnonymousAuthenticationToken도 isAuthenticated = true를 리턴하기 때문에,
    “인증 여부만 체크하면 오인”할 수 있음
  • 그래서 auth !is AnonymousAuthenticationToken 같은 필터링이 꼭 필요

override fun verify(
    authentication: Supplier<Authentication>?,
    context: RequestAuthorizationContext
) {
    val decision = check(authentication, context)
    if (decision == null || !decision.isGranted) {
        throw AccessDeniedException("인증 필요")
    }
}
  • check()의 결과가 false라면 AccessDeniedException 예외 발생
  • 이 로직 덕분에 SecurityConfig에서 @PublicApi만 붙이면 자동으로 인증이 스킵됨

 

전체 인증 흐름 정리 

컨트롤러에 요청이 들어왔을 때, Security와 PublicApiAuthorizationManager가 어떻게 동작하는지 머메이드 차트를 사용하여 표현해보았습니다.

 

메서드에 적용하기

@PublicApi
@GetMapping("/cafes")   // 조회는 공개
fun getCafes() { ... }

@PostMapping("/cafes")  // 생성은 인증 필요
fun createCafe() { ... }

 

이렇게 위와 같이 메서드단에 @PublicApi 가 붙은 컨트롤러 메서드는 자동적으로 인증 없이 요청이 가능합니다.

스프링 시큐리티가 붙은 경우에 인증쪽을 커스터 마이징 하는게 쉽지 않은데 도움이 됐길 바랍니다ㅎㅎ

 

 

전체 코드

 

package com.chapssals.kohee.common.annotation

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PublicApi
package com.chapssals.kohee.app.config

import com.chapssals.kohee.common.annotation.PublicApi
import io.swagger.v3.oas.models.OpenAPI
import org.springdoc.core.customizers.OpenApiCustomizer
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping

@Component
class SecurityAnnotationCustomizer(
    private val requestMappingHandlerMapping: RequestMappingHandlerMapping
) : OpenApiCustomizer {
    
    override fun customise(openApi: OpenAPI) {
        requestMappingHandlerMapping.handlerMethods.forEach { (requestMapping, handlerMethod) ->
            if (handlerMethod.method.isAnnotationPresent(PublicApi::class.java)) {
                val path = requestMapping.pathPatternsCondition?.patterns?.firstOrNull()?.patternString
                val httpMethods = requestMapping.methodsCondition.methods
                
                if (path != null) {
                    removeSecurityFromPath(openApi, path, httpMethods)
                }
            }
        }
    }
    
    private fun removeSecurityFromPath(
        openApi: OpenAPI, 
        path: String, 
        httpMethods: Set<org.springframework.web.bind.annotation.RequestMethod>
    ) {
        openApi.paths?.get(path)?.let { pathItem ->
            httpMethods.forEach { httpMethod ->
                val operation = when (httpMethod.name.lowercase()) {
                    "get" -> pathItem.get
                    "post" -> pathItem.post
                    "put" -> pathItem.put
                    "delete" -> pathItem.delete
                    else -> null
                }
                
                operation?.let {
                    it.security = emptyList()
                }
            }
        }
    }
}

 

package com.chapssals.kohee.auth.config

import com.chapssals.kohee.common.filter.RequestResponseLoggingFilter
import com.chapssals.kohee.security.jwt.JwtAuthenticationEntryPoint
import com.chapssals.kohee.security.jwt.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.filter.CorsFilter

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(
    private val jwtAuthenticationFilter: JwtAuthenticationFilter,
    private val jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint,
    private val requestResponseLoggingFilter: RequestResponseLoggingFilter,
    private val publicApiAuthorizationManager: PublicApiAuthorizationManager
) {

    companion object {
        private val SWAGGER_WHITELIST = arrayOf(
            "/swagger-ui/**",
            "/swagger-ui.html",
            "/api-docs/**",
            "/api-docs.yaml",
            "/swagger-resources/**"
        )

        private val ADMIN_STATIC_WHITELIST = arrayOf(
            "/api/v1/auth/**",
            "/api/v1/admin/auth/**",
            "/admin",  // Admin UI
            "/admin/**",  // Admin UI static resources
        )

        private val ACTUATOR_WHITELIST = arrayOf(
            "/actuator/health/**",
            "/actuator/health",
            "/actuator/info"
        )
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return BCryptPasswordEncoder()
    }

    @Bean
    fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager {
        return authenticationConfiguration.authenticationManager
    }

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf { it.disable() }
            .cors { it.disable() }
            .exceptionHandling { it.authenticationEntryPoint(jwtAuthenticationEntryPoint) }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests {
                it
                    // Swagger UI, Actuator, Admin static resources는 명시적으로 허용
                    .requestMatchers(*SWAGGER_WHITELIST).permitAll()
                    .requestMatchers(*ACTUATOR_WHITELIST).permitAll()
                    .requestMatchers(*ADMIN_STATIC_WHITELIST).permitAll()
            }
            // RequestResponseLoggingFilter를 가장 먼저 실행하여 모든 요청/응답 로깅
            .addFilterBefore(requestResponseLoggingFilter, CorsFilter::class.java)
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)

        return http.build()
    }
}

 

 

package com.chapssals.kohee.auth.config

import com.chapssals.kohee.common.annotation.PublicApi
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.authorization.AuthorizationDecision
import org.springframework.security.authorization.AuthorizationManager
import org.springframework.security.core.Authentication
import org.springframework.security.web.access.intercept.RequestAuthorizationContext
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
import java.util.function.Supplier

@Component
class PublicApiAuthorizationManager(
    @Qualifier("requestMappingHandlerMapping")
    private val handlerMapping: RequestMappingHandlerMapping
) : AuthorizationManager<RequestAuthorizationContext> {

    @Suppress("DEPRECATION")
    override fun check(
        authentication: Supplier<Authentication>?,
        context: RequestAuthorizationContext
    ): AuthorizationDecision {
        val handlerMethod = handlerMapping
            .getHandler(context.request)
            ?.handler as? HandlerMethod

        val isPublic = handlerMethod?.hasMethodAnnotation(PublicApi::class.java) == true ||
                handlerMethod?.beanType?.isAnnotationPresent(PublicApi::class.java) == true

        if (isPublic) return AuthorizationDecision(true)

        val auth = authentication?.get()
        val isAuthenticated = auth != null &&
                auth.isAuthenticated &&
                auth !is AnonymousAuthenticationToken

        return AuthorizationDecision(isAuthenticated)
    }

    override fun verify(
        authentication: Supplier<Authentication>?,
        context: RequestAuthorizationContext
    ) {
        val decision = check(authentication, context)
        if (decision == null || !decision.isGranted) {
            throw AccessDeniedException("인증 필요")
        }
    }
}

 

 

혹시 머메이드 차트도 있으면 좋을까 해서 첨부!

sequenceDiagram
    participant Client as Client
    participant Security as Spring Security FilterChain
    participant PublicApiAuth as PublicApiAuthorizationManager
    participant Controller as Controller Method

    Client->>Security: HTTP Request
    Security->>PublicApiAuth: 인증 필요 여부 확인
    PublicApiAuth->>PublicApiAuth: HandlerMethod 조회
    alt @PublicApi 애노테이션 존재
        PublicApiAuth-->>Security: AuthorizationDecision(허용)
        Security-->>Controller: 요청 전달
        Controller-->>Client: Response (인증 없이 OK)
    else @PublicApi 없음
        PublicApiAuth->>PublicApiAuth: Authentication 확인
        alt 인증 성공
            PublicApiAuth-->>Security: AuthorizationDecision(허용)
            Security-->>Controller: 요청 전달
            Controller-->>Client: Response (인증 성공)
        else 인증 실패
            PublicApiAuth-->>Security: AuthorizationDecision(거부)
            Security-->>Client: 403 Forbidden
        end
    end

 

 

Comments