From 2327b416e27ab3e59d7ce62367d908dc38b0a426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Mon, 20 Jul 2026 09:47:23 +0900 Subject: [PATCH 1/2] feat :: add global common configuration --- .../cklob/mudda/global/config/AsyncConfig.kt | 8 +++++ .../mudda/global/config/JacksonConfig.kt | 11 ++++++ .../cklob/mudda/global/config/RedisConfig.kt | 25 +++++++++++++ .../mudda/global/config/SecurityConfig.kt | 24 +++++++++++++ .../mudda/global/config/SwaggerConfig.kt | 12 +++++++ .../cklob/mudda/global/config/WebConfig.kt | 11 ++++++ .../global/exception/BusinessException.kt | 5 +++ .../cklob/mudda/global/exception/ErrorCode.kt | 11 ++++++ .../exception/GlobalExceptionHandler.kt | 23 ++++++++++++ .../mudda/global/response/ApiResponse.kt | 12 +++++++ .../mudda/global/response/ErrorResponse.kt | 7 ++++ .../security/JwtAuthenticationFilter.kt | 20 +++++++++++ .../mudda/global/security/JwtProperties.kt | 10 ++++++ .../mudda/global/security/JwtTokenProvider.kt | 24 +++++++++++++ .../cklob/mudda/global/security/LoginUser.kt | 5 +++ .../security/LoginUserArgumentResolver.kt | 16 +++++++++ src/main/resources/application.yaml | 5 +++ .../team/cklob/mudda/MuddaApplicationTests.kt | 1 + .../mudda/global/config/RedisConfigTest.kt | 18 ++++++++++ .../mudda/global/config/SecurityConfigTest.kt | 34 ++++++++++++++++++ .../exception/GlobalExceptionHandlerTest.kt | 35 +++++++++++++++++++ .../global/security/JwtTokenProviderTest.kt | 22 ++++++++++++ 22 files changed, 339 insertions(+) create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/AsyncConfig.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/WebConfig.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/exception/BusinessException.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/response/ApiResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/response/ErrorResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/security/JwtProperties.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/security/JwtTokenProvider.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/security/LoginUser.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt create mode 100644 src/test/kotlin/team/cklob/mudda/global/config/RedisConfigTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/global/security/JwtTokenProviderTest.kt diff --git a/src/main/kotlin/team/cklob/mudda/global/config/AsyncConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/AsyncConfig.kt new file mode 100644 index 0000000..1a711ee --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/AsyncConfig.kt @@ -0,0 +1,8 @@ +package team.cklob.mudda.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync + +@Configuration +@EnableAsync +class AsyncConfig diff --git a/src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt new file mode 100644 index 0000000..b919f67 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt @@ -0,0 +1,11 @@ +package team.cklob.mudda.global.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JacksonConfig { + @Bean fun objectMapper(): ObjectMapper = ObjectMapper().registerKotlinModule() +} diff --git a/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt new file mode 100644 index 0000000..0f36a70 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt @@ -0,0 +1,25 @@ +package team.cklob.mudda.global.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import com.fasterxml.jackson.annotation.JsonTypeInfo +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig { + @Bean + fun redisTemplate(factory: RedisConnectionFactory, objectMapper: ObjectMapper) = RedisTemplate().apply { + connectionFactory = factory + keySerializer = StringRedisSerializer() + valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper.copy().activateDefaultTyping( + BasicPolymorphicTypeValidator.builder().allowIfSubType("team.cklob.mudda").build(), + ObjectMapper.DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY, + )) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt new file mode 100644 index 0000000..895aac0 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt @@ -0,0 +1,24 @@ +package team.cklob.mudda.global.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +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.http.HttpStatus +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import team.cklob.mudda.global.security.JwtAuthenticationFilter +import team.cklob.mudda.global.security.JwtProperties +import team.cklob.mudda.global.security.JwtTokenProvider + +@Configuration +@EnableConfigurationProperties(JwtProperties::class) +class SecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity, jwtTokenProvider: JwtTokenProvider): SecurityFilterChain = http + .csrf { it.disable() }.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { it.requestMatchers("/api/v1/auth/**", "/api/v1/maps/**", "/actuator/health", "/swagger-ui/**", "/v3/api-docs/**").permitAll().anyRequest().authenticated() } + .exceptionHandling { it.authenticationEntryPoint { _, response, _ -> response.sendError(HttpStatus.UNAUTHORIZED.value()) } } + .addFilterBefore(JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java).build() +} diff --git a/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..52f2efc --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt @@ -0,0 +1,12 @@ +package team.cklob.mudda.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig { + @Bean fun openAPI(): OpenAPI = OpenAPI().components(Components().addSecuritySchemes("bearerAuth", SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"))) +} diff --git a/src/main/kotlin/team/cklob/mudda/global/config/WebConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/WebConfig.kt new file mode 100644 index 0000000..4e2df9d --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/WebConfig.kt @@ -0,0 +1,11 @@ +package team.cklob.mudda.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import team.cklob.mudda.global.security.LoginUserArgumentResolver + +@Configuration +class WebConfig(private val loginUserArgumentResolver: LoginUserArgumentResolver) : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { resolvers += loginUserArgumentResolver } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/BusinessException.kt b/src/main/kotlin/team/cklob/mudda/global/exception/BusinessException.kt new file mode 100644 index 0000000..c45a331 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/exception/BusinessException.kt @@ -0,0 +1,5 @@ +package team.cklob.mudda.global.exception + +open class BusinessException(val errorCode: ErrorCode) : RuntimeException(errorCode.message) +class AuthException(errorCode: ErrorCode = ErrorCode.UNAUTHORIZED) : BusinessException(errorCode) +class CapsuleException(errorCode: ErrorCode = ErrorCode.CAPSULE_NOT_FOUND) : BusinessException(errorCode) diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt new file mode 100644 index 0000000..cc72771 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.mudda.global.exception + +import org.springframework.http.HttpStatus + +enum class ErrorCode(val status: HttpStatus, val code: String, val message: String) { + INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "Invalid input."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "Internal server error."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "Authentication is required."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "Invalid token."), + CAPSULE_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "Time capsule not found."), +} diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..53ed5ae --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt @@ -0,0 +1,23 @@ +package team.cklob.mudda.global.exception + +import org.springframework.http.ResponseEntity +import org.springframework.http.MediaType +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import team.cklob.mudda.global.response.ApiResponse + +@RestControllerAdvice +class GlobalExceptionHandler { + @ExceptionHandler(BusinessException::class) + fun handleBusiness(e: BusinessException) = response(e.errorCode) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation(e: MethodArgumentNotValidException) = response(ErrorCode.INVALID_INPUT) + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception) = response(ErrorCode.INTERNAL_SERVER_ERROR) + + private fun response(errorCode: ErrorCode): ResponseEntity> = + ResponseEntity.status(errorCode.status).contentType(MediaType.APPLICATION_JSON).body(ApiResponse.failure(errorCode)) +} diff --git a/src/main/kotlin/team/cklob/mudda/global/response/ApiResponse.kt b/src/main/kotlin/team/cklob/mudda/global/response/ApiResponse.kt new file mode 100644 index 0000000..aaea70d --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/response/ApiResponse.kt @@ -0,0 +1,12 @@ +package team.cklob.mudda.global.response + +import com.fasterxml.jackson.annotation.JsonInclude +import team.cklob.mudda.global.exception.ErrorCode + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ApiResponse(val success: Boolean, val data: T? = null, val error: ErrorResponse? = null) { + companion object { + fun success(data: T? = null) = ApiResponse(success = true, data = data) + fun failure(errorCode: ErrorCode) = ApiResponse(success = false, error = ErrorResponse(errorCode)) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/response/ErrorResponse.kt b/src/main/kotlin/team/cklob/mudda/global/response/ErrorResponse.kt new file mode 100644 index 0000000..643cc67 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/response/ErrorResponse.kt @@ -0,0 +1,7 @@ +package team.cklob.mudda.global.response + +import team.cklob.mudda.global.exception.ErrorCode + +data class ErrorResponse(val code: String, val message: String) { + constructor(errorCode: ErrorCode) : this(errorCode.code, errorCode.message) +} diff --git a/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..4887be0 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,20 @@ +package team.cklob.mudda.global.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.web.filter.OncePerRequestFilter + +class JwtAuthenticationFilter(private val jwtTokenProvider: JwtTokenProvider) : OncePerRequestFilter() { + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + request.getHeader("Authorization")?.removePrefix("Bearer ")?.takeIf(jwtTokenProvider::validate)?.let { token -> + val authentication = UsernamePasswordAuthenticationToken(jwtTokenProvider.getMemberId(token), null, emptyList()) + authentication.details = WebAuthenticationDetailsSource().buildDetails(request) + SecurityContextHolder.getContext().authentication = authentication + } + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/security/JwtProperties.kt b/src/main/kotlin/team/cklob/mudda/global/security/JwtProperties.kt new file mode 100644 index 0000000..21f5a6c --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/security/JwtProperties.kt @@ -0,0 +1,10 @@ +package team.cklob.mudda.global.security + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("jwt") +data class JwtProperties( + val secret: String, + val accessTokenExpiration: Long, + val refreshTokenExpiration: Long, +) diff --git a/src/main/kotlin/team/cklob/mudda/global/security/JwtTokenProvider.kt b/src/main/kotlin/team/cklob/mudda/global/security/JwtTokenProvider.kt new file mode 100644 index 0000000..c42cf8c --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/security/JwtTokenProvider.kt @@ -0,0 +1,24 @@ +package team.cklob.mudda.global.security + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.util.Date + +@Component +class JwtTokenProvider(properties: JwtProperties) { + private val key = Keys.hmacShaKeyFor(properties.secret.toByteArray()) + private val accessExpiration = properties.accessTokenExpiration + private val refreshExpiration = properties.refreshTokenExpiration + + fun createAccessToken(memberId: Long) = createToken(memberId, accessExpiration) + fun createRefreshToken(memberId: Long) = createToken(memberId, refreshExpiration) + fun getMemberId(token: String): Long = claims(token).subject.toLong() + fun validate(token: String): Boolean = runCatching { claims(token) }.isSuccess + + private fun createToken(memberId: Long, expiration: Long): String = Jwts.builder() + .subject(memberId.toString()).issuedAt(Date()).expiration(Date(System.currentTimeMillis() + expiration)) + .signWith(key).compact() + + private fun claims(token: String) = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload +} diff --git a/src/main/kotlin/team/cklob/mudda/global/security/LoginUser.kt b/src/main/kotlin/team/cklob/mudda/global/security/LoginUser.kt new file mode 100644 index 0000000..8c0dd0b --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/security/LoginUser.kt @@ -0,0 +1,5 @@ +package team.cklob.mudda.global.security + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class LoginUser diff --git a/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt b/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt new file mode 100644 index 0000000..2af8980 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt @@ -0,0 +1,16 @@ +package team.cklob.mudda.global.security + +import org.springframework.core.MethodParameter +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +@Component +class LoginUserArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter) = parameter.hasParameterAnnotation(LoginUser::class.java) && parameter.parameterType == Long::class.java + override fun resolveArgument(parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?) = + SecurityContextHolder.getContext().authentication?.principal as? Long +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c2859fa..49ffa05 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -32,3 +32,8 @@ management: health: probes: enabled: true + +jwt: + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} diff --git a/src/test/kotlin/team/cklob/mudda/MuddaApplicationTests.kt b/src/test/kotlin/team/cklob/mudda/MuddaApplicationTests.kt index a0820a3..138e5e1 100644 --- a/src/test/kotlin/team/cklob/mudda/MuddaApplicationTests.kt +++ b/src/test/kotlin/team/cklob/mudda/MuddaApplicationTests.kt @@ -15,6 +15,7 @@ class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer(), JacksonConfig().objectMapper()).valueSerializer as RedisSerializer + val value = CachedValue(1, "capsule") + + assertEquals(value, serializer.deserialize(serializer.serialize(value))) + } + + private data class CachedValue(val id: Long, val name: String) +} diff --git a/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt b/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt new file mode 100644 index 0000000..59208ef --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt @@ -0,0 +1,34 @@ +package team.cklob.mudda.global.config + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext +import com.ninjasquad.springmockk.MockkBean +import team.cklob.mudda.global.security.JwtTokenProvider +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@WebMvcTest(controllers = [SecurityTestController::class], properties = [ + "jwt.secret=local-test-secret-must-be-at-least-32-bytes", +]) +@Import(SecurityConfig::class, JwtTokenProvider::class) +class SecurityConfigTest(@Autowired private val mockMvc: MockMvc, @Autowired private val jwtTokenProvider: JwtTokenProvider) { + @MockkBean lateinit var jpaMappingContext: JpaMetamodelMappingContext + + @Test fun `permits public map path and rejects protected path without authentication`() { + mockMvc.perform(get("/api/v1/maps/ping")).andExpect(status().isOk) + mockMvc.perform(get("/api/v1/private/ping")).andExpect(status().isUnauthorized) + mockMvc.perform(get("/api/v1/private/ping").header("Authorization", "Bearer ${jwtTokenProvider.createAccessToken(1)}")).andExpect(status().isOk) + } +} + +@RestController +class SecurityTestController { + @GetMapping("/api/v1/maps/ping") fun public() = "ok" + @GetMapping("/api/v1/private/ping") fun private() = "ok" +} diff --git a/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt b/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt new file mode 100644 index 0000000..e25276c --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt @@ -0,0 +1,35 @@ +package team.cklob.mudda.global.exception + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders + +class GlobalExceptionHandlerTest { + private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(TestController()).setControllerAdvice(GlobalExceptionHandler()).build() + + @Test fun `maps business validation and unexpected exceptions safely`() { + mockMvc.perform(get("/business")).andExpect(status().isNotFound).andExpect(jsonPath("$.error.code").value("T001")) + mockMvc.perform(post("/valid").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest).andExpect(jsonPath("$.error.code").value("C001")) + mockMvc.perform(get("/unexpected")).andExpect(status().isInternalServerError).andExpect(jsonPath("$.error.message").value("Internal server error.")) + } + + @RestController + private class TestController { + @GetMapping("/business") fun business(): Nothing = throw CapsuleException() + @PostMapping("/valid") fun valid(@Valid @RequestBody body: Body) = body + @GetMapping("/unexpected") fun unexpected(): Nothing = error("boom") + } + private data class Body(@field:NotBlank val value: String?) +} diff --git a/src/test/kotlin/team/cklob/mudda/global/security/JwtTokenProviderTest.kt b/src/test/kotlin/team/cklob/mudda/global/security/JwtTokenProviderTest.kt new file mode 100644 index 0000000..0c22699 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/security/JwtTokenProviderTest.kt @@ -0,0 +1,22 @@ +package team.cklob.mudda.global.security + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class JwtTokenProviderTest { + private val provider = JwtTokenProvider(JwtProperties("test-secret-that-is-at-least-thirty-two-bytes", 60_000, 120_000)) + + @Test fun `creates and validates access and refresh tokens`() { + val access = provider.createAccessToken(1) + val refresh = provider.createRefreshToken(1) + + assertTrue(provider.validate(access)); assertTrue(provider.validate(refresh)); assertEquals(1, provider.getMemberId(access)) + } + + @Test fun `rejects expired token`() { + val expired = JwtTokenProvider(JwtProperties("test-secret-that-is-at-least-thirty-two-bytes", -1, -1)).createAccessToken(1) + assertFalse(provider.validate(expired)) + } +} From 3bdcaf23ea9382f6f0e8ce505f2e694982ed9333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Mon, 20 Jul 2026 10:15:55 +0900 Subject: [PATCH 2/2] fix :: address global configuration review --- .../team/cklob/mudda/global/config/JacksonConfig.kt | 11 ----------- .../team/cklob/mudda/global/config/RedisConfig.kt | 6 +++++- .../team/cklob/mudda/global/config/SecurityConfig.kt | 11 +++++++++-- .../mudda/global/exception/GlobalExceptionHandler.kt | 7 ++++++- .../mudda/global/security/JwtAuthenticationFilter.kt | 2 +- .../global/security/LoginUserArgumentResolver.kt | 8 ++++++-- .../team/cklob/mudda/global/config/RedisConfigTest.kt | 2 +- 7 files changed, 28 insertions(+), 19 deletions(-) delete mode 100644 src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt diff --git a/src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt deleted file mode 100644 index b919f67..0000000 --- a/src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package team.cklob.mudda.global.config - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -@Configuration -class JacksonConfig { - @Bean fun objectMapper(): ObjectMapper = ObjectMapper().registerKotlinModule() -} diff --git a/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt index 0f36a70..09087a8 100644 --- a/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt +++ b/src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt @@ -17,7 +17,11 @@ class RedisConfig { connectionFactory = factory keySerializer = StringRedisSerializer() valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper.copy().activateDefaultTyping( - BasicPolymorphicTypeValidator.builder().allowIfSubType("team.cklob.mudda").build(), + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("team.cklob.mudda") + .allowIfSubType("java.util.") + .allowIfSubType("java.time.") + .build(), ObjectMapper.DefaultTyping.EVERYTHING, JsonTypeInfo.As.PROPERTY, )) diff --git a/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt index 895aac0..8795b63 100644 --- a/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt +++ b/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package team.cklob.mudda.global.config +import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -11,14 +12,20 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic import team.cklob.mudda.global.security.JwtAuthenticationFilter import team.cklob.mudda.global.security.JwtProperties import team.cklob.mudda.global.security.JwtTokenProvider +import team.cklob.mudda.global.exception.ErrorCode +import team.cklob.mudda.global.response.ApiResponse @Configuration @EnableConfigurationProperties(JwtProperties::class) -class SecurityConfig { +class SecurityConfig(private val objectMapper: ObjectMapper) { @Bean fun securityFilterChain(http: HttpSecurity, jwtTokenProvider: JwtTokenProvider): SecurityFilterChain = http .csrf { it.disable() }.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { it.requestMatchers("/api/v1/auth/**", "/api/v1/maps/**", "/actuator/health", "/swagger-ui/**", "/v3/api-docs/**").permitAll().anyRequest().authenticated() } - .exceptionHandling { it.authenticationEntryPoint { _, response, _ -> response.sendError(HttpStatus.UNAUTHORIZED.value()) } } + .exceptionHandling { it.authenticationEntryPoint { _, response, _ -> + response.status = HttpStatus.UNAUTHORIZED.value() + response.contentType = "application/json" + response.writer.write(objectMapper.writeValueAsString(ApiResponse.failure(ErrorCode.UNAUTHORIZED))) + } } .addFilterBefore(JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java).build() } diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt index 53ed5ae..7219472 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt @@ -6,9 +6,11 @@ import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import team.cklob.mudda.global.response.ApiResponse +import org.slf4j.LoggerFactory @RestControllerAdvice class GlobalExceptionHandler { + private val logger = LoggerFactory.getLogger(javaClass) @ExceptionHandler(BusinessException::class) fun handleBusiness(e: BusinessException) = response(e.errorCode) @@ -16,7 +18,10 @@ class GlobalExceptionHandler { fun handleValidation(e: MethodArgumentNotValidException) = response(ErrorCode.INVALID_INPUT) @ExceptionHandler(Exception::class) - fun handleException(e: Exception) = response(ErrorCode.INTERNAL_SERVER_ERROR) + fun handleException(e: Exception): ResponseEntity> { + logger.error("Unexpected exception type: {}", e.javaClass.name) + return response(ErrorCode.INTERNAL_SERVER_ERROR) + } private fun response(errorCode: ErrorCode): ResponseEntity> = ResponseEntity.status(errorCode.status).contentType(MediaType.APPLICATION_JSON).body(ApiResponse.failure(errorCode)) diff --git a/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt index 4887be0..10ba16e 100644 --- a/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/team/cklob/mudda/global/security/JwtAuthenticationFilter.kt @@ -10,7 +10,7 @@ import org.springframework.web.filter.OncePerRequestFilter class JwtAuthenticationFilter(private val jwtTokenProvider: JwtTokenProvider) : OncePerRequestFilter() { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { - request.getHeader("Authorization")?.removePrefix("Bearer ")?.takeIf(jwtTokenProvider::validate)?.let { token -> + request.getHeader("Authorization")?.takeIf { it.startsWith("Bearer ") }?.substring(7)?.takeIf(jwtTokenProvider::validate)?.let { token -> val authentication = UsernamePasswordAuthenticationToken(jwtTokenProvider.getMemberId(token), null, emptyList()) authentication.details = WebAuthenticationDetailsSource().buildDetails(request) SecurityContextHolder.getContext().authentication = authentication diff --git a/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt b/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt index 2af8980..34d7dfc 100644 --- a/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt +++ b/src/main/kotlin/team/cklob/mudda/global/security/LoginUserArgumentResolver.kt @@ -7,10 +7,14 @@ import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer +import team.cklob.mudda.global.exception.AuthException @Component class LoginUserArgumentResolver : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter) = parameter.hasParameterAnnotation(LoginUser::class.java) && parameter.parameterType == Long::class.java - override fun resolveArgument(parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?) = - SecurityContextHolder.getContext().authentication?.principal as? Long + override fun resolveArgument(parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?): Any? { + val principal = SecurityContextHolder.getContext().authentication?.principal as? Long + if (principal == null && !parameter.isOptional) throw AuthException() + return principal + } } diff --git a/src/test/kotlin/team/cklob/mudda/global/config/RedisConfigTest.kt b/src/test/kotlin/team/cklob/mudda/global/config/RedisConfigTest.kt index 854bdc9..068e6bf 100644 --- a/src/test/kotlin/team/cklob/mudda/global/config/RedisConfigTest.kt +++ b/src/test/kotlin/team/cklob/mudda/global/config/RedisConfigTest.kt @@ -8,7 +8,7 @@ import org.springframework.data.redis.serializer.RedisSerializer class RedisConfigTest { @Test fun `serializes Kotlin data classes without field loss`() { - val serializer = RedisConfig().redisTemplate(mockk(), JacksonConfig().objectMapper()).valueSerializer as RedisSerializer + val serializer = RedisConfig().redisTemplate(mockk(), com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()).valueSerializer as RedisSerializer val value = CachedValue(1, "capsule") assertEquals(value, serializer.deserialize(serializer.serialize(value)))