diff --git a/src/main/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilter.kt b/src/main/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilter.kt index c5acee1..af83927 100644 --- a/src/main/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilter.kt +++ b/src/main/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilter.kt @@ -12,7 +12,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut import org.springframework.web.filter.OncePerRequestFilter import tools.jackson.databind.ObjectMapper -class JwtAuthenticationValidationFilter( +open class JwtAuthenticationValidationFilter( private val validators: List, private val objectMapper: ObjectMapper, ) : OncePerRequestFilter() { diff --git a/src/main/kotlin/com/unit/platform/security/SecurityConfig.kt b/src/main/kotlin/com/unit/platform/security/SecurityConfig.kt index 64468ef..39b46ab 100644 --- a/src/main/kotlin/com/unit/platform/security/SecurityConfig.kt +++ b/src/main/kotlin/com/unit/platform/security/SecurityConfig.kt @@ -4,14 +4,11 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.web.builders.HttpSecurity 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.AuthenticationEntryPoint import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.access.AccessDeniedHandler import org.springframework.security.web.access.intercept.AuthorizationFilter import tools.jackson.databind.ObjectMapper -import kotlin.jvm.java @Configuration class SecurityConfig( diff --git a/src/test/kotlin/com/unit/member/security/MemberStatusJwtAuthenticationValidatorTest.kt b/src/test/kotlin/com/unit/member/security/MemberStatusJwtAuthenticationValidatorTest.kt new file mode 100644 index 0000000..460c116 --- /dev/null +++ b/src/test/kotlin/com/unit/member/security/MemberStatusJwtAuthenticationValidatorTest.kt @@ -0,0 +1,73 @@ +package com.unit.member.security + +import com.unit.member.enums.MemberStatus +import com.unit.member.repository.MemberRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.springframework.security.oauth2.jwt.Jwt +import kotlin.test.Test + +@DisplayName("회원 상태 JWT 검증기 테스트") +class MemberStatusJwtAuthenticationValidatorTest { + + private val memberRepository = mockk() + private val validator = MemberStatusJwtAuthenticationValidator(memberRepository) + + @Test + @DisplayName("JWT subject의 회원이 활성 상태이면 true를 반환한다") + fun validMember() { + val jwt = createJwt(subject = "1") + + every { + memberRepository.existsByIdAndStatusInAndDeletedAtIsNull( + id = 1L, + statuses = listOf(MemberStatus.PENDING, MemberStatus.ACTIVE), + ) + } returns true + + val result = validator.isValid(jwt) + + assertThat(result).isTrue() + } + + @Test + @DisplayName("JWT subject의 회원이 유효하지 않으면 false를 반환한다") + fun invalidMember() { + val jwt = createJwt(subject = "1") + + every { + memberRepository.existsByIdAndStatusInAndDeletedAtIsNull( + id = 1L, + statuses = listOf(MemberStatus.PENDING, MemberStatus.ACTIVE), + ) + } returns false + + val result = validator.isValid(jwt) + + assertThat(result).isFalse() + } + + @Test + @DisplayName("JWT subject가 숫자가 아니면 false를 반환하고 Repository를 호출하지 않는다") + fun invalidSubject() { + val jwt = createJwt(subject = "invalid") + + val result = validator.isValid(jwt) + + assertThat(result).isFalse() + verify(exactly = 0) { + memberRepository.existsByIdAndStatusInAndDeletedAtIsNull(any(), any()) + } + } + + private fun createJwt(subject: String): Jwt { + return Jwt.withTokenValue("access-token") + .header("alg", "none") + .subject(subject) + .build() + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilterTest.kt b/src/test/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilterTest.kt new file mode 100644 index 0000000..d5d696e --- /dev/null +++ b/src/test/kotlin/com/unit/platform/security/JwtAuthenticationValidationFilterTest.kt @@ -0,0 +1,113 @@ +package com.unit.platform.security + +import com.unit.platform.error.CommonErrorCode +import com.unit.platform.web.response.ErrorResponse +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.FilterChain +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import tools.jackson.module.kotlin.jacksonObjectMapper +import tools.jackson.module.kotlin.readValue +import kotlin.test.Test + +@DisplayName("JWT 인증 추가 검증 필터 테스트") +class JwtAuthenticationValidationFilterTest { + + private val objectMapper = jacksonObjectMapper() + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + } + + @Test + @DisplayName("JWT 인증 정보가 없으면 다음 필터로 넘긴다") + fun passThroughWithoutJwt() { + val validator = mockk() + val filter = JwtAuthenticationValidationFilter(listOf(validator), objectMapper) + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val chain = mockk(relaxed = true) + + filter.doFilter(request, response, chain) + + verify(exactly = 1) { chain.doFilter(request, response) } + verify(exactly = 0) { validator.isValid(any()) } + } + + @Test + @DisplayName("검증기가 없으면 다음 필터로 넘긴다") + fun passThroughWithoutValidators() { + val filter = JwtAuthenticationValidationFilter(emptyList(), objectMapper) + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val chain = mockk(relaxed = true) + + SecurityContextHolder.getContext().authentication = JwtAuthenticationToken(createJwt()) + + filter.doFilter(request, response, chain) + + verify(exactly = 1) { chain.doFilter(request, response) } + } + + @Test + @DisplayName("모든 검증을 통과하면 다음 필터로 넘긴다") + fun passThroughWhenValid() { + val jwt = createJwt() + val validator = mockk() + val filter = JwtAuthenticationValidationFilter(listOf(validator), objectMapper) + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val chain = mockk(relaxed = true) + + SecurityContextHolder.getContext().authentication = JwtAuthenticationToken(jwt) + every { validator.isValid(jwt) } returns true + + filter.doFilter(request, response, chain) + + verify(exactly = 1) { validator.isValid(jwt) } + verify(exactly = 1) { chain.doFilter(request, response) } + } + + @Test + @DisplayName("검증에 실패하면 INVALID_TOKEN 응답을 반환하고 다음 필터로 넘기지 않는다") + fun rejectWhenInvalid() { + val jwt = createJwt() + val validator = mockk() + val filter = JwtAuthenticationValidationFilter(listOf(validator), objectMapper) + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val chain = mockk(relaxed = true) + + SecurityContextHolder.getContext().authentication = JwtAuthenticationToken(jwt) + every { validator.isValid(jwt) } returns false + + filter.doFilter(request, response, chain) + + val body = objectMapper.readValue(response.contentAsString) + + assertThat(response.status).isEqualTo(CommonErrorCode.INVALID_TOKEN.status.value()) + assertThat(body.code).isEqualTo(CommonErrorCode.INVALID_TOKEN.code) + assertThat(body.message).isEqualTo(CommonErrorCode.INVALID_TOKEN.message) + assertThat(body.traceId).isNotBlank() + + verify(exactly = 1) { validator.isValid(jwt) } + verify(exactly = 0) { chain.doFilter(any(), any()) } + } + + private fun createJwt(subject: String = "1"): Jwt { + return Jwt.withTokenValue("access-token") + .header("alg", "none") + .subject(subject) + .build() + } + +} \ No newline at end of file