> 이전글 : [Kotlin / Spring] Spring Security 도입기 [2] : Custom Filter 구현하기
spring security 를 사용하면서 이것이 동작할 조건을 설정하기 위해 `Security Config` 를 작성한다.
작업하는 프로젝트에서는 spring security 를 통해 검증할 토큰의 종류가 크게 2가지 이다.
- Basic token
- Bearer token
기본적으로 Spring Security 는 Basic Auth 를 지원하기 때문에 만약 프로젝트가 다른형식의 인증방식을 따른다면 이를 커스텀 해주어야 한다. 이전에 작성한 CustomFilter 가 이를 위한 것이다.
[1] Security Config 작성하기
Basic token 검증과 CustomFilter 검증을 위해 두개의 Bean 을 등록해야 한다.
@Configuration
class SecurityConfig(
private val jwtAdminTokenFilter: JwtAdminTokenFilter, // 직접 구현한 Custom Filter
) {
@Bean
@Order(1)
fun adminBasicSecurityFilterChain(
http: HttpSecurity,
customAuthenticationEntryPoint: CustomAuthenticationEntryPoint,
): SecurityFilterChain {
http
.securityMatcher("/api/admin/token")
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests {
it.anyRequest().authenticated()
}.httpBasic {
it.authenticationEntryPoint(customAuthenticationEntryPoint) // 커스텀 EntryPoint 적용
}
return http.build()
}
@Bean
@Order(2)
fun adminAccessSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.securityMatcher("/api/user/token")
.authorizeHttpRequests {
it
.anyRequest()
.authenticated() // 나머지는 모두 인증 필요
}.addFilterBefore(jwtAdminTokenFilter, UsernamePasswordAuthenticationFilter::class.java)
.formLogin { it.disable() }
.httpBasic { it.disable() } // Basic Auth 완전 제거
return http.build()
}
class 위에 어노테이션으로 @Configuration 과 @EnableWebSecurity 를 달아주어 해당 클래스가 스프링 설정 클래스여서 내부의 @Bean 메서드를 스프링 컨테이너에 빈으로 등록하겠다는것과, Spring Security 설정을 활성화 하겠다는것을 의미한다.
메소드로 SecurityFilterChain을 반환하는 함수를 선언한다. 각 줄에 대한 의미는 주석으로 달아두었다.
+) Order 어노테이션으로 순서 결정하기
여러 개의 SecurityFilterChain을 Bean 으로 등록하면, Spring Security는 가장 낮은 @Order 값을 가진 FilterChain부터 순서대로 경로 매칭(securityMatcher)을 수행한다.
즉, 숫자가 낮을수록 먼저 검사된다.
securityMatcher 로 경로를 지정해두었기 때문에 큰 문제는 없지만, 여러 FilterChain 이 공존할 때 어느 체인이 먼저 평가될지 명시적으로 설정해두는 것이 가장 안전한 방식이다.
[2] Security Config : Basic Token 검증

