Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
Tags
- hibe
- sops
- sops 암호화
- 스프링 소셜 로그인
- 인증 제외
- 로또 앱 만들기
- java
- 채팅방 구현
- 안드로이드 스튜디오
- oauth 로그인
- 스프링 시큐리티 없이
- sops age
- android studio
- jpa dto 매핑
- 중간 테이블 엔티티 최적화
- 어노테이션 인증
- jpa 최적화
- 스프링 환경변수
- 시크릿 깃에 올리기
- 스프링 오어스
- 어노테이션 인증 제외
- 로또 등수 코드
- spring 채팅방
- jpa bulk insert
- 쿠버네티스 #fabric8
- 시크릿 암호화
- 스프링 환경변수 설정
- springboot
- mysql multi-row insert
- 로또 등수 알고리즘
Archives
- Today
- Total
야미의 개발
[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
'스프링 > 웹 개발' 카테고리의 다른 글
| JPA 쿼리 성능 개선하기 - @BatchSize 어노테이션 (2) | 2025.08.31 |
|---|---|
| SOPS+Age로 시크릿을 git으로 관리하기 (0) | 2025.06.03 |
| 여러 명이 나누는 월세, 어떻게 정산할까? – UserPayment와 Payment로 구현하는 자동 정산 시스템 (0) | 2025.05.06 |
| [Spring/SpringBoot]GitLabRunner로 ci 파이프라인 작성하기 JPA, H2, test 빌드 시 오류 날때 (0) | 2025.02.06 |
| [Spring/SpringBoot] 채팅방 쿼리 개선기 | JPA(Hibernate)+Mysql에서 BulkInsert(벌크 인서트) | Native Query, JDBC Template 비교하기 (1) | 2025.01.02 |
Comments