diff --git a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessDeniedException.kt b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessDeniedException.kt new file mode 100644 index 00000000..67871d08 --- /dev/null +++ b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessDeniedException.kt @@ -0,0 +1,15 @@ +package com.ttasjwi.board.system.auth.domain.exception + +import com.ttasjwi.board.system.core.exception.CustomException +import com.ttasjwi.board.system.core.exception.ErrorStatus + +class AccessDeniedException( + cause: Throwable? = null, +) : CustomException( + status = ErrorStatus.FORBIDDEN, + code = "Error.AccessDenied", + args = emptyList(), + source = "credentials", + debugMessage = "리소스에 접근할 수 없습니다. 해당 리소스에 접근할 권한이 없습니다.", + cause = cause +) diff --git a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessTokenExpiredException.kt b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessTokenExpiredException.kt new file mode 100644 index 00000000..e1e79aa4 --- /dev/null +++ b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessTokenExpiredException.kt @@ -0,0 +1,16 @@ +package com.ttasjwi.board.system.auth.domain.exception + +import com.ttasjwi.board.system.core.exception.CustomException +import com.ttasjwi.board.system.core.exception.ErrorStatus +import java.time.ZonedDateTime + +class AccessTokenExpiredException( + expiredAt: ZonedDateTime, + currentTime: ZonedDateTime +) : CustomException( + status = ErrorStatus.UNAUTHENTICATED, + code = "Error.AccessTokenExpired", + args = listOf(expiredAt, currentTime), + source = "accessToken", + debugMessage = "액세스 토큰의 유효시간이 경과되어 더 이상 유효하지 않습니다. (expiredAt=${expiredAt},currentTime=${currentTime}) 리프레시 토큰을 통해 갱신해주세요. 리프레시 토큰도 만료됐다면 로그인을 다시 하셔야합니다." +) diff --git a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/UnauthenticatedException.kt b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/UnauthenticatedException.kt new file mode 100644 index 00000000..02add523 --- /dev/null +++ b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/exception/UnauthenticatedException.kt @@ -0,0 +1,15 @@ +package com.ttasjwi.board.system.auth.domain.exception + +import com.ttasjwi.board.system.core.exception.CustomException +import com.ttasjwi.board.system.core.exception.ErrorStatus + +class UnauthenticatedException( + cause: Throwable? = null, +) : CustomException( + status = ErrorStatus.UNAUTHENTICATED, + code = "Error.Unauthenticated", + args = emptyList(), + source = "credentials", + debugMessage = "리소스에 접근할 수 없습니다. 이 리소스에 접근하기 위해서는 인증이 필요합니다.", + cause = cause +) diff --git a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/model/AccessToken.kt b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/model/AccessToken.kt index 6c5fec52..bfd87a2e 100644 --- a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/model/AccessToken.kt +++ b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/model/AccessToken.kt @@ -1,5 +1,6 @@ package com.ttasjwi.board.system.auth.domain.model +import com.ttasjwi.board.system.auth.domain.exception.AccessTokenExpiredException import java.time.ZonedDateTime class AccessToken @@ -50,4 +51,10 @@ internal constructor( result = 31 * result + expiresAt.hashCode() return result } + + internal fun checkCurrentlyValid(currentTime: ZonedDateTime) { + if (currentTime >= this.expiresAt) { + throw AccessTokenExpiredException(expiredAt = this.expiresAt, currentTime = currentTime) + } + } } diff --git a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/AccessTokenManager.kt b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/AccessTokenManager.kt index 9995f446..30ab9e29 100644 --- a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/AccessTokenManager.kt +++ b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/AccessTokenManager.kt @@ -8,4 +8,5 @@ interface AccessTokenManager { fun generate(authMember: AuthMember, issuedAt: ZonedDateTime): AccessToken fun parse(tokenValue: String): AccessToken + fun checkCurrentlyValid(accessToken: AccessToken, currentTime: ZonedDateTime) } diff --git a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImpl.kt b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImpl.kt index bd772f64..714a00bd 100644 --- a/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImpl.kt +++ b/board-system-domain/domain-auth/src/main/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImpl.kt @@ -20,4 +20,8 @@ internal class AccessTokenManagerImpl( override fun parse(tokenValue: String): AccessToken { return externalAccessTokenManager.parse(tokenValue) } + + override fun checkCurrentlyValid(accessToken: AccessToken, currentTime: ZonedDateTime) { + accessToken.checkCurrentlyValid(currentTime) + } } diff --git a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessDeniedExceptionTest.kt b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessDeniedExceptionTest.kt new file mode 100644 index 00000000..4c296502 --- /dev/null +++ b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessDeniedExceptionTest.kt @@ -0,0 +1,40 @@ +package com.ttasjwi.board.system.auth.domain.exception + +import com.ttasjwi.board.system.core.exception.ErrorStatus +import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("AccessDeniedException: 인가 실패, 권한부족 상황에서 발생하는 예외") +class AccessDeniedExceptionTest { + + @Test + @DisplayName("예외 기본값 테스트") + fun test1() { + val exception = AccessDeniedException() + + assertThat(exception.status).isEqualTo(ErrorStatus.FORBIDDEN) + assertThat(exception.code).isEqualTo("Error.AccessDenied") + assertThat(exception.source).isEqualTo("credentials") + assertThat(exception.args).isEmpty() + assertThat(exception.message).isEqualTo(exception.debugMessage) + assertThat(exception.cause).isNull() + assertThat(exception.debugMessage).isEqualTo("리소스에 접근할 수 없습니다. 해당 리소스에 접근할 권한이 없습니다.") + } + + @Test + @DisplayName("근원 예외를 전달하여 생성할 수 있다.") + fun test2() { + val cause = customExceptionFixture() + val exception = AccessDeniedException(cause) + + assertThat(exception.status).isEqualTo(ErrorStatus.FORBIDDEN) + assertThat(exception.code).isEqualTo("Error.AccessDenied") + assertThat(exception.source).isEqualTo("credentials") + assertThat(exception.args).isEmpty() + assertThat(exception.message).isEqualTo(exception.debugMessage) + assertThat(exception.cause).isEqualTo(cause) + assertThat(exception.debugMessage).isEqualTo("리소스에 접근할 수 없습니다. 해당 리소스에 접근할 권한이 없습니다.") + } +} diff --git a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessTokenExpiredExceptionTest.kt b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessTokenExpiredExceptionTest.kt new file mode 100644 index 00000000..95ae7bce --- /dev/null +++ b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/AccessTokenExpiredExceptionTest.kt @@ -0,0 +1,27 @@ +package com.ttasjwi.board.system.auth.domain.exception + +import com.ttasjwi.board.system.core.exception.ErrorStatus +import com.ttasjwi.board.system.core.time.fixture.timeFixture +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("AccessTokenExpiredException: 액세스토큰이 만료됐을 때 발생하는 예외") +class AccessTokenExpiredExceptionTest { + + @Test + @DisplayName("예외 기본값 테스트") + fun test1() { + val expiredAt = timeFixture(minute = 3) + val currentTime = timeFixture(minute = 5) + val exception = AccessTokenExpiredException(expiredAt, currentTime) + + assertThat(exception.status).isEqualTo(ErrorStatus.UNAUTHENTICATED) + assertThat(exception.code).isEqualTo("Error.AccessTokenExpired") + assertThat(exception.source).isEqualTo("accessToken") + assertThat(exception.args).containsExactly(expiredAt, currentTime) + assertThat(exception.message).isEqualTo(exception.debugMessage) + assertThat(exception.cause).isNull() + assertThat(exception.debugMessage).isEqualTo("액세스 토큰의 유효시간이 경과되어 더 이상 유효하지 않습니다. (expiredAt=${expiredAt},currentTime=${currentTime}) 리프레시 토큰을 통해 갱신해주세요. 리프레시 토큰도 만료됐다면 로그인을 다시 하셔야합니다.") + } +} diff --git a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/UnauthenticatedExceptionTest.kt b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/UnauthenticatedExceptionTest.kt new file mode 100644 index 00000000..97da886a --- /dev/null +++ b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/exception/UnauthenticatedExceptionTest.kt @@ -0,0 +1,40 @@ +package com.ttasjwi.board.system.auth.domain.exception + +import com.ttasjwi.board.system.core.exception.ErrorStatus +import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("UnauthenticatedException: 인증이 필요할 때 발생하는 예외") +class UnauthenticatedExceptionTest { + + @Test + @DisplayName("예외 기본값 테스트") + fun test1() { + val exception = UnauthenticatedException() + + assertThat(exception.status).isEqualTo(ErrorStatus.UNAUTHENTICATED) + assertThat(exception.code).isEqualTo("Error.Unauthenticated") + assertThat(exception.source).isEqualTo("credentials") + assertThat(exception.args).isEmpty() + assertThat(exception.message).isEqualTo(exception.debugMessage) + assertThat(exception.cause).isNull() + assertThat(exception.debugMessage).isEqualTo("리소스에 접근할 수 없습니다. 이 리소스에 접근하기 위해서는 인증이 필요합니다.") + } + + @Test + @DisplayName("근원 예외를 전달하여 생성할 수 있다.") + fun test2() { + val cause = customExceptionFixture() + val exception = UnauthenticatedException(cause) + + assertThat(exception.status).isEqualTo(ErrorStatus.UNAUTHENTICATED) + assertThat(exception.code).isEqualTo("Error.Unauthenticated") + assertThat(exception.source).isEqualTo("credentials") + assertThat(exception.args).isEmpty() + assertThat(exception.message).isEqualTo(exception.debugMessage) + assertThat(exception.cause).isEqualTo(cause) + assertThat(exception.debugMessage).isEqualTo("리소스에 접근할 수 없습니다. 이 리소스에 접근하기 위해서는 인증이 필요합니다.") + } +} diff --git a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixtureTest.kt b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixtureTest.kt index 743d4461..46875a47 100644 --- a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixtureTest.kt +++ b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixtureTest.kt @@ -1,13 +1,12 @@ package com.ttasjwi.board.system.auth.domain.service.fixture +import com.ttasjwi.board.system.auth.domain.exception.AccessTokenExpiredException +import com.ttasjwi.board.system.auth.domain.model.fixture.accessTokenFixture import com.ttasjwi.board.system.auth.domain.model.fixture.authMemberFixture import com.ttasjwi.board.system.core.time.fixture.timeFixture import com.ttasjwi.board.system.logging.getLogger import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.* @DisplayName("AccessTokenManager 픽스쳐 테스트") class AccessTokenManagerFixtureTest { @@ -69,4 +68,62 @@ class AccessTokenManagerFixtureTest { assertThat(accessToken.expiresAt).isEqualTo(timeFixture(minute = 35)) } } + + + @Nested + @DisplayName("checkCurrentlyValid: 액세스토큰이 현재 유효한 지 검증한다") + inner class CheckCurrentlyValid { + + @Test + @DisplayName("현재 시간이 만료시간 이전 -> 성공") + fun testValid() { + // given + val accessToken = accessTokenFixture( + issuedAt = timeFixture(minute = 0), + expiresAt = timeFixture(minute = 2) + ) + val currentTime = timeFixture(minute = 1) + + // when + // then + accessTokenManagerFixture.checkCurrentlyValid(accessToken, currentTime) + } + + @Test + @DisplayName("현재 시간이 만료시간과 같음 -> 예외") + fun testExpired1() { + // given + val accessToken = accessTokenFixture( + issuedAt = timeFixture(minute = 0), + expiresAt = timeFixture(minute = 2) + ) + val currentTime = timeFixture(minute = 2) + + // when + // then + assertThrows { + accessTokenManagerFixture.checkCurrentlyValid(accessToken, currentTime) + } + } + + @Test + @DisplayName("현재 시간이 만료시간 이후 -> 예외") + fun testExpired2() { + // given + val accessToken = accessTokenFixture( + issuedAt = timeFixture(minute = 0), + expiresAt = timeFixture(minute = 2) + ) + val currentTime = timeFixture(minute = 3) + + // when + // then + assertThrows { + accessTokenManagerFixture.checkCurrentlyValid( + accessToken, + currentTime + ) + } + } + } } diff --git a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImplTest.kt b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImplTest.kt index d3e9c7ae..145585a0 100644 --- a/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImplTest.kt +++ b/board-system-domain/domain-auth/src/test/kotlin/com/ttasjwi/board/system/auth/domain/service/impl/AccessTokenManagerImplTest.kt @@ -1,16 +1,15 @@ package com.ttasjwi.board.system.auth.domain.service.impl +import com.ttasjwi.board.system.auth.domain.exception.AccessTokenExpiredException import com.ttasjwi.board.system.auth.domain.external.fixture.ExternalAccessTokenManagerFixture import com.ttasjwi.board.system.auth.domain.model.AccessToken +import com.ttasjwi.board.system.auth.domain.model.fixture.accessTokenFixture import com.ttasjwi.board.system.auth.domain.model.fixture.authMemberFixture import com.ttasjwi.board.system.auth.domain.service.AccessTokenManager import com.ttasjwi.board.system.core.time.fixture.timeFixture import com.ttasjwi.board.system.logging.getLogger import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.* @DisplayName("AccessTokenManagerImpl 테스트") class AccessTokenManagerImplTest { @@ -76,4 +75,61 @@ class AccessTokenManagerImplTest { } } + @Nested + @DisplayName("checkCurrentlyValid: 액세스토큰이 현재 유효한 지 검증한다") + inner class CheckCurrentlyValid { + + + @Test + @DisplayName("현재 시간이 만료시간 이전 -> 성공") + fun testValid() { + // given + val accessToken = accessTokenFixture( + issuedAt = timeFixture(minute = 0), + expiresAt = timeFixture(minute = 2) + ) + val currentTime = timeFixture(minute = 1) + + // when + // then + accessTokenManager.checkCurrentlyValid(accessToken, currentTime) + } + + @Test + @DisplayName("현재 시간이 만료시간과 같음 -> 예외") + fun testExpired1() { + // given + val accessToken = accessTokenFixture( + issuedAt = timeFixture(minute = 0), + expiresAt = timeFixture(minute = 2) + ) + val currentTime = timeFixture(minute = 2) + + // when + // then + assertThrows { + accessTokenManager.checkCurrentlyValid(accessToken, currentTime) + } + } + + @Test + @DisplayName("현재 시간이 만료시간 이후 -> 예외") + fun testExpired2() { + // given + val accessToken = accessTokenFixture( + issuedAt = timeFixture(minute = 0), + expiresAt = timeFixture(minute = 2) + ) + val currentTime = timeFixture(minute = 3) + + // when + // then + assertThrows { + accessTokenManager.checkCurrentlyValid( + accessToken, + currentTime + ) + } + } + } } diff --git a/board-system-domain/domain-auth/src/testFixtures/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixture.kt b/board-system-domain/domain-auth/src/testFixtures/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixture.kt index a2d38bf6..b9616646 100644 --- a/board-system-domain/domain-auth/src/testFixtures/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixture.kt +++ b/board-system-domain/domain-auth/src/testFixtures/kotlin/com/ttasjwi/board/system/auth/domain/service/fixture/AccessTokenManagerFixture.kt @@ -1,9 +1,11 @@ package com.ttasjwi.board.system.auth.domain.service.fixture +import com.ttasjwi.board.system.auth.domain.exception.AccessTokenExpiredException import com.ttasjwi.board.system.auth.domain.model.AccessToken import com.ttasjwi.board.system.auth.domain.model.AuthMember import com.ttasjwi.board.system.auth.domain.model.fixture.accessTokenFixture import com.ttasjwi.board.system.auth.domain.service.AccessTokenManager +import com.ttasjwi.board.system.core.time.fixture.timeFixture import com.ttasjwi.board.system.member.domain.model.Role import java.time.ZonedDateTime @@ -18,6 +20,8 @@ class AccessTokenManagerFixture : AccessTokenManager { private const val ISSUED_AT_INDEX = 2 private const val EXPIRES_AT_INDEX = 3 private const val TOKEN_TYPE = "accessToken" + + val ERROR_CHECK_TIME = timeFixture(minute = 37) } override fun generate(authMember: AuthMember, issuedAt: ZonedDateTime): AccessToken { @@ -44,6 +48,12 @@ class AccessTokenManagerFixture : AccessTokenManager { ) } + override fun checkCurrentlyValid(accessToken: AccessToken, currentTime: ZonedDateTime) { + if (accessToken.expiresAt <= currentTime) { + throw AccessTokenExpiredException(accessToken.expiresAt, currentTime) + } + } + private fun makeTokenValue(authMember: AuthMember, issuedAt: ZonedDateTime, expiresAt: ZonedDateTime): String { return "${authMember.memberId.value}," + // 0 "${authMember.role.name}," + // 1 diff --git a/board-system-external/external-exception-handle/src/main/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionController.kt b/board-system-external/external-exception-handle/src/main/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionController.kt index 05182bd8..180770a1 100644 --- a/board-system-external/external-exception-handle/src/main/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionController.kt +++ b/board-system-external/external-exception-handle/src/main/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionController.kt @@ -10,6 +10,7 @@ import com.ttasjwi.board.system.logging.getLogger import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.resource.NoResourceFoundException @RestControllerAdvice internal class GlobalExceptionController( @@ -62,6 +63,18 @@ internal class GlobalExceptionController( ) } + @ExceptionHandler(NoResourceFoundException::class) + fun handleNoResourceFoundException(e: NoResourceFoundException): ResponseEntity { + return makeSingleErrorResponse( + errorStatus = ErrorStatus.NOT_FOUND, + errorItem = makeErrorItem( + code = "Error.ResourceNotFound", + args = listOf(e.httpMethod.name(), "/${e.resourcePath}"), + source = "httpMethod,resourcePath", + ) + ) + } + private fun handleNotImplementedError(e: NotImplementedError): ResponseEntity { log.error(e) diff --git a/board-system-external/external-exception-handle/src/test/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionControllerTest.kt b/board-system-external/external-exception-handle/src/test/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionControllerTest.kt index a6e05d0b..4be2e990 100644 --- a/board-system-external/external-exception-handle/src/test/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionControllerTest.kt +++ b/board-system-external/external-exception-handle/src/test/kotlin/com/ttasjwi/board/system/core/exception/api/GlobalExceptionControllerTest.kt @@ -10,7 +10,9 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import org.springframework.web.servlet.resource.NoResourceFoundException import java.util.* @@ -100,6 +102,28 @@ class GlobalExceptionControllerTest { assertThat(errorItem.source).isEqualTo("server") } + @Test + @DisplayName("NoResourceFoundException 처리 테스트") + fun handleNoResourceFound() { + val e = NoResourceFoundException(HttpMethod.GET, "fire/punch") + + val responseEntity = exceptionController.handleNoResourceFoundException(e) + val response = responseEntity.body as ErrorResponse + + assertThat(responseEntity.statusCode.value()).isEqualTo(HttpStatus.NOT_FOUND.value()) + assertThat(response.isSuccess).isFalse() + assertThat(response.code).isEqualTo("Error.Occurred") + assertThat(response.message).isEqualTo("Error.Occurred.message(locale=${Locale.KOREAN},args=[])") + assertThat(response.description).isEqualTo("Error.Occurred.description(locale=${Locale.KOREAN},args=[])") + assertThat(response.errors.size).isEqualTo(1) + + val errorItem = response.errors[0] + assertThat(errorItem.code).isEqualTo("Error.ResourceNotFound") + assertThat(errorItem.message).isEqualTo("Error.ResourceNotFound.message(locale=${Locale.KOREAN},args=[])") + assertThat(errorItem.description).isEqualTo("Error.ResourceNotFound.description(locale=${Locale.KOREAN},args=[${e.httpMethod.name()}, /${e.resourcePath}])") + assertThat(errorItem.source).isEqualTo("httpMethod,resourcePath") + } + @Test @DisplayName("ValidationExceptionCollector 을 잘 handle 하는 지 테스트") fun handleValidationExceptionCollectorTest() { @@ -132,4 +156,5 @@ class GlobalExceptionControllerTest { assertThat(errorItem2.description).isEqualTo("${exception2.code}.description(locale=${Locale.KOREAN},args=${exception2.args})") assertThat(errorItem2.source).isEqualTo(exception2.source) } + } diff --git a/board-system-external/external-message/src/main/resources/message/error-message_en.yml b/board-system-external/external-message/src/main/resources/message/error-message_en.yml index f5b45786..970c6063 100644 --- a/board-system-external/external-message/src/main/resources/message/error-message_en.yml +++ b/board-system-external/external-message/src/main/resources/message/error-message_en.yml @@ -8,6 +8,9 @@ Error: NotImplemented: message: "Feature Not Implemented" description: "The requested feature is currently not implemented. It is under development and is expected to be added in the near future." + ResourceNotFound: + message: "Resource Not Found" + description: "The requested resource could not be found. (httpMethod={0}, resourcePath=''{1}'')" NullArgument: message: "Missing required value" @@ -51,9 +54,21 @@ Error: LoginFailure: message: "Login failed" description: "Login failed. The email or password is incorrect." + InvalidAuthorizationHeaderFormat: + message: "Invalid Authorization Header" + description: "The format of the Authorization header is incorrect. Please send the token in the format ''Bearer [token]'' in the Authorization header." InvalidAccessTokenFormat: message: "Invalid access token format" description: "The access token format is invalid. The token value is incorrect or it is not an access token." InvalidRefreshTokenFormat: message: "Invalid refresh token format" description: "The refresh token format is invalid. The token value is incorrect or it is not a refresh token." + AccessTokenExpired: + message: "Access Token Expired" + description: "The access token has expired and is no longer valid. (Expiration Time={0}, Current Time={1}) Please refresh using a refresh token. If the refresh token has also expired, you will need to log in again." + Unauthenticated: + message: "Unauthenticated" + description: "Access to this resource is denied. Authentication is required to access this resource." + AccessDenied: + message: "Access Denied" + description: "You do not have permission to access this resource." diff --git a/board-system-external/external-message/src/main/resources/message/error-message_ko.yml b/board-system-external/external-message/src/main/resources/message/error-message_ko.yml index 9ca13421..e237fa61 100644 --- a/board-system-external/external-message/src/main/resources/message/error-message_ko.yml +++ b/board-system-external/external-message/src/main/resources/message/error-message_ko.yml @@ -8,6 +8,9 @@ Error: NotImplemented: message: "미구현 기능" description: "요청하신 기능은 현재 미구현 상태입니다. 해당 기능은 개발 중이며, 가까운 시일 내에 추가될 예정입니다." + ResourceNotFound: + message: "리소스를 찾을 수 없음" + description: "요청한 리소스를 찾을 수 없습니다. (httpMethod={0},resourcePath=''{1}'')" NullArgument: message: "필수값 누락" @@ -51,9 +54,21 @@ Error: LoginFailure: message: "로그인 실패" description: "로그인에 실패했습니다. 이메일 또는 패스워드가 잘못됐습니다." + InvalidAuthorizationHeaderFormat: + message: "Authorization 헤더값 오류" + description: "잘못된 Authorization 헤더 형식입니다. 토큰값을 Authorization 헤더에 ''Bearer [토큰값]'' 형식으로 보내주세요." InvalidAccessTokenFormat: message: "유효하지 않은 액세스토큰 포맷" description: "액세스 토큰 포맷이 유효하지 않습니다. 토큰값이 잘못됐거나, 액세스 토큰이 아닙니다." InvalidRefreshTokenFormat: message: "유효하지 않은 리프레시토큰 포맷" description: "리프레시 토큰 포맷이 유효하지 않습니다. 토큰값이 잘못됐거나, 리프레시 토큰이 아닙니다." + AccessTokenExpired: + message: "액세스토큰 만료됨" + description: "액세스 토큰이 만료되어 더 이상 유효하지 않습니다.(만료시각={0},현재시각={1}) 리프레시 토큰을 통해 갱신해주세요. 리프레시 토큰도 만료됐다면 로그인을 다시 하셔야합니다." + Unauthenticated: + message: "인증되지 않음" + description: "리소스에 접근할 수 없습니다. 이 리소스에 접근하기 위해서는 인증이 필요합니다." + AccessDenied: + message: "리소스 접근 권한 없음" + description: "리소스에 접근할 수 없습니다. 해당 리소스에 접근할 권한이 없습니다." diff --git a/board-system-external/external-security/build.gradle.kts b/board-system-external/external-security/build.gradle.kts index 37754532..f8589562 100644 --- a/board-system-external/external-security/build.gradle.kts +++ b/board-system-external/external-security/build.gradle.kts @@ -1,6 +1,6 @@ dependencies { - implementation(Dependencies.SPRING_BOOT_STARTER.fullName) - implementation(Dependencies.SPRING_SECURITY_CRYPTO.fullName) + implementation(Dependencies.SPRING_BOOT_SECURITY.fullName) + implementation(Dependencies.SPRING_BOOT_WEB.fullName) implementation(Dependencies.SPRING_SECURITY_JOSE.fullName) implementation(project(":board-system-domain:domain-core")) implementation(project(":board-system-domain:domain-member")) diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/FilterChainConfig.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/FilterChainConfig.kt new file mode 100644 index 00000000..e60580fd --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/FilterChainConfig.kt @@ -0,0 +1,90 @@ +package com.ttasjwi.board.system.core.config + +import com.ttasjwi.board.system.external.spring.security.authentication.AccessTokenAuthenticationFilter +import com.ttasjwi.board.system.external.spring.security.support.BearerTokenResolver +import com.ttasjwi.board.system.auth.domain.service.AccessTokenManager +import com.ttasjwi.board.system.core.time.TimeManager +import com.ttasjwi.board.system.external.spring.security.exception.CustomAccessDeniedHandler +import com.ttasjwi.board.system.external.spring.security.exception.CustomAuthenticationEntryPoint +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.savedrequest.NullRequestCache +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.servlet.HandlerExceptionResolver + +@Configuration +class FilterChainConfig( + private val accessTokenManager: AccessTokenManager, + private val timeManager: TimeManager, + @Qualifier(value = "handlerExceptionResolver") + private val handlerExceptionResolver: HandlerExceptionResolver, +) { + + @Bean + @Order(0) + fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + securityMatcher("/api/**") + authorizeHttpRequests { + authorize(HttpMethod.GET, "/api/v1/deploy/health-check", permitAll) + + authorize(HttpMethod.GET, "/api/v1/members/email-available", permitAll) + authorize(HttpMethod.GET, "/api/v1/members/username-available", permitAll) + authorize(HttpMethod.GET, "/api/v1/members/nickname-available", permitAll) + + authorize(HttpMethod.POST, "/api/v1/members/email-verification/start", permitAll) + authorize(HttpMethod.POST, "/api/v1/members/email-verification", permitAll) + authorize(HttpMethod.POST, "/api/v1/members", permitAll) + + authorize(HttpMethod.POST, "/api/v1/auth/login", permitAll) + + authorize(anyRequest, authenticated) + } + + csrf { disable() } + + sessionManagement { + sessionCreationPolicy = SessionCreationPolicy.STATELESS + } + + requestCache { + requestCache = NullRequestCache() + } + + exceptionHandling { + authenticationEntryPoint = CustomAuthenticationEntryPoint(handlerExceptionResolver) + accessDeniedHandler = CustomAccessDeniedHandler(handlerExceptionResolver) + } + + addFilterBefore(accessTokenAuthenticationFilter()) + } + return http.build() + } + + private fun accessTokenAuthenticationFilter(): OncePerRequestFilter { + return AccessTokenAuthenticationFilter( + bearerTokenResolver = BearerTokenResolver(), + accessTokenManager = accessTokenManager, + timeManager = timeManager, + ) + } + + @Bean + @Order(1) + fun staticResourceSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, permitAll) + } + } + return http.build() + } +} diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/NimbusJwtConfig.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/NimbusJwtConfig.kt index 008cabad..44213bd2 100644 --- a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/NimbusJwtConfig.kt +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/core/config/NimbusJwtConfig.kt @@ -12,7 +12,7 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import com.nimbusds.jwt.proc.DefaultJWTProcessor import com.nimbusds.jwt.proc.JWTClaimsSetVerifier -import com.ttasjwi.board.system.auth.domain.external.spring.security.X509CertificateThumbprintValidator +import com.ttasjwi.board.system.external.spring.security.support.X509CertificateThumbprintValidator import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.io.ResourceLoader diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AccessTokenAuthenticationFilter.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AccessTokenAuthenticationFilter.kt new file mode 100644 index 00000000..ed7a078f --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AccessTokenAuthenticationFilter.kt @@ -0,0 +1,67 @@ +package com.ttasjwi.board.system.external.spring.security.authentication + +import com.ttasjwi.board.system.auth.domain.service.AccessTokenManager +import com.ttasjwi.board.system.core.time.TimeManager +import com.ttasjwi.board.system.external.spring.security.support.BearerTokenResolver +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class AccessTokenAuthenticationFilter( + private val bearerTokenResolver: BearerTokenResolver, + private val accessTokenManager: AccessTokenManager, + private val timeManager: TimeManager, +) : OncePerRequestFilter() { + + public override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + + // 이미 인증됐다면 통과 + if (isAuthenticated()) { + filterChain.doFilter(request, response) + return + } + + // 헤더를 통해 토큰을 가져옴. 없다면 통과 + val tokenValue = bearerTokenResolver.resolve(request) + if (tokenValue == null) { + filterChain.doFilter(request, response) + return + } + + // 토큰값을 통해 인증 + val authentication = attemptAuthenticate(tokenValue) + + // 인증 결과를 SecurityContextHolder 에 저장 + saveAuthenticationToSecurityContextHolder(authentication) + + // 통과 + try { + filterChain.doFilter(request, response) + } finally { + SecurityContextHolder.getContextHolderStrategy().clearContext() + } + } + + private fun isAuthenticated() = SecurityContextHolder.getContextHolderStrategy().context.authentication != null + + private fun attemptAuthenticate(tokenValue: String): AuthMemberAuthentication { + val accessToken = accessTokenManager.parse(tokenValue) + val currentTime = timeManager.now() + + accessTokenManager.checkCurrentlyValid(accessToken, currentTime) + + return AuthMemberAuthentication.from(accessToken.authMember) + } + + private fun saveAuthenticationToSecurityContextHolder(authentication: AuthMemberAuthentication) { + val securityContext = SecurityContextHolder.getContextHolderStrategy().createEmptyContext() + securityContext.authentication = authentication + SecurityContextHolder.getContextHolderStrategy().context = securityContext + } +} diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AuthMemberAuthentication.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AuthMemberAuthentication.kt new file mode 100644 index 00000000..4f11812f --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AuthMemberAuthentication.kt @@ -0,0 +1,46 @@ +package com.ttasjwi.board.system.external.spring.security.authentication + +import com.ttasjwi.board.system.auth.domain.model.AuthMember +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority + +class AuthMemberAuthentication +private constructor( + private val authMember: AuthMember +) : Authentication { + + companion object { + fun from(authMember: AuthMember): AuthMemberAuthentication { + return AuthMemberAuthentication(authMember) + } + } + + override fun getName(): String? { + return null + } + + override fun getAuthorities(): MutableCollection { + return mutableListOf(SimpleGrantedAuthority("ROLE_${authMember.role.name}")) + } + + override fun getCredentials(): Any? { + return null + } + + override fun getDetails(): Any? { + return null + } + + override fun getPrincipal(): Any { + return authMember + } + + override fun isAuthenticated(): Boolean { + return true + } + + override fun setAuthenticated(isAuthenticated: Boolean) { + throw IllegalStateException("cannot set this token to trusted") + } +} diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAccessDeniedHandler.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAccessDeniedHandler.kt new file mode 100644 index 00000000..e7bf7fdd --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAccessDeniedHandler.kt @@ -0,0 +1,22 @@ +package com.ttasjwi.board.system.external.spring.security.exception + +import com.ttasjwi.board.system.auth.domain.exception.AccessDeniedException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.web.servlet.HandlerExceptionResolver + +class CustomAccessDeniedHandler( + private val handlerExceptionResolver: HandlerExceptionResolver +) : AccessDeniedHandler { + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: org.springframework.security.access.AccessDeniedException + ) { + val customAccessDeniedException = AccessDeniedException(accessDeniedException) + + handlerExceptionResolver.resolveException(request, response, null, customAccessDeniedException) + } +} diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAuthenticationEntryPoint.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAuthenticationEntryPoint.kt new file mode 100644 index 00000000..06e50f6b --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAuthenticationEntryPoint.kt @@ -0,0 +1,28 @@ +package com.ttasjwi.board.system.external.spring.security.exception + +import com.ttasjwi.board.system.auth.domain.exception.UnauthenticatedException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.InsufficientAuthenticationException +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.web.servlet.HandlerExceptionResolver + +class CustomAuthenticationEntryPoint( + private val handlerExceptionResolver: HandlerExceptionResolver +) : AuthenticationEntryPoint { + + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + var ex: Exception = authException + + if (authException is InsufficientAuthenticationException) { + ex = UnauthenticatedException(authException) + } + + handlerExceptionResolver.resolveException(request, response, null, ex) + } +} diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/InvalidAuthorizationHeaderFormatException.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/InvalidAuthorizationHeaderFormatException.kt new file mode 100644 index 00000000..aa8f1a79 --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/exception/InvalidAuthorizationHeaderFormatException.kt @@ -0,0 +1,12 @@ +package com.ttasjwi.board.system.external.spring.security.exception + +import com.ttasjwi.board.system.core.exception.CustomException +import com.ttasjwi.board.system.core.exception.ErrorStatus + +class InvalidAuthorizationHeaderFormatException : CustomException( + status = ErrorStatus.UNAUTHENTICATED, + code = "Error.InvalidAuthorizationHeaderFormat", + args = emptyList(), + source = "authorizationHeader", + debugMessage = "잘못된 Authorization 헤더 형식입니다. 토큰값을 Authorization 헤더에 'Bearer [토큰값]' 형식으로 보내주세요." +) diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/support/BearerTokenResolver.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/support/BearerTokenResolver.kt new file mode 100644 index 00000000..b9f7f120 --- /dev/null +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/support/BearerTokenResolver.kt @@ -0,0 +1,20 @@ +package com.ttasjwi.board.system.external.spring.security.support + +import com.ttasjwi.board.system.external.spring.security.exception.InvalidAuthorizationHeaderFormatException +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpHeaders + +class BearerTokenResolver { + + private val bearerTokenHeaderName = HttpHeaders.AUTHORIZATION + + fun resolve(request: HttpServletRequest): String? { + val authorizationHeader = request.getHeader(this.bearerTokenHeaderName) ?: return null + + if (!authorizationHeader.startsWith("Bearer ", ignoreCase = true)) { + throw InvalidAuthorizationHeaderFormatException() + } + return authorizationHeader.substring(7) + } + +} diff --git a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/auth/domain/external/spring/security/X509CertificateThumbprintValidator.kt b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/support/X509CertificateThumbprintValidator.kt similarity index 98% rename from board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/auth/domain/external/spring/security/X509CertificateThumbprintValidator.kt rename to board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/support/X509CertificateThumbprintValidator.kt index cb19ea14..21afb0e2 100644 --- a/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/auth/domain/external/spring/security/X509CertificateThumbprintValidator.kt +++ b/board-system-external/external-security/src/main/kotlin/com/ttasjwi/board/system/external/spring/security/support/X509CertificateThumbprintValidator.kt @@ -1,4 +1,4 @@ -package com.ttasjwi.board.system.auth.domain.external.spring.security +package com.ttasjwi.board.system.external.spring.security.support import org.apache.commons.logging.Log import org.apache.commons.logging.LogFactory diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/MvcTestController.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/MvcTestController.kt new file mode 100644 index 00000000..6ae24888 --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/MvcTestController.kt @@ -0,0 +1,17 @@ +package com.ttasjwi.board.system + +import com.ttasjwi.board.system.auth.domain.model.AuthMember +import org.springframework.http.ResponseEntity +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class MvcTestController { + + @GetMapping("/api/v1/test/authenticated") + fun testAuthenticated(): ResponseEntity { + val authMember = SecurityContextHolder.getContext().authentication.principal as AuthMember + return ResponseEntity.ok(authMember) + } +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/MvcTestControllerTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/MvcTestControllerTest.kt new file mode 100644 index 00000000..176178ab --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/MvcTestControllerTest.kt @@ -0,0 +1,79 @@ +package com.ttasjwi.board.system + +import com.nimbusds.jose.util.StandardCharset +import com.ttasjwi.board.system.auth.domain.exception.AccessTokenExpiredException +import com.ttasjwi.board.system.auth.domain.model.fixture.authMemberFixture +import com.ttasjwi.board.system.auth.domain.service.fixture.AccessTokenManagerFixture +import com.ttasjwi.board.system.core.time.fixture.TimeManagerFixture +import com.ttasjwi.board.system.core.time.fixture.timeFixture +import com.ttasjwi.board.system.member.domain.model.Role +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* + +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("MVC 통합 인증/인가 테스트") +class MvcTestControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var accessTokenManagerFixture: AccessTokenManagerFixture + + @Autowired + private lateinit var timeManagerFixture: TimeManagerFixture + + @Test + @DisplayName("유효한 시간 내에 액세스토큰을 전달하면 필터를 통과한다.") + fun testAuthenticated() { + // given + val authMember = authMemberFixture(memberId = 5544, role = Role.USER) + val accessToken = accessTokenManagerFixture.generate(authMember, timeFixture(minute = 5)) + timeManagerFixture.changeCurrentTime(timeFixture(minute = 18)) + + mockMvc + .perform( + get("/api/v1/test/authenticated") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken.tokenValue}") + .characterEncoding(StandardCharset.UTF_8) + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.memberId.value").value(authMember.memberId.value), + jsonPath("$.role").value(authMember.role.name) + ) + } + + @Test + @DisplayName("유효시간을 경과하면 예외가 발생하고, 인증에 실패한다.") + fun testFailed() { + // given + val authMember = authMemberFixture(memberId = 5544, role = Role.USER) + val accessToken = accessTokenManagerFixture.generate(authMember, timeFixture(minute = 5)) + timeManagerFixture.changeCurrentTime(timeFixture(minute = 45)) + + assertThrows { + mockMvc + .perform( + get("/api/v1/test/authenticated") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken.tokenValue}") + .characterEncoding(StandardCharset.UTF_8) + .contentType(MediaType.APPLICATION_JSON) + ) + } + } +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/SecurityTestApplication.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/SecurityTestApplication.kt index 991c374d..710f6c1b 100644 --- a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/SecurityTestApplication.kt +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/SecurityTestApplication.kt @@ -1,12 +1,26 @@ package com.ttasjwi.board.system +import com.ttasjwi.board.system.auth.domain.service.fixture.AccessTokenManagerFixture +import com.ttasjwi.board.system.core.time.fixture.TimeManagerFixture import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.context.annotation.Bean @ConfigurationPropertiesScan @SpringBootApplication -class SecurityTestApplication +class SecurityTestApplication { + + @Bean + fun accessTokenManagerFixture(): AccessTokenManagerFixture { + return AccessTokenManagerFixture() + } + + @Bean + fun timeManagerFixture(): TimeManagerFixture { + return TimeManagerFixture() + } +} fun main(args: Array) { runApplication(*args) diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AccessTokenAuthenticationFilterTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AccessTokenAuthenticationFilterTest.kt new file mode 100644 index 00000000..885d480f --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AccessTokenAuthenticationFilterTest.kt @@ -0,0 +1,118 @@ +package com.ttasjwi.board.system.external.spring.security.authentication + +import com.ttasjwi.board.system.auth.domain.model.fixture.accessTokenFixture +import com.ttasjwi.board.system.auth.domain.model.fixture.authMemberFixture +import com.ttasjwi.board.system.auth.domain.service.AccessTokenManager +import com.ttasjwi.board.system.core.time.TimeManager +import com.ttasjwi.board.system.core.time.fixture.timeFixture +import com.ttasjwi.board.system.external.spring.security.support.BearerTokenResolver +import com.ttasjwi.board.system.member.domain.model.Role +import io.mockk.* +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder + +@DisplayName("AccessTokenAuthenticationFilter 테스트") +class AccessTokenAuthenticationFilterTest { + + private lateinit var accessTokenAuthenticationFilter: AccessTokenAuthenticationFilter + private lateinit var bearerTokenResolver: BearerTokenResolver + private lateinit var accessTokenManager: AccessTokenManager + private lateinit var timeManager: TimeManager + private lateinit var request: HttpServletRequest + private lateinit var response: HttpServletResponse + private lateinit var filterChain: FilterChain + + @BeforeEach + fun setup() { + bearerTokenResolver = mockk() + accessTokenManager = mockk() + timeManager = mockk() + accessTokenAuthenticationFilter = AccessTokenAuthenticationFilter( + bearerTokenResolver = bearerTokenResolver, + accessTokenManager = accessTokenManager, + timeManager = timeManager + ) + SecurityContextHolder.clearContext() + request = mockk() + response = mockk() + filterChain = mockk() + } + + @AfterEach + fun teardown() { + SecurityContextHolder.clearContext() + } + + @Test + @DisplayName("이미 인증된 회원은 필터를 통과시킨다.") + fun testAuthenticated() { + // given + SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken.authenticated( + authMemberFixture(1L, Role.USER), + "", + mutableListOf(SimpleGrantedAuthority("ROLE_USER")) + ) + + every { filterChain.doFilter(request, response) } just Runs + + // when + accessTokenAuthenticationFilter.doFilterInternal(request, response, filterChain) + + // then + verify(exactly = 1) { filterChain.doFilter(request, response) } + verify(exactly = 0) { bearerTokenResolver.resolve(request) } + } + + @Test + @DisplayName("토큰값이 없다면 다음 필터로 통과시킨다") + fun testNullTokenValue() { + // given + every { bearerTokenResolver.resolve(request) } returns null + every { filterChain.doFilter(request, response) } just Runs + + // when + accessTokenAuthenticationFilter.doFilterInternal(request, response, filterChain) + + // then + verify(exactly = 1) { bearerTokenResolver.resolve(request) } + verify(exactly = 1) { filterChain.doFilter(request, response) } + verify(exactly = 0) { accessTokenManager.parse(anyNullable()) } + } + + @Test + @DisplayName("인증 토큰을 지참했다면 인증을 해야 통과된다.") + fun testWithValidAccessToken() { + // given + val tokenValue = "validToken" + val accessToken = accessTokenFixture( + memberId = 1557L, role = Role.ADMIN, tokenValue = tokenValue, + issuedAt = timeFixture(minute = 5), expiresAt = timeFixture(minute = 35) + ) + val currentTime = timeFixture(minute = 13) + + every { bearerTokenResolver.resolve(request) } returns tokenValue + every { accessTokenManager.parse(tokenValue) } returns accessToken + every { timeManager.now() } returns currentTime + every { accessTokenManager.checkCurrentlyValid(accessToken, currentTime) } just Runs + every { filterChain.doFilter(request, response) } just Runs + + // when + accessTokenAuthenticationFilter.doFilterInternal(request, response, filterChain) + + // then + verify(exactly = 1) { accessTokenManager.parse(tokenValue) } + verify(exactly = 1) { bearerTokenResolver.resolve(request) } + verify(exactly = 1) { timeManager.now() } + verify(exactly = 1) { accessTokenManager.checkCurrentlyValid(accessToken, currentTime) } + verify(exactly = 1) { filterChain.doFilter(request, response) } + } + +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AuthMemberAuthenticationTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AuthMemberAuthenticationTest.kt new file mode 100644 index 00000000..2013002a --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/authentication/AuthMemberAuthenticationTest.kt @@ -0,0 +1,105 @@ +package com.ttasjwi.board.system.external.spring.security.authentication + +import com.ttasjwi.board.system.auth.domain.model.AuthMember +import com.ttasjwi.board.system.auth.domain.model.fixture.authMemberFixture +import com.ttasjwi.board.system.member.domain.model.Role +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority + +@DisplayName("AuthMemberAuthentication: AuthMember를 통해 얻어낸 Spring Security Authentication") +class AuthMemberAuthenticationTest { + + private lateinit var authMember: AuthMember + private lateinit var authentication: Authentication + + @BeforeEach + fun setup() { + authMember = authMemberFixture(memberId = 1357L, role = Role.ADMIN) + authentication = AuthMemberAuthentication.from(authMember) + } + + @Test + @DisplayName("getName: Null 반환") + fun testGetName() { + // given + // when + val name = authentication.name + + // then + assertThat(name).isNull() + } + + @Test + @DisplayName("getAuthorities: 권한목록 반환") + fun testGetAuthorities() { + // given + // when + val authorities = authentication.authorities + + // then + assertThat(authorities).containsExactly(SimpleGrantedAuthority("ROLE_ADMIN")) + } + + @Test + @DisplayName("getCredentials: Null 반환") + fun testGetCredentials() { + // given + // when + val credentials = authentication.credentials + + // then + assertThat(credentials).isNull() + } + + @Test + @DisplayName("getDetails: Null 반환") + fun testGetDetails() { + // given + // when + val details = authentication.details + + // then + assertThat(details).isNull() + } + + @Test + @DisplayName("getPrincipal: authMember 반환") + fun testGetPrincipal() { + // given + // when + val innerPrincipal = authentication.principal as AuthMember + + // then + assertThat(innerPrincipal).isEqualTo(authMember) + } + + + @Test + @DisplayName("isAuthenticated: true 반환") + fun testIsAuthenticated() { + // given + // when + val authenticated = authentication.isAuthenticated + + // then + assertThat(authenticated).isTrue() + } + + + @Test + @DisplayName("setAuthenticated: 예외 발생") + fun testSetAuthenticated() { + // given + // when + // then + assertThrows { authentication.isAuthenticated = true } + .let { + assertThat(it.message).isEqualTo("cannot set this token to trusted") + } + } +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAccessDeniedHandlerTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAccessDeniedHandlerTest.kt new file mode 100644 index 00000000..1677ef16 --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAccessDeniedHandlerTest.kt @@ -0,0 +1,49 @@ +package com.ttasjwi.board.system.external.spring.security.exception + +import com.ttasjwi.board.system.auth.domain.exception.AccessDeniedException +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.web.servlet.HandlerExceptionResolver +import org.springframework.web.servlet.ModelAndView + +@DisplayName("CustomAccessDeniedHandler 테스트") +class CustomAccessDeniedHandlerTest { + + private lateinit var accessDeniedHandler: AccessDeniedHandler + private lateinit var handlerExceptionResolver: HandlerExceptionResolver + + @BeforeEach + fun setup() { + handlerExceptionResolver = mockk() + accessDeniedHandler = CustomAccessDeniedHandler(handlerExceptionResolver) + } + + @Test + @DisplayName("내부적으로 HandlerExceptionResolver 를 호출한다.") + fun handleAccessDeniedException() { + // given + val request = mockk() + val response = mockk() + val exception = org.springframework.security.access.AccessDeniedException("권한 부족") + + every { handlerExceptionResolver.resolveException( + request, + response, + null, + any(AccessDeniedException::class) + ) } returns ModelAndView() + + // when + accessDeniedHandler.handle(request, response, exception) + + // then + verify(exactly = 1) { handlerExceptionResolver.resolveException(request, response, null, any(AccessDeniedException::class)) } + } +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAuthenticationEntryPointTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAuthenticationEntryPointTest.kt new file mode 100644 index 00000000..c2d49f47 --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/CustomAuthenticationEntryPointTest.kt @@ -0,0 +1,78 @@ +package com.ttasjwi.board.system.external.spring.security.exception + +import com.ttasjwi.board.system.auth.domain.exception.UnauthenticatedException +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.InsufficientAuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.web.servlet.HandlerExceptionResolver +import org.springframework.web.servlet.ModelAndView + +@DisplayName("CustomAuthenticationEntryPoint 테스트") +class CustomAuthenticationEntryPointTest { + + private lateinit var authenticationEntryPoint: AuthenticationEntryPoint + private lateinit var handlerExceptionResolver: HandlerExceptionResolver + + @BeforeEach + fun setup() { + handlerExceptionResolver = mockk() + authenticationEntryPoint = CustomAuthenticationEntryPoint(handlerExceptionResolver) + } + + @Test + @DisplayName("InsufficientAuthenticationException 은 인증이 필요하다는 예외이고, UnauthenticatedException 으로 변환해서 처리한다.") + fun handleInsufficientAuthenticationException() { + // given + val request = mockk() + val response = mockk() + val exception = InsufficientAuthenticationException("Full authentication is required to access this resource") + + every { + handlerExceptionResolver.resolveException( + request, + response, + null, + any(UnauthenticatedException::class) + ) + } returns ModelAndView() + + // when + authenticationEntryPoint.commence(request, response, exception) + + // then + verify(exactly = 1) { handlerExceptionResolver.resolveException(request, response, null, any(UnauthenticatedException::class)) } + } + + + @Test + @DisplayName("내부적으로 HandlerExceptionResolver 를 호출한다.") + fun handleAuthenticationException() { + // given + val request = mockk() + val response = mockk() + val exception = BadCredentialsException("password") + + every { + handlerExceptionResolver.resolveException( + request, + response, + null, + exception + ) + } returns ModelAndView() + + // when + authenticationEntryPoint.commence(request, response, exception) + + // then + verify(exactly = 1) { handlerExceptionResolver.resolveException(request, response, null, exception) } + } +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/InvalidAuthorizationHeaderFormatExceptionTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/InvalidAuthorizationHeaderFormatExceptionTest.kt new file mode 100644 index 00000000..2363fdbe --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/exception/InvalidAuthorizationHeaderFormatExceptionTest.kt @@ -0,0 +1,25 @@ +package com.ttasjwi.board.system.external.spring.security.exception + +import com.ttasjwi.board.system.external.spring.security.exception.InvalidAuthorizationHeaderFormatException +import com.ttasjwi.board.system.core.exception.ErrorStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("InvalidAuthorizationHeaderFormatException: Authorization 헤더값 포맷이 잘못된 형식일 때 발생하는 예외") +class InvalidAuthorizationHeaderFormatExceptionTest { + + @Test + @DisplayName("예외 기본값 테스트") + fun test() { + val exception = InvalidAuthorizationHeaderFormatException() + + assertThat(exception.status).isEqualTo(ErrorStatus.UNAUTHENTICATED) + assertThat(exception.code).isEqualTo("Error.InvalidAuthorizationHeaderFormat") + assertThat(exception.source).isEqualTo("authorizationHeader") + assertThat(exception.args).isEmpty() + assertThat(exception.message).isEqualTo(exception.debugMessage) + assertThat(exception.cause).isNull() + assertThat(exception.debugMessage).isEqualTo("잘못된 Authorization 헤더 형식입니다. 토큰값을 Authorization 헤더에 'Bearer [토큰값]' 형식으로 보내주세요.") + } +} diff --git a/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/support/BearerTokenResolverTest.kt b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/support/BearerTokenResolverTest.kt new file mode 100644 index 00000000..1d72c9d0 --- /dev/null +++ b/board-system-external/external-security/src/test/kotlin/com/ttasjwi/board/system/external/spring/security/support/BearerTokenResolverTest.kt @@ -0,0 +1,66 @@ +package com.ttasjwi.board.system.external.spring.security.support + +import com.ttasjwi.board.system.external.spring.security.exception.InvalidAuthorizationHeaderFormatException +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.HttpServletRequest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpHeaders + +@DisplayName("BearerTokenResolver: Authorization 헤더의 Bearer 뒤에 위치한 토큰값을 분리한다.") +class BearerTokenResolverTest { + private lateinit var bearerTokenResolver: BearerTokenResolver + + @BeforeEach + fun setup() { + bearerTokenResolver = BearerTokenResolver() + } + + @Test + @DisplayName("헤더값이 유효하다면, 토큰값이 성공적으로 분리된다.") + fun testSuccess() { + // given + val request = mockk() + every { request.getHeader(HttpHeaders.AUTHORIZATION) } returns "Bearer validToken123" + + + // when + val token = bearerTokenResolver.resolve(request) + + // then + assertThat(token).isEqualTo("validToken123") + verify { request.getHeader(HttpHeaders.AUTHORIZATION) } + } + + @Test + @DisplayName("Authorization 헤더값이 없을 경우 null 이 반환된다.") + fun authorizationHeaderNull() { + // given + val request = mockk() + every { request.getHeader(HttpHeaders.AUTHORIZATION) } returns null + + // when + val token = bearerTokenResolver.resolve(request) + + // then + assertThat(token).isNull() + } + + @Test + @DisplayName("Authorization 헤더값이 Bearer 로 시작하지 않으면 예외가 발생한다.") + fun testBadAuthorizationHeader() { + // given + val request = mockk() + every { request.getHeader(HttpHeaders.AUTHORIZATION) } returns "Basic abc123" + + // when + // then + assertThrows { bearerTokenResolver.resolve(request) } + verify { request.getHeader(HttpHeaders.AUTHORIZATION) } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 2bf7848e..84e1afae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { implementation(Dependencies.KOTLIN_REFLECT.fullName) testImplementation(Dependencies.SPRING_BOOT_TEST.fullName) + testImplementation(Dependencies.MOCKK.fullName) } tasks.getByName("bootJar") { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 839a1ffd..c69c0765 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -15,9 +15,9 @@ enum class Dependencies( SPRING_BOOT_WEB(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-web"), SPRING_BOOT_DATA_JPA(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-data-jpa"), SPRING_BOOT_DATA_REDIS(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-data-redis"), + SPRING_BOOT_SECURITY(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-security"), SPRING_BOOT_MAIL(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-mail"), SPRING_BOOT_TEST(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-test"), - SPRING_SECURITY_CRYPTO(groupId = "org.springframework.security", artifactId = "spring-security-crypto"), SPRING_SECURITY_JOSE(groupId = "org.springframework.security", artifactId = "spring-security-oauth2-jose"), // jackson date time @@ -33,8 +33,10 @@ enum class Dependencies( YAML_RESOURCE_BUNDLE(groupId = "dev.akkinoc.util", artifactId = "yaml-resource-bundle", version = "2.13.0"), // email-format-check - COMMONS_VALIDATOR(groupId="commons-validator", artifactId ="commons-validator" , version="1.9.0"); + COMMONS_VALIDATOR(groupId="commons-validator", artifactId ="commons-validator" , version="1.9.0"), + // mockk + MOCKK(groupId="io.mockk", artifactId="mockk" , version="1.13.13"); val fullName: String get() {