spring security 는 기본적으로 Basic Token 을 지원하고 있어 Security Config 에서 Baisc token 에 대한 검증시에는 크게 작성할 부분이 없다. 다만, 어떤 경로에 대해서 Basic Token 을 검증할 것인지 정도는 설정해 주어야 한다.
1. AuthenticationProvider 구현
위 코드에서는 `/api/admin/token` 경로일때에 검증하도록 설정해 두었다.
Basic token 에 대한 검증은 spring security의 AuthenticationProvider 에 의해서 감지되고 검증이 진행된다. 해당 프로바이더를 통해 spring security의 `UserDetailsService` 인터페이스의 함수를 호출하게 되는데 `AuthenticationProvider` 를 통해 `UserDetailsService` 로부터 반환된 UsernamePasswordAuthenticationToken 객체를 다시한번 검증하여 최종 리턴하도록 한다.
@Component
class CustomAuthenticationProvider(
private val customUserDetailsService: CustomUserDetailsService,
private val cryptoService: CryptoService,
) : AuthenticationProvider {
override fun authenticate(authentication: Authentication): Authentication {
val adminId = authentication.name
val encryptedPassword =
authentication.credentials as? String
?: throw CustomCredentialException(ErrorCodeType.NOT_EXISTENCE_PASSWORD)
val userDetails = customUserDetailsService.loadUserByUsername(adminId)
// 비밀번호 복호화 하여 검증하기
try {
if (!cryptoService.validateEncryptedPassword(encryptedPassword, userDetails.password)) {
throw CustomCredentialException(ErrorCodeType.INVALID_PASSWORD)
}
} catch (ce: CustomCredentialException) {
throw ce
} catch (ie: IndexOutOfBoundsException) {
throw CustomCredentialException(ErrorCodeType.INVALID_PASSWORD)
} catch (e: Exception) {
throw CustomCredentialException(errorCode = ErrorCodeType.FAIL_SECURITY, exception = e)
}
// return
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
}
override fun supports(authentication: Class<*>): Boolean =
UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
}
Basic Token 은 간단하게 표현하면 아이디-비밀번호 형태로 2개값이 존재한다. 위 코드에서는 아이디로 관리자 ID 값을 지정하고, 비밀번호는 암호화된 키를 사용하도록 했다.
관리자 ID 에대해서는 `UserDetailsService` 구현체 클래스에서 인증된 관리자인지 검증하도록 하고, 이후 암호화된 비밀번호에 대해서 `AuthenticationProvider` 구현체 클래서에서 검증 후 최종적으로 `UsernamePasswordAuthenticationToken` 객체로 반환해준다.
2. UserDetailsService 구현
`UserDetailsService` 인터페이스의 구현체클래스를 작성한다.
@Service
@Transactional(readOnly = true)
class CustomUserDetailsService(
@Value("\${spring.security.user.password}")
private val password: String,
private val adminService: AdminService,
) : UserDetailsService {
private val log = logger()
private val passwordEncoder = BCryptPasswordEncoder()
private val mdcLogging = MdcLogging()
override fun loadUserByUsername(username: String?): UserDetails {
if (username.isNullOrEmpty()) {
throw BException.of(ErrorCodeType.NOT_EXISTENCE_ADMIN)
}
// password parsing 하여 유효성 확인
try {
val adminId = username.toInt()
val admin =
adminService.findByIdOrNull(adminId)
?: throw CustomCredentialException.of(ErrorCodeType.NOT_EXISTENCE_ADMIN)
return BasicAuth(
adminId = adminId,
password = passwordEncoder.encode(password),
authorities = mutableListOf(SimpleGrantedAuthority("ROLE_${admin.roleId}")),
)
} catch (ne: NumberFormatException) {
log.error(ne) { "관리자 Id 타입 변환 오류" }
throw BadCredentialsException("관리자 Id 타입 변환 오류", ne)
} catch (be: BException) {
log.error(be) { "관리자 basic 검증 실패" }
throw BException.of(ge.errorCode, ge)
} catch (e: InternalAuthenticationServiceException) {
log.error(e) { "시스템 내부 오류" }
throw InternalAuthenticationServiceException("시스템 내부 오류", e)
}
}
}
`loadUserByUsername` 이라는 함수를 오버라이드 하여 그 안에 우리 프로젝트에 맞게 로직을 작성해준다. 여기서 반환한 값의 클래스가 controllere 에서 인자로 받게된 클래스 임을 유의하자!!
+) Basic token 검증대상 controller
@RestController
class AdminController(
private val adminAuthService: AdminAuthService,
) {
@PostMapping("/api/admin/token")
fun generateAdminToken(
@AuthenticationPrincipal admin: BasicAuth,
): ResponseEntity<CommonResponse> {
val res = adminAuthService.generateAdminToken(authAdmin = admin)
return ResponseEntity.ok().body(CommonResponse.Success(response = res))
}
}
[3] Security Config : Bearer Token 검증

