반응형

> 이전글 : [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 를 구현하고 이를 활용하는 결과물에 대해서 공유해 보도록 하겠다.

반응형

+ Recent posts