bearer token 검증을 위해 작성한 Custom Filter 클래스 !!
class JwtAdminTokenFilter(
private val jwtConfig: JwtConfig,
) : AbstractJwtTokenFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
try {
if (adminAccessFilterPath.contains(request.requestURI)) {
val adminTokenInfo =
JwtTokenValidator(
encodedToken = this.processBearerToken(request.getHeader(RequestHeaderType.AUTHORIZATION)),
authTokenType = AuthTokenType.ADMIN.value,
jwtSecretKey = jwtConfig.secretKey,
).verifyAdminToken()
if (adminTokenInfo.payload.userType != UserType.ADMIN.value) {
throw JwtCustomException.of(ErrorCodeType.ACCESS_DENIED)
}
this.setMdcLogTokenInfo(
tokenType = AuthTokenType.ADMIN.value,
tokenJti = adminTokenInfo.jti,
userType = adminTokenInfo.payload.userType,
)
val authInfo =
AuthAdminInfo(adminId = adminTokenInfo.payload.adminId, jti = adminTokenInfo.jti)
// SecurityContext 에 사용자 설정
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(authInfo, null, authInfo.authorities)
}
filterChain.doFilter(request, response)
} catch (e: BException) {
Sentry.captureException(e)
this.processFilterError(response, e.errorCode, e.message)
return
} catch (e: JwtCustomException) {
Sentry.captureException(e)
this.processFilterError(response, e.errorCode, e.message)
return
} catch (e: Exception) {
Sentry.captureException(e)
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.message ?: "Unauthorized")
return
}
}
}
+) 경로로 한번더 체크 ?!
security config 에서 `securityMatchers` 를 통해 경로를 이미 지정했는데 filter 안에서 다시 체크하는 이유는,,
커스텀 필터들은 모두 공통된 JwtConfig 값을 사용해야 하는데 Filter 는 스프링 영역 밖에 위치해 있기 때문에 class body 에서 지연로딩으로 @Value 를 통해 값을 주입할수도 없고, 생성자 주입도 받지 못한다 !!
그래서 아래처럼 SecurityFilterConfig 클래스를 만들어 Filter 들을 Bean 으로 등록하고 JwtConfig 을 전달하도록 하였다.
이렇게 Filter 를 Bean 으로 등록하니 경로와 상관없이 전역필터로 동작해버리는것,,, ㅜㅜ
@Configuration
class SecurityFilterConfig(
private val jwtConfig: JwtConfig,
) {
@Bean
fun jwtAdminAccessTokenFilter(): JwtAdminTokenFilter = JwtAdminTokenFilter(jwtConfig = jwtConfig)
@Bean
fun jwtUserAccessTokenFilter(): JwtUserAccessTokenFilter = JwtUserAccessTokenFilter(jwtConfig = jwtConfig)
@Bean
fun jwtUserRefreshTokenFilter(): JwtUserRefreshTokenFilter = JwtUserRefreshTokenFilter(jwtConfig = jwtConfig)
@Bean
fun jwtTripAccessTokenFilter(): JwtTripTokenFilter = JwtTripTokenFilter(jwtConfig = jwtConfig)
}
그래서 이중으로 경로를 확인하고 있다!
> 일반적인 Spring Security Filter 동작 흐름도

> Bean 으로 등록된 후 Spring Security Filter 동작 흐름도

+) Bearer Token 파싱하여 검증하기
spring security 에서는 Bearer Token 형식에 대해서 자동으로 검증해주는 기능이 없다. 따라서 문자열을 확인하여 개발자가 직접 검증을 해주어야 한다.
fun processBearerToken(authHeader: String?): String {
if (authHeader?.startsWith("Bearer ") == true) {
val token = authHeader.removePrefix("Bearer ").trim()
if (token.isBlank()) {
throw BException.of(ErrorCodeType.NOT_EXISTENCE_BEARER_TOKEN)
}
return token
}
throw BException.of(ErrorCodeType.NOT_EXISTENCE_BEARER_TOKEN)
}
1. Bearer Token 검증 : SecurityContextHoldere 에 저장
토큰을 검증하여 인증된 사용자의 접근인지 확인한다. 검증이 완료되었다면 이후 Controller 에서 인자로 받을 클래스와 동일한 클래스로 SecurityContextHolder 의 authentication 필드에 저장해준다!
그리고 doFilter 로 이후 프로세스가 진행되도록 한다.
2. Bearer Token 검증 : 인증된 사용자의 접근
인증된 사용자라면 이후 요청은 Controller 에 도착한다.
@RestController
class UserAuthController(
private val authService: AuthService,
) {
@PostMapping("/api/user/token")
fun generateNewUserToken(
@RequestBody requestData: GenerateTokenRequestDto,
@AuthAdmin authAdmin: AuthAdminInfo,
): ResponseEntity<CommonResponse> {
val res =
authService.generateNewToken(requestData = requestData, authAdmin = authAdmin)
return ResponseEntity.ok().body(CommonResponse.Success(response = res))
}
}
3. Bearer Token 검증 : Annotation 활용
controller 에 보면 AuthAdminInfo 타입 파라미터에 대해 어노테이션이 걸려있다. 해당 어노테이션의 Resolver 는 아래와 같다.
@Component
class AuthAdminArgumentResolver(
private val adminRepository: AdminRepository,
private val jwtTokenInfoService: JwtTokenInfoService,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean =
parameter.hasParameterAnnotation(AuthAdmin::class.java) && parameter.parameterType == AuthAdminInfo::class.java
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): Any? {
val authentication = SecurityContextHolder.getContext().authentication
val principal = authentication?.principal ?: throw BException.of(ErrorCodeType.NON_AUTHENTICATION)
if (principal !is AuthAdminInfo) {
throw BException.of(ErrorCodeType.PRINCIPLE_TYPE_ERROR)
}
// 관리자 토큰의 상태 확인
jwtTokenInfoService.validateTokenJti(
userId = principal.adminId,
userType = JwtConst.UserType.ADMIN,
jti = principal.jti,
)
// 관리자 존재 확인
adminRepository.findByIdOrNull(id = principal.adminId)
?: throw BException.of(ErrorCodeType.NOT_EXISTENCE_ADMIN)
return principal
}
}
아까 CustomFilter 에서 SecurityContextHodler 의 authentiation 필드에 저장해둔 AuthAdminInfo 인스턴스값을 꺼내서 다시 확인해본다! 최종적으로 리턴할 prinicipal 에서는 이후 Service 단에서 필요한 값들이 들어있다.
AuthAdminInfo(adminId = adminTokenInfo.payload.adminId, jti = adminTokenInfo.jti)
이처럼, Spring Security 를 활용하여 Basic token 과 Bearer token을 검증하는 클래스를 구현하고, 최종적으로 인증된 사용자에 대한 요청이 Controller 로 전달되는것 까지 확인할 수 있었다.
다음번에는 여러 유형의 사용자에 대해서 다양한 커스텀 Filter class 를 구현하고 이를 활용하는 결과물에 대해서 공유해 보도록 하겠다.
'DEV-ing log > Spring' 카테고리의 다른 글
| [Kotlin / Spring] Spring Security 도입기 [2] : Custom Filter 구현하기 (0) | 2025.05.08 |
|---|---|
| [Kotlin / Spring] Spring Security 도입기 [1] : 의존성 설치하기 (0) | 2025.05.02 |
| [Kotlin / Spring] Spring 컨테이너 관리 vs 직접 인스턴스 생성: @Value 어노테이션 문제 해결기 (0) | 2025.04.25 |
| [Kotlin / Spring] Spring Rest Docs 2탄 : x-www-form-urlencoded (3) | 2024.09.15 |
| [Kotlin / Spring] Spring Rest Docs 1탄 - 시작하기 : application/json (0) | 2024.09.12